@open-mercato/shared 0.6.8-develop.6891.1.4dca3f1ad3 → 0.6.8-develop.6893.1.7af3b3a72d
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 +5 -0
- package/dist/lib/bootstrap/dynamicLoader.js +40 -4
- package/dist/lib/bootstrap/dynamicLoader.js.map +2 -2
- package/dist/lib/bootstrap/factory.js +18 -13
- package/dist/lib/bootstrap/factory.js.map +2 -2
- package/dist/lib/encryption/find.js +22 -0
- package/dist/lib/encryption/find.js.map +2 -2
- package/dist/lib/encryption/likeFilterWarning.js +66 -0
- package/dist/lib/encryption/likeFilterWarning.js.map +7 -0
- package/dist/lib/i18n/dictionary-cache.js +5 -0
- package/dist/lib/i18n/dictionary-cache.js.map +2 -2
- package/dist/lib/modules/registry.js +31 -5
- package/dist/lib/modules/registry.js.map +2 -2
- package/dist/lib/query/ciphertext-search-warning.js +7 -1
- package/dist/lib/query/ciphertext-search-warning.js.map +2 -2
- package/dist/lib/version.js +1 -1
- package/dist/lib/version.js.map +1 -1
- package/package.json +2 -2
- package/src/lib/bootstrap/__tests__/dynamicLoader.test.ts +126 -0
- package/src/lib/bootstrap/__tests__/factory.test.ts +78 -0
- package/src/lib/bootstrap/dynamicLoader.ts +52 -4
- package/src/lib/bootstrap/factory.ts +21 -15
- package/src/lib/bootstrap/types.ts +6 -0
- package/src/lib/encryption/__tests__/likeFilterWarning.test.ts +167 -0
- package/src/lib/encryption/find.ts +22 -0
- package/src/lib/encryption/likeFilterWarning.ts +102 -0
- package/src/lib/i18n/__tests__/dictionary-cache.test.ts +23 -0
- package/src/lib/i18n/dictionary-cache.ts +5 -0
- package/src/lib/modules/__tests__/registry.test.ts +26 -0
- package/src/lib/modules/registry.ts +36 -7
- package/src/lib/query/ciphertext-search-warning.ts +17 -6
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import { collectLikeFilterFields, warnOnEncryptedLikeFilter } from '../likeFilterWarning'
|
|
2
|
+
import { registerEntityIds } from '../entityIds'
|
|
3
|
+
import { resetCiphertextLikeWarnCache } from '../../query/ciphertext-search-warning'
|
|
4
|
+
|
|
5
|
+
jest.mock('../../logger', () => {
|
|
6
|
+
const warn = jest.fn()
|
|
7
|
+
const debug = jest.fn()
|
|
8
|
+
const child = jest.fn(() => ({ warn, debug }))
|
|
9
|
+
return { createLogger: jest.fn(() => ({ child })), __warn: warn, __debug: debug }
|
|
10
|
+
})
|
|
11
|
+
|
|
12
|
+
jest.mock('../customFieldValues', () => ({
|
|
13
|
+
resolveTenantEncryptionService: jest.fn(() => null),
|
|
14
|
+
}))
|
|
15
|
+
|
|
16
|
+
const loggerModule = jest.requireMock('../../logger') as { __warn: jest.Mock }
|
|
17
|
+
|
|
18
|
+
describe('collectLikeFilterFields', () => {
|
|
19
|
+
it('finds string operators on a top-level property', () => {
|
|
20
|
+
expect(collectLikeFilterFields({ title: { $ilike: '%q%' } })).toEqual(['title'])
|
|
21
|
+
expect(collectLikeFilterFields({ title: { $like: 'q%' } })).toEqual(['title'])
|
|
22
|
+
expect(collectLikeFilterFields({ title: { $re: '^q' } })).toEqual(['title'])
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
it('ignores filters that do not use a string operator', () => {
|
|
26
|
+
expect(collectLikeFilterFields({ title: 'Renewal', status: { $in: ['open'] } })).toEqual([])
|
|
27
|
+
expect(collectLikeFilterFields({ createdAt: { $gte: new Date(0) } })).toEqual([])
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
it('descends into $and, $or and $not branches', () => {
|
|
31
|
+
const where = {
|
|
32
|
+
$and: [
|
|
33
|
+
{ tenantId: 't1' },
|
|
34
|
+
{ $or: [{ title: { $ilike: '%q%' } }, { subject: { $ilike: '%q%' } }] },
|
|
35
|
+
],
|
|
36
|
+
}
|
|
37
|
+
expect(collectLikeFilterFields(where).sort()).toEqual(['subject', 'title'])
|
|
38
|
+
expect(collectLikeFilterFields({ title: { $not: { $ilike: '%q%' } } })).toEqual(['title'])
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
it('does not attribute a nested relation filter to the root entity', () => {
|
|
42
|
+
expect(collectLikeFilterFields({ customer: { displayName: { $ilike: '%q%' } } })).toEqual([])
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
it('tolerates empty, null and non-object filters', () => {
|
|
46
|
+
expect(collectLikeFilterFields(undefined)).toEqual([])
|
|
47
|
+
expect(collectLikeFilterFields(null)).toEqual([])
|
|
48
|
+
expect(collectLikeFilterFields('id-1')).toEqual([])
|
|
49
|
+
expect(collectLikeFilterFields({})).toEqual([])
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
it('reports each property once regardless of how many branches match', () => {
|
|
53
|
+
const where = { $or: [{ title: { $ilike: '%a%' } }, { title: { $ilike: '%b%' } }] }
|
|
54
|
+
expect(collectLikeFilterFields(where)).toEqual(['title'])
|
|
55
|
+
})
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
describe('warnOnEncryptedLikeFilter', () => {
|
|
59
|
+
const originalNodeEnv = process.env.NODE_ENV
|
|
60
|
+
|
|
61
|
+
const makeEm = () => ({
|
|
62
|
+
getMetadata: () => ({ find: (name: string) => (name === 'MailMessage' ? { className: 'MailMessage' } : undefined) }),
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
const makeService = (encryptedFields: string[]) => ({
|
|
66
|
+
isEnabled: () => true,
|
|
67
|
+
getEncryptedFieldNames: jest.fn(async () => encryptedFields),
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
beforeAll(() => {
|
|
71
|
+
registerEntityIds({ mail: { mail_message: 'mail:mail_message' } })
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
beforeEach(() => {
|
|
75
|
+
resetCiphertextLikeWarnCache()
|
|
76
|
+
loggerModule.__warn.mockClear()
|
|
77
|
+
Object.defineProperty(process.env, 'NODE_ENV', { value: 'test', configurable: true })
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
afterAll(() => {
|
|
81
|
+
Object.defineProperty(process.env, 'NODE_ENV', { value: originalNodeEnv, configurable: true })
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
it('warns when the filtered property is covered by an encryption map', async () => {
|
|
85
|
+
await warnOnEncryptedLikeFilter({
|
|
86
|
+
em: makeEm() as any,
|
|
87
|
+
entityName: 'MailMessage',
|
|
88
|
+
where: { subject: { $ilike: '%invoice%' } },
|
|
89
|
+
tenantId: 'tenant-1',
|
|
90
|
+
encryptionService: makeService(['subject']) as any,
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
expect(loggerModule.__warn).toHaveBeenCalledTimes(1)
|
|
94
|
+
expect(loggerModule.__warn.mock.calls[0][1]).toMatchObject({
|
|
95
|
+
entity: 'mail:mail_message',
|
|
96
|
+
field: 'subject',
|
|
97
|
+
reason: 'raw-orm-filter',
|
|
98
|
+
})
|
|
99
|
+
expect(loggerModule.__warn.mock.calls[0][1].hint).toContain('findEntityIdsBySearchTokens')
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
it('stays quiet for properties outside the encryption map', async () => {
|
|
103
|
+
await warnOnEncryptedLikeFilter({
|
|
104
|
+
em: makeEm() as any,
|
|
105
|
+
entityName: 'MailMessage',
|
|
106
|
+
where: { subject: { $ilike: '%invoice%' } },
|
|
107
|
+
tenantId: 'tenant-1',
|
|
108
|
+
encryptionService: makeService(['body']) as any,
|
|
109
|
+
})
|
|
110
|
+
expect(loggerModule.__warn).not.toHaveBeenCalled()
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
it('skips the encryption lookup entirely in production', async () => {
|
|
114
|
+
Object.defineProperty(process.env, 'NODE_ENV', { value: 'production', configurable: true })
|
|
115
|
+
const service = makeService(['subject'])
|
|
116
|
+
await warnOnEncryptedLikeFilter({
|
|
117
|
+
em: makeEm() as any,
|
|
118
|
+
entityName: 'MailMessage',
|
|
119
|
+
where: { subject: { $ilike: '%invoice%' } },
|
|
120
|
+
tenantId: 'tenant-1',
|
|
121
|
+
encryptionService: service as any,
|
|
122
|
+
})
|
|
123
|
+
expect(service.getEncryptedFieldNames).not.toHaveBeenCalled()
|
|
124
|
+
expect(loggerModule.__warn).not.toHaveBeenCalled()
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
it('skips the encryption lookup when no string operator is present', async () => {
|
|
128
|
+
const service = makeService(['subject'])
|
|
129
|
+
await warnOnEncryptedLikeFilter({
|
|
130
|
+
em: makeEm() as any,
|
|
131
|
+
entityName: 'MailMessage',
|
|
132
|
+
where: { subject: 'Invoice' },
|
|
133
|
+
tenantId: 'tenant-1',
|
|
134
|
+
encryptionService: service as any,
|
|
135
|
+
})
|
|
136
|
+
expect(service.getEncryptedFieldNames).not.toHaveBeenCalled()
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
it('stays quiet when the entity id cannot be resolved', async () => {
|
|
140
|
+
await warnOnEncryptedLikeFilter({
|
|
141
|
+
em: makeEm() as any,
|
|
142
|
+
entityName: 'UnknownEntity',
|
|
143
|
+
where: { subject: { $ilike: '%invoice%' } },
|
|
144
|
+
tenantId: 'tenant-1',
|
|
145
|
+
encryptionService: makeService(['subject']) as any,
|
|
146
|
+
})
|
|
147
|
+
expect(loggerModule.__warn).not.toHaveBeenCalled()
|
|
148
|
+
})
|
|
149
|
+
|
|
150
|
+
it('never rethrows when metadata access fails', async () => {
|
|
151
|
+
const em = {
|
|
152
|
+
getMetadata: () => {
|
|
153
|
+
throw new Error('orm not initialized')
|
|
154
|
+
},
|
|
155
|
+
}
|
|
156
|
+
await expect(
|
|
157
|
+
warnOnEncryptedLikeFilter({
|
|
158
|
+
em: em as any,
|
|
159
|
+
entityName: 'MailMessage',
|
|
160
|
+
where: { subject: { $ilike: '%invoice%' } },
|
|
161
|
+
tenantId: 'tenant-1',
|
|
162
|
+
encryptionService: makeService(['subject']) as any,
|
|
163
|
+
}),
|
|
164
|
+
).resolves.toBeUndefined()
|
|
165
|
+
expect(loggerModule.__warn).not.toHaveBeenCalled()
|
|
166
|
+
})
|
|
167
|
+
})
|
|
@@ -5,6 +5,7 @@ import type {
|
|
|
5
5
|
FindOneOptions,
|
|
6
6
|
FindOptions,
|
|
7
7
|
} from '@mikro-orm/postgresql'
|
|
8
|
+
import { warnOnEncryptedLikeFilter } from './likeFilterWarning'
|
|
8
9
|
import { decryptEntitiesWithFallbackScope } from './subscriber'
|
|
9
10
|
import type { TenantDataEncryptionService } from './tenantDataEncryptionService'
|
|
10
11
|
|
|
@@ -24,6 +25,13 @@ export async function findWithDecryption<Entity extends object, Hint extends str
|
|
|
24
25
|
options?: AnyFindOptions<Entity, Hint>,
|
|
25
26
|
scope?: DecryptionScope,
|
|
26
27
|
): Promise<Entity[]> {
|
|
28
|
+
await warnOnEncryptedLikeFilter({
|
|
29
|
+
em,
|
|
30
|
+
entityName,
|
|
31
|
+
where,
|
|
32
|
+
tenantId: scope?.tenantId,
|
|
33
|
+
encryptionService: scope?.encryptionService,
|
|
34
|
+
})
|
|
27
35
|
const records = (await em.find<Entity, Hint, any, any>(entityName as any, where as any, options as any)) as any as
|
|
28
36
|
| Entity[]
|
|
29
37
|
| undefined
|
|
@@ -44,6 +52,13 @@ export async function findOneWithDecryption<Entity extends object, Hint extends
|
|
|
44
52
|
options?: AnyFindOneOptions<Entity, Hint>,
|
|
45
53
|
scope?: DecryptionScope,
|
|
46
54
|
): Promise<Entity | null> {
|
|
55
|
+
await warnOnEncryptedLikeFilter({
|
|
56
|
+
em,
|
|
57
|
+
entityName,
|
|
58
|
+
where,
|
|
59
|
+
tenantId: scope?.tenantId,
|
|
60
|
+
encryptionService: scope?.encryptionService,
|
|
61
|
+
})
|
|
47
62
|
const record = (await em.findOne<Entity, Hint, any, any>(entityName as any, where as any, options as any)) as any as
|
|
48
63
|
| Entity
|
|
49
64
|
| null
|
|
@@ -64,6 +79,13 @@ export async function findAndCountWithDecryption<Entity extends object, Hint ext
|
|
|
64
79
|
options?: AnyFindOptions<Entity, Hint>,
|
|
65
80
|
scope?: DecryptionScope,
|
|
66
81
|
): Promise<[Entity[], number]> {
|
|
82
|
+
await warnOnEncryptedLikeFilter({
|
|
83
|
+
em,
|
|
84
|
+
entityName,
|
|
85
|
+
where,
|
|
86
|
+
tenantId: scope?.tenantId,
|
|
87
|
+
encryptionService: scope?.encryptionService,
|
|
88
|
+
})
|
|
67
89
|
const [recordsRaw, count] = await em.findAndCount<Entity, Hint, any, any>(
|
|
68
90
|
entityName as any,
|
|
69
91
|
where as any,
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import type { EntityManager, EntityName } from '@mikro-orm/postgresql'
|
|
2
|
+
import { warnOnCiphertextLikeFallback } from '../query/ciphertext-search-warning'
|
|
3
|
+
import { resolveTenantEncryptionService } from './customFieldValues'
|
|
4
|
+
import { resolveEntityIdFromMetadata } from './entityIds'
|
|
5
|
+
import type { TenantDataEncryptionService } from './tenantDataEncryptionService'
|
|
6
|
+
|
|
7
|
+
const LIKE_OPERATORS = new Set(['$like', '$ilike', '$re', '$fulltext'])
|
|
8
|
+
const LOGICAL_OPERATORS = new Set(['$and', '$or', '$not', '$every', '$some', '$none'])
|
|
9
|
+
|
|
10
|
+
const isPlainObject = (value: unknown): value is Record<string, unknown> =>
|
|
11
|
+
typeof value === 'object' && value !== null && !Array.isArray(value) && !(value instanceof Date)
|
|
12
|
+
|
|
13
|
+
// An operator object holds only `$`-prefixed keys. Anything else is a nested
|
|
14
|
+
// relation filter, whose property names belong to another entity's encryption
|
|
15
|
+
// map — attributing them to the root entity would produce false warnings.
|
|
16
|
+
const isOperatorObject = (value: unknown): value is Record<string, unknown> => {
|
|
17
|
+
if (!isPlainObject(value)) return false
|
|
18
|
+
const keys = Object.keys(value)
|
|
19
|
+
return keys.length > 0 && keys.every((key) => key.startsWith('$'))
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function walkFilter(node: unknown, field: string | null, out: Set<string>): void {
|
|
23
|
+
if (Array.isArray(node)) {
|
|
24
|
+
for (const item of node) walkFilter(item, field, out)
|
|
25
|
+
return
|
|
26
|
+
}
|
|
27
|
+
if (!isPlainObject(node)) return
|
|
28
|
+
for (const [key, value] of Object.entries(node)) {
|
|
29
|
+
if (LIKE_OPERATORS.has(key)) {
|
|
30
|
+
if (field) out.add(field)
|
|
31
|
+
continue
|
|
32
|
+
}
|
|
33
|
+
if (LOGICAL_OPERATORS.has(key)) {
|
|
34
|
+
walkFilter(value, field, out)
|
|
35
|
+
continue
|
|
36
|
+
}
|
|
37
|
+
if (key.startsWith('$')) continue
|
|
38
|
+
if (isOperatorObject(value)) walkFilter(value, key, out)
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Lists the root-entity properties a filter targets with a string-matching
|
|
44
|
+
* operator (`$like`, `$ilike`, `$re`, `$fulltext`), including inside
|
|
45
|
+
* `$and`/`$or`/`$not` branches.
|
|
46
|
+
*/
|
|
47
|
+
export function collectLikeFilterFields(where: unknown): string[] {
|
|
48
|
+
const found = new Set<string>()
|
|
49
|
+
walkFilter(where, null, found)
|
|
50
|
+
return Array.from(found)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// The map lookup behind the warning issues an uncached read against
|
|
54
|
+
// `encryption_maps`, and this path runs on every matching query rather than in a
|
|
55
|
+
// rare fallback branch — so the check stays out of production entirely.
|
|
56
|
+
const isWarningEnabled = (): boolean => process.env.NODE_ENV !== 'production'
|
|
57
|
+
|
|
58
|
+
function resolveEntityId(em: EntityManager, entityName: EntityName<any>): string | null {
|
|
59
|
+
const metadata = (em as any)?.getMetadata?.()
|
|
60
|
+
if (!metadata || typeof metadata.find !== 'function') return null
|
|
61
|
+
const name = typeof entityName === 'string' ? entityName : (entityName as any)?.name
|
|
62
|
+
if (!name) return null
|
|
63
|
+
return resolveEntityIdFromMetadata(metadata.find(name) ?? undefined)
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Warn, outside production, when a `where` clause matches an encrypted-at-rest
|
|
68
|
+
* column with a string operator.
|
|
69
|
+
*
|
|
70
|
+
* Decryption happens on load, not on filter, so the predicate compares a
|
|
71
|
+
* plaintext pattern against ciphertext: it matches nothing and does not raise,
|
|
72
|
+
* which is indistinguishable from a genuine empty result. The query engines
|
|
73
|
+
* already warn about their own `ILIKE` fallback; this covers the raw-ORM path
|
|
74
|
+
* that never reaches them. Issue #5051, related to #2990.
|
|
75
|
+
*
|
|
76
|
+
* Never throws and never blocks the query.
|
|
77
|
+
*/
|
|
78
|
+
export async function warnOnEncryptedLikeFilter(params: {
|
|
79
|
+
em: EntityManager
|
|
80
|
+
entityName: EntityName<any>
|
|
81
|
+
where: unknown
|
|
82
|
+
tenantId?: string | null
|
|
83
|
+
encryptionService?: TenantDataEncryptionService | null
|
|
84
|
+
}): Promise<void> {
|
|
85
|
+
if (!isWarningEnabled()) return
|
|
86
|
+
const fields = collectLikeFilterFields(params.where)
|
|
87
|
+
if (!fields.length) return
|
|
88
|
+
try {
|
|
89
|
+
const entity = resolveEntityId(params.em, params.entityName)
|
|
90
|
+
if (!entity) return
|
|
91
|
+
const service = params.encryptionService ?? resolveTenantEncryptionService(params.em)
|
|
92
|
+
await warnOnCiphertextLikeFallback({
|
|
93
|
+
entity,
|
|
94
|
+
fields,
|
|
95
|
+
tenantId: params.tenantId ?? null,
|
|
96
|
+
reason: 'raw-orm-filter',
|
|
97
|
+
service,
|
|
98
|
+
})
|
|
99
|
+
} catch {
|
|
100
|
+
// A diagnostic must never break the query it is diagnosing.
|
|
101
|
+
}
|
|
102
|
+
}
|
|
@@ -41,6 +41,29 @@ describe('i18n dictionary memoization', () => {
|
|
|
41
41
|
expect(loader).toHaveBeenCalledTimes(2)
|
|
42
42
|
})
|
|
43
43
|
|
|
44
|
+
it('keeps other locale dictionaries cached after locale-scoped module registration', async () => {
|
|
45
|
+
const loader = jest.fn(async (locale: Locale) => ({ greeting: locale }))
|
|
46
|
+
registerAppDictionaryLoader(loader)
|
|
47
|
+
registerModules([
|
|
48
|
+
{ id: 'demo', translations: { en: { module: 'English' }, pl: { module: 'Polski' } } },
|
|
49
|
+
] as any)
|
|
50
|
+
|
|
51
|
+
const englishBefore = await loadDictionary('en')
|
|
52
|
+
const polishBefore = await loadDictionary('pl')
|
|
53
|
+
|
|
54
|
+
registerModules([
|
|
55
|
+
{ id: 'demo', translations: { pl: { module: 'Polski 2' } } },
|
|
56
|
+
] as any)
|
|
57
|
+
|
|
58
|
+
const englishAfter = await loadDictionary('en')
|
|
59
|
+
const polishAfter = await loadDictionary('pl')
|
|
60
|
+
|
|
61
|
+
expect(englishAfter).toBe(englishBefore)
|
|
62
|
+
expect(polishAfter).not.toBe(polishBefore)
|
|
63
|
+
expect(polishAfter.module).toBe('Polski 2')
|
|
64
|
+
expect(loader).toHaveBeenCalledTimes(3)
|
|
65
|
+
})
|
|
66
|
+
|
|
44
67
|
it('rebuilds after modules are re-registered', async () => {
|
|
45
68
|
registerAppDictionaryLoader(async () => ({}))
|
|
46
69
|
registerModules([{ translations: { en: { a: 'one' } } }] as any)
|
|
@@ -33,3 +33,8 @@ export function setCachedDictionary(locale: Locale, dict: Dict): void {
|
|
|
33
33
|
export function invalidateDictionaryCache(): void {
|
|
34
34
|
getCache().clear()
|
|
35
35
|
}
|
|
36
|
+
|
|
37
|
+
export function invalidateDictionaryCacheLocales(locales: readonly Locale[]): void {
|
|
38
|
+
const cache = getCache()
|
|
39
|
+
for (const locale of locales) cache.delete(locale)
|
|
40
|
+
}
|
|
@@ -145,4 +145,30 @@ describe('shared modules registry', () => {
|
|
|
145
145
|
}),
|
|
146
146
|
])
|
|
147
147
|
})
|
|
148
|
+
|
|
149
|
+
it('preserves translations when runtime modules register after locale shards', () => {
|
|
150
|
+
const registry = loadRegistry()
|
|
151
|
+
const handler = jest.fn()
|
|
152
|
+
registry.registerModules([
|
|
153
|
+
{
|
|
154
|
+
id: 'checkout',
|
|
155
|
+
translations: { pl: { title: 'Kasa' } },
|
|
156
|
+
} as Module,
|
|
157
|
+
])
|
|
158
|
+
|
|
159
|
+
registry.registerModules([
|
|
160
|
+
{
|
|
161
|
+
id: 'checkout',
|
|
162
|
+
subscribers: [{ id: 'checkout-gateway-payment-failed', event: 'payment_gateways.payment.failed', handler }],
|
|
163
|
+
} as Module,
|
|
164
|
+
])
|
|
165
|
+
|
|
166
|
+
expect(registry.getModules()).toEqual([
|
|
167
|
+
expect.objectContaining({
|
|
168
|
+
id: 'checkout',
|
|
169
|
+
subscribers: [expect.objectContaining({ id: 'checkout-gateway-payment-failed' })],
|
|
170
|
+
translations: { pl: { title: 'Kasa' } },
|
|
171
|
+
}),
|
|
172
|
+
])
|
|
173
|
+
})
|
|
148
174
|
})
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { Module } from '@open-mercato/shared/modules/registry'
|
|
2
2
|
import { applyModuleOverridesToModules } from '@open-mercato/shared/modules/overrides'
|
|
3
|
-
import {
|
|
3
|
+
import type { Locale } from '../i18n/config'
|
|
4
|
+
import { invalidateDictionaryCache, invalidateDictionaryCacheLocales } from '../i18n/dictionary-cache'
|
|
4
5
|
import { createLogger } from '../logger'
|
|
5
6
|
|
|
6
7
|
const logger = createLogger('shared').child({ component: 'modules-registry' })
|
|
@@ -38,7 +39,10 @@ function mergeI18nModules(existing: Module[], incoming: Module[]): Module[] {
|
|
|
38
39
|
if (!i18nModule?.translations) return entry
|
|
39
40
|
return {
|
|
40
41
|
...entry,
|
|
41
|
-
translations:
|
|
42
|
+
translations: {
|
|
43
|
+
...(entry.translations ?? {}),
|
|
44
|
+
...i18nModule.translations,
|
|
45
|
+
},
|
|
42
46
|
}
|
|
43
47
|
})
|
|
44
48
|
|
|
@@ -49,17 +53,42 @@ function mergeI18nModules(existing: Module[], incoming: Module[]): Module[] {
|
|
|
49
53
|
return merged
|
|
50
54
|
}
|
|
51
55
|
|
|
56
|
+
function getTranslationLocales(modules: Module[]): Locale[] {
|
|
57
|
+
const locales = new Set<Locale>()
|
|
58
|
+
for (const entry of modules) {
|
|
59
|
+
for (const locale of Object.keys(entry.translations ?? {})) {
|
|
60
|
+
locales.add(locale as Locale)
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
return [...locales]
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function preserveExistingTranslations(existing: Module[], incoming: Module[]): Module[] {
|
|
67
|
+
const existingById = new Map(existing.map((entry) => [entry.id, entry]))
|
|
68
|
+
return incoming.map((entry) => {
|
|
69
|
+
if (entry.translations) return entry
|
|
70
|
+
const translations = existingById.get(entry.id)?.translations
|
|
71
|
+
return translations ? { ...entry, translations } : entry
|
|
72
|
+
})
|
|
73
|
+
}
|
|
74
|
+
|
|
52
75
|
export function registerModules(modules: Module[]) {
|
|
53
76
|
const existing = getGlobalModules()
|
|
54
77
|
if (existing !== null && process.env.NODE_ENV === 'development') {
|
|
55
78
|
logger.debug('Modules re-registered (this may occur during HMR)')
|
|
56
79
|
}
|
|
57
80
|
const nextModules = applyModuleOverridesToModules(modules)
|
|
58
|
-
const
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
81
|
+
const i18nOnlyRegistration = isI18nOnlyRegistration(nextModules)
|
|
82
|
+
const shouldMergeI18nOnly = existing !== null && i18nOnlyRegistration
|
|
83
|
+
const registeredModules = shouldMergeI18nOnly
|
|
84
|
+
? mergeI18nModules(existing, nextModules)
|
|
85
|
+
: preserveExistingTranslations(existing ?? [], nextModules)
|
|
86
|
+
setGlobalModules(registeredModules)
|
|
87
|
+
if (i18nOnlyRegistration) {
|
|
88
|
+
invalidateDictionaryCacheLocales(getTranslationLocales(nextModules))
|
|
89
|
+
} else {
|
|
90
|
+
invalidateDictionaryCache()
|
|
91
|
+
}
|
|
63
92
|
}
|
|
64
93
|
|
|
65
94
|
export function getModules(): Module[] {
|
|
@@ -16,7 +16,22 @@ export type CiphertextWarningEncryptionService = {
|
|
|
16
16
|
isEnabled?: () => boolean
|
|
17
17
|
} | null | undefined
|
|
18
18
|
|
|
19
|
-
export type CiphertextLikeFallbackReason =
|
|
19
|
+
export type CiphertextLikeFallbackReason =
|
|
20
|
+
| 'no-indexable-tokens'
|
|
21
|
+
| 'no-search-tokens'
|
|
22
|
+
| 'search-disabled'
|
|
23
|
+
| 'raw-orm-filter'
|
|
24
|
+
|
|
25
|
+
const REASON_HINTS: Record<CiphertextLikeFallbackReason, string> = {
|
|
26
|
+
'search-disabled':
|
|
27
|
+
'OM_SEARCH_ENABLED is off, so the filter runs as ILIKE against ciphertext and matches nothing.',
|
|
28
|
+
'no-indexable-tokens':
|
|
29
|
+
'The filter value produced no search tokens, so it runs as ILIKE against ciphertext and matches nothing. Use at least OM_SEARCH_MIN_LEN indexable characters, or filter on the deterministic hash column.',
|
|
30
|
+
'no-search-tokens':
|
|
31
|
+
'No search_tokens exist for this entity and scope, so the filter runs as ILIKE against ciphertext and matches nothing. Index the entity through query_index and reindex, or filter on the deterministic hash column.',
|
|
32
|
+
'raw-orm-filter':
|
|
33
|
+
'This filter bypasses the query engine, so it runs as ILIKE against ciphertext and matches nothing. Resolve ids through findEntityIdsBySearchTokens (@open-mercato/shared/lib/search/tokenLookup), filter on the deterministic hash column, or compare in memory after decryption.',
|
|
34
|
+
}
|
|
20
35
|
|
|
21
36
|
// One warning per entity/tenant/field per process — the fallback repeats on
|
|
22
37
|
// every request, and an operator only needs to learn about it once.
|
|
@@ -79,11 +94,7 @@ export async function warnOnCiphertextLikeFallback(params: {
|
|
|
79
94
|
entity,
|
|
80
95
|
field,
|
|
81
96
|
reason,
|
|
82
|
-
hint: reason
|
|
83
|
-
? 'OM_SEARCH_ENABLED is off, so the filter runs as ILIKE against ciphertext and matches nothing.'
|
|
84
|
-
: reason === 'no-indexable-tokens'
|
|
85
|
-
? 'The filter value produced no search tokens, so it runs as ILIKE against ciphertext and matches nothing. Use at least OM_SEARCH_MIN_LEN indexable characters, or filter on the deterministic hash column.'
|
|
86
|
-
: 'No search_tokens exist for this entity and scope, so the filter runs as ILIKE against ciphertext and matches nothing. Index the entity through query_index and reindex, or filter on the deterministic hash column.',
|
|
97
|
+
hint: REASON_HINTS[reason],
|
|
87
98
|
})
|
|
88
99
|
}
|
|
89
100
|
} catch (err) {
|