@open-mercato/shared 0.6.7-develop.6795.1.8a3f27921c → 0.6.7-develop.6824.1.21fff90682

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 (44) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/dist/lib/crud/factory.js +3 -2
  3. package/dist/lib/crud/factory.js.map +2 -2
  4. package/dist/lib/di/container.js +33 -3
  5. package/dist/lib/di/container.js.map +2 -2
  6. package/dist/lib/email/send.js +56 -0
  7. package/dist/lib/email/send.js.map +2 -2
  8. package/dist/lib/query/types.js.map +1 -1
  9. package/dist/lib/search/config.js +24 -2
  10. package/dist/lib/search/config.js.map +2 -2
  11. package/dist/lib/search/tokenize.js +23 -11
  12. package/dist/lib/search/tokenize.js.map +2 -2
  13. package/dist/lib/version.js +1 -1
  14. package/dist/lib/version.js.map +1 -1
  15. package/dist/lib/webhooks/body.js +57 -0
  16. package/dist/lib/webhooks/body.js.map +7 -0
  17. package/dist/lib/webhooks/index.js +13 -1
  18. package/dist/lib/webhooks/index.js.map +2 -2
  19. package/dist/lib/webhooks/verify.js +12 -7
  20. package/dist/lib/webhooks/verify.js.map +2 -2
  21. package/dist/modules/payment_gateways/types.js +8 -1
  22. package/dist/modules/payment_gateways/types.js.map +2 -2
  23. package/dist/modules/widgets/extension-points.js.map +1 -1
  24. package/package.json +2 -2
  25. package/src/lib/crud/__tests__/crud-factory.test.ts +79 -0
  26. package/src/lib/crud/factory.ts +2 -1
  27. package/src/lib/di/__tests__/container-app-di-absent.test.ts +139 -0
  28. package/src/lib/di/__tests__/container-app-di.test.ts +173 -0
  29. package/src/lib/di/container.ts +45 -4
  30. package/src/lib/email/__tests__/send.test.ts +37 -1
  31. package/src/lib/email/send.ts +79 -0
  32. package/src/lib/query/types.ts +4 -5
  33. package/src/lib/search/__tests__/config.test.ts +59 -0
  34. package/src/lib/search/__tests__/tokenize.test.ts +49 -0
  35. package/src/lib/search/config.ts +28 -0
  36. package/src/lib/search/tokenize.ts +33 -11
  37. package/src/lib/webhooks/__tests__/body.test.ts +95 -0
  38. package/src/lib/webhooks/__tests__/verify.test.ts +20 -1
  39. package/src/lib/webhooks/body.ts +70 -0
  40. package/src/lib/webhooks/index.ts +7 -1
  41. package/src/lib/webhooks/verify.ts +17 -9
  42. package/src/modules/payment_gateways/__tests__/types.test.ts +37 -0
  43. package/src/modules/payment_gateways/types.ts +15 -0
  44. package/src/modules/widgets/extension-points.ts +104 -1
@@ -0,0 +1,173 @@
1
+ // Tests for the app-level DI override hook (`src/di.ts` → `@/di`) in
2
+ // `createRequestContainer()`. The hook used to swallow EVERY failure with
3
+ // bare `catch {}` blocks, so a src/di.ts whose register() threw — or a @/di
4
+ // module that failed to load for any reason other than being absent — was
5
+ // skipped without a trace and the documented override point silently did
6
+ // nothing. Absence of the optional module must stay quiet; real failures
7
+ // must be logged.
8
+
9
+ import { asValue } from 'awilix'
10
+
11
+ jest.mock(
12
+ '@open-mercato/shared/lib/db/mikro',
13
+ () => {
14
+ const baseEm: any = {
15
+ fork: () => baseEm,
16
+ getRepository: () => ({ find: async () => [], findOne: async () => null }),
17
+ }
18
+ return {
19
+ __esModule: true,
20
+ getOrm: async () => ({ em: baseEm }),
21
+ getOrmEntities: () => [],
22
+ registerOrmEntities: () => {},
23
+ }
24
+ },
25
+ { virtual: false },
26
+ )
27
+
28
+ jest.mock(
29
+ '@mikro-orm/core',
30
+ () => ({
31
+ __esModule: true,
32
+ RequestContext: { getEntityManager: () => null },
33
+ }),
34
+ { virtual: true },
35
+ )
36
+
37
+ jest.mock(
38
+ '@open-mercato/shared/lib/query/engine',
39
+ () => ({ __esModule: true, BasicQueryEngine: class {} }),
40
+ { virtual: false },
41
+ )
42
+
43
+ jest.mock(
44
+ '@open-mercato/shared/lib/data/engine',
45
+ () => ({ __esModule: true, DefaultDataEngine: class {} }),
46
+ { virtual: false },
47
+ )
48
+
49
+ jest.mock(
50
+ '@open-mercato/shared/lib/commands',
51
+ () => ({
52
+ __esModule: true,
53
+ commandRegistry: {},
54
+ CommandBus: class { constructor() {} },
55
+ }),
56
+ { virtual: false },
57
+ )
58
+
59
+ jest.mock(
60
+ '@open-mercato/shared/modules/overrides',
61
+ () => ({
62
+ __esModule: true,
63
+ applyDiOverridesToContainer: () => {},
64
+ }),
65
+ { virtual: false },
66
+ )
67
+
68
+ // Keep this suite hermetic: without this mock the REAL core bootstrap module
69
+ // loads (and runs its module-level side effects) whenever generated files
70
+ // exist on disk, polluting globalThis state shared with sibling test files.
71
+ jest.mock(
72
+ '@open-mercato/core/bootstrap',
73
+ () => ({
74
+ __esModule: true,
75
+ bootstrap: async () => {},
76
+ }),
77
+ { virtual: true },
78
+ )
79
+
80
+ jest.mock(
81
+ '@open-mercato/shared/lib/encryption/subscriber',
82
+ () => ({
83
+ __esModule: true,
84
+ registerTenantEncryptionSubscriber: () => {},
85
+ }),
86
+ { virtual: true },
87
+ )
88
+
89
+ const mockWarn = jest.fn()
90
+ jest.mock('../../logger', () => ({
91
+ createLogger: () => ({
92
+ debug: jest.fn(),
93
+ info: jest.fn(),
94
+ warn: (...args: unknown[]) => mockWarn(...args),
95
+ error: jest.fn(),
96
+ child() { return this },
97
+ }),
98
+ }))
99
+
100
+ const mockAppDiRegister = jest.fn()
101
+ jest.mock(
102
+ '@/di',
103
+ () => ({
104
+ __esModule: true,
105
+ register: (...args: unknown[]) => mockAppDiRegister(...args),
106
+ }),
107
+ { virtual: true },
108
+ )
109
+
110
+ const {
111
+ createRequestContainer,
112
+ registerAppDiRegistrar,
113
+ registerDiRegistrars,
114
+ resetBootstrapCache,
115
+ } = require('@open-mercato/shared/lib/di/container')
116
+
117
+ describe('app-level DI override hook (@/di)', () => {
118
+ beforeEach(() => {
119
+ resetBootstrapCache()
120
+ registerAppDiRegistrar(null)
121
+ registerDiRegistrars([])
122
+ mockWarn.mockClear()
123
+ mockAppDiRegister.mockReset()
124
+ })
125
+
126
+ it('calls register() with the request container and applies its registrations', async () => {
127
+ mockAppDiRegister.mockImplementation((container: any) => {
128
+ container.register({ appOverrideProbe: asValue('from-app-di') })
129
+ })
130
+ const container = await createRequestContainer()
131
+ expect(mockAppDiRegister).toHaveBeenCalledTimes(1)
132
+ expect(mockAppDiRegister).toHaveBeenCalledWith(container)
133
+ expect(container.resolve('appOverrideProbe')).toBe('from-app-di')
134
+ expect(mockWarn).not.toHaveBeenCalled()
135
+ })
136
+
137
+ it('awaits an async register()', async () => {
138
+ mockAppDiRegister.mockImplementation(async (container: any) => {
139
+ await Promise.resolve()
140
+ container.register({ appOverrideProbe: asValue('async-app-di') })
141
+ })
142
+ const container = await createRequestContainer()
143
+ expect(container.resolve('appOverrideProbe')).toBe('async-app-di')
144
+ expect(mockWarn).not.toHaveBeenCalled()
145
+ })
146
+
147
+ it('warns instead of silently swallowing when register() throws', async () => {
148
+ mockAppDiRegister.mockImplementation(() => {
149
+ throw new Error('boom from app di')
150
+ })
151
+ const firstContainer = await createRequestContainer()
152
+ const secondContainer = await createRequestContainer()
153
+ expect(firstContainer).toBeDefined()
154
+ expect(secondContainer).toBeDefined()
155
+ expect(mockAppDiRegister).toHaveBeenCalledTimes(2)
156
+ expect(mockWarn).toHaveBeenCalledTimes(1)
157
+ const [message, fields] = mockWarn.mock.calls[0]
158
+ expect(String(message)).toContain('register()')
159
+ expect((fields as { err: Error }).err.message).toBe('boom from app di')
160
+ })
161
+
162
+ it('warns when an async register() rejects', async () => {
163
+ mockAppDiRegister.mockImplementation(async () => {
164
+ throw new Error('async boom from app di')
165
+ })
166
+ const firstContainer = await createRequestContainer()
167
+ const secondContainer = await createRequestContainer()
168
+ expect(firstContainer).toBeDefined()
169
+ expect(secondContainer).toBeDefined()
170
+ expect(mockAppDiRegister).toHaveBeenCalledTimes(2)
171
+ expect(mockWarn).toHaveBeenCalledTimes(1)
172
+ })
173
+ })
@@ -24,6 +24,8 @@ export type AppDiRegistrar = (container: AppContainer) => void | Promise<void>
24
24
  // file can be loaded as multiple module instances when mixing dynamic and static imports
25
25
  const GLOBAL_KEY = '__openMercatoDiRegistrars__'
26
26
  const APP_DI_REGISTRAR_KEY = '__openMercatoAppDiRegistrar__'
27
+ const APP_DI_LOAD_WARNING_KEY = '__openMercatoAppDiLoadWarningEmitted__'
28
+ const APP_DI_REGISTER_WARNING_KEY = '__openMercatoAppDiRegisterWarningEmitted__'
27
29
  // Phase 5 — process-scoped bootstrap cache. The cache/event-bus/encryption
28
30
  // services bootstrap() creates are inherently process-scoped (they hold
29
31
  // state across requests). Caching them on globalThis after the first
@@ -137,10 +139,35 @@ export function registerAppDiRegistrar(registrar: AppDiRegistrar | null): void {
137
139
  ;(globalThis as Record<string, unknown>)[APP_DI_REGISTRAR_KEY] = registrar
138
140
  }
139
141
 
140
- /** Test-only helper to drop the process-scoped bootstrap cache. */
142
+ /** Test-only helper to drop process-scoped request-container state. */
141
143
  export function resetBootstrapCache(): void {
142
144
  (globalThis as any)[BOOTSTRAP_CACHE_KEY] = null
143
145
  ;(globalThis as any)[ENCRYPTION_ENABLED_KEY] = undefined
146
+ ;(globalThis as Record<string, unknown>)[APP_DI_LOAD_WARNING_KEY] = undefined
147
+ ;(globalThis as Record<string, unknown>)[APP_DI_REGISTER_WARNING_KEY] = undefined
148
+ }
149
+
150
+ function isAppDiModuleNotFound(error: unknown): boolean {
151
+ if (!error || typeof error !== 'object') return false
152
+ const { code, message } = error as { code?: unknown; message?: unknown }
153
+ const text = typeof message === 'string' ? message : ''
154
+ const moduleNotFound =
155
+ code === 'MODULE_NOT_FOUND'
156
+ || code === 'ERR_MODULE_NOT_FOUND'
157
+ || text.startsWith('Cannot find module')
158
+ || text.startsWith('Cannot find package')
159
+ return moduleNotFound && /(?:module|package) ['"]@\/di['"]/.test(text)
160
+ }
161
+
162
+ function warnAppDiFailureOnce(
163
+ key: typeof APP_DI_LOAD_WARNING_KEY | typeof APP_DI_REGISTER_WARNING_KEY,
164
+ message: string,
165
+ error: unknown,
166
+ ): void {
167
+ const globalScope = globalThis as Record<string, unknown>
168
+ if (globalScope[key] === true) return
169
+ globalScope[key] = true
170
+ logger.warn(message, { err: error })
144
171
  }
145
172
 
146
173
  function isAwilixResolver(value: unknown): value is Resolver<unknown> {
@@ -249,11 +276,25 @@ export async function createRequestContainer(): Promise<AppContainer> {
249
276
  try {
250
277
  const maybe = appDi.register(container)
251
278
  if (maybe && typeof maybe.then === 'function') await maybe
252
- } catch (error) {
253
- logger.error('App-level DI registrar failed', { err: error })
279
+ } catch (err) {
280
+ warnAppDiFailureOnce(
281
+ APP_DI_REGISTER_WARNING_KEY,
282
+ 'App-level DI override (src/di.ts register()) threw; its registrations are skipped',
283
+ err,
284
+ )
254
285
  }
255
286
  }
256
- } catch {}
287
+ } catch (err) {
288
+ if (isAppDiModuleNotFound(err)) {
289
+ logger.debug('App-level DI override module (@/di) not resolvable; skipping', { err })
290
+ } else {
291
+ warnAppDiFailureOnce(
292
+ APP_DI_LOAD_WARNING_KEY,
293
+ 'App-level DI override module (@/di) failed to load; its registrations are skipped',
294
+ err,
295
+ )
296
+ }
297
+ }
257
298
  }
258
299
  applyDiOverridesToContainer({
259
300
  register: (registrations) => container.register(toAwilixRegistrations(registrations)),
@@ -1,4 +1,7 @@
1
1
  import React from 'react'
2
+ import { mkdtemp, readFile, rm } from 'node:fs/promises'
3
+ import { join } from 'node:path'
4
+ import { tmpdir } from 'node:os'
2
5
  import { sendEmail } from '../send'
3
6
 
4
7
  var sendMock: jest.Mock
@@ -15,6 +18,7 @@ jest.mock('resend', () => {
15
18
 
16
19
  describe('sendEmail', () => {
17
20
  const originalEnv = process.env
21
+ let tempDir: string | null = null
18
22
 
19
23
  beforeEach(() => {
20
24
  process.env = {
@@ -26,7 +30,11 @@ describe('sendEmail', () => {
26
30
  ResendMock.mockClear()
27
31
  })
28
32
 
29
- afterEach(() => {
33
+ afterEach(async () => {
34
+ if (tempDir) {
35
+ await rm(tempDir, { recursive: true, force: true })
36
+ tempDir = null
37
+ }
30
38
  process.env = originalEnv
31
39
  })
32
40
 
@@ -158,4 +166,32 @@ describe('sendEmail', () => {
158
166
  expect(ResendMock).not.toHaveBeenCalled()
159
167
  expect(sendMock).not.toHaveBeenCalled()
160
168
  })
169
+
170
+ it('captures email links in OM_TEST_MODE without external delivery', async () => {
171
+ tempDir = await mkdtemp(join(tmpdir(), 'om-email-capture-'))
172
+ const capturePath = join(tempDir, 'emails.jsonl')
173
+ process.env.OM_TEST_MODE = '1'
174
+ process.env.OM_TEST_EMAIL_CAPTURE_PATH = capturePath
175
+ delete process.env.RESEND_API_KEY
176
+
177
+ await sendEmail({
178
+ to: 'user@example.com',
179
+ subject: 'Invite',
180
+ react: React.createElement('div', null, [
181
+ React.createElement('p', { key: 'text' }, 'Accept your invite'),
182
+ React.createElement('a', { key: 'link', href: 'https://example.com/portal/invite?token=raw' }, 'Accept'),
183
+ ]),
184
+ })
185
+
186
+ const rows = (await readFile(capturePath, 'utf8')).trim().split('\n')
187
+ expect(rows).toHaveLength(1)
188
+ expect(JSON.parse(rows[0])).toEqual(expect.objectContaining({
189
+ to: 'user@example.com',
190
+ subject: 'Invite',
191
+ links: ['https://example.com/portal/invite?token=raw'],
192
+ text: 'Accept your invite Accept',
193
+ }))
194
+ expect(ResendMock).not.toHaveBeenCalled()
195
+ expect(sendMock).not.toHaveBeenCalled()
196
+ })
161
197
  })
@@ -1,5 +1,8 @@
1
1
  import { Resend } from 'resend'
2
2
  import React from 'react'
3
+ import { appendFile, mkdir } from 'node:fs/promises'
4
+ import { dirname, join } from 'node:path'
5
+ import { tmpdir } from 'node:os'
3
6
  import { parseBooleanWithDefault } from '../boolean'
4
7
  import { resolveDefaultEmailFromAddress } from './config'
5
8
 
@@ -16,10 +19,86 @@ export type SendEmailOptions = {
16
19
  }>
17
20
  }
18
21
 
22
+ type CapturedEmail = {
23
+ to: string
24
+ subject: string
25
+ from: string | null
26
+ replyTo: string | null
27
+ links: string[]
28
+ text: string
29
+ capturedAt: string
30
+ }
31
+
32
+ type ReactElementProps = {
33
+ href?: unknown
34
+ children?: unknown
35
+ }
36
+
37
+ const DEFAULT_TEST_EMAIL_CAPTURE_PATH = join(tmpdir(), 'open-mercato-email-capture.jsonl')
38
+
39
+ function resolveTestEmailCapturePath(): string {
40
+ return process.env.OM_TEST_EMAIL_CAPTURE_PATH?.trim() || DEFAULT_TEST_EMAIL_CAPTURE_PATH
41
+ }
42
+
43
+ function readElementProps(node: React.ReactElement): ReactElementProps {
44
+ return node.props as ReactElementProps
45
+ }
46
+
47
+ function collectEmailLinks(node: unknown, links: string[] = []): string[] {
48
+ if (node == null || typeof node === 'boolean') return links
49
+ if (Array.isArray(node)) {
50
+ for (const child of node) collectEmailLinks(child, links)
51
+ return links
52
+ }
53
+ if (React.isValidElement(node)) {
54
+ const props = readElementProps(node)
55
+ if (typeof props.href === 'string' && props.href.length > 0) links.push(props.href)
56
+ collectEmailLinks(props.children, links)
57
+ }
58
+ return links
59
+ }
60
+
61
+ function collectEmailText(node: unknown, parts: string[] = []): string[] {
62
+ if (node == null || typeof node === 'boolean') return parts
63
+ if (typeof node === 'string' || typeof node === 'number') {
64
+ parts.push(String(node))
65
+ return parts
66
+ }
67
+ if (Array.isArray(node)) {
68
+ for (const child of node) collectEmailText(child, parts)
69
+ return parts
70
+ }
71
+ if (React.isValidElement(node)) {
72
+ collectEmailText(readElementProps(node).children, parts)
73
+ }
74
+ return parts
75
+ }
76
+
77
+ async function captureEmailForTests(options: SendEmailOptions): Promise<void> {
78
+ if (!parseBooleanWithDefault(process.env.OM_TEST_MODE, false)) return
79
+
80
+ const capturePath = resolveTestEmailCapturePath()
81
+ const record: CapturedEmail = {
82
+ to: options.to,
83
+ subject: options.subject,
84
+ from: options.from ?? resolveDefaultEmailFromAddress() ?? null,
85
+ replyTo: options.replyTo ?? null,
86
+ links: collectEmailLinks(options.react),
87
+ text: collectEmailText(options.react).join(' ').replace(/\s+/g, ' ').trim(),
88
+ capturedAt: new Date().toISOString(),
89
+ }
90
+
91
+ await mkdir(dirname(capturePath), { recursive: true })
92
+ await appendFile(capturePath, `${JSON.stringify(record)}\n`, 'utf8')
93
+ }
94
+
19
95
  export async function sendEmail({ to, subject, react, from, replyTo, attachments }: SendEmailOptions) {
20
96
  const emailDisabled =
21
97
  parseBooleanWithDefault(process.env.OM_DISABLE_EMAIL_DELIVERY, false) ||
22
98
  parseBooleanWithDefault(process.env.OM_TEST_MODE, false)
99
+
100
+ await captureEmailForTests({ to, subject, react, from, replyTo, attachments })
101
+
23
102
  if (emailDisabled) return
24
103
 
25
104
  const apiKey = process.env.RESEND_API_KEY
@@ -108,11 +108,10 @@ export type QueryOptions = {
108
108
  * and MUST fail closed when the authenticated principal lacks a resolvable tenant/org, otherwise
109
109
  * queries return cross-tenant rows.
110
110
  *
111
- * When this flag is set, the hybrid query engine delegates to the basic engine, which means
112
- * custom-field (`cf:*`) filters/sorts, `search_tokens` fulltext filtering, and vector-search
113
- * branches are BYPASSED. Only use this on entities whose scoping does not match the standard
114
- * `organization_id = X AND tenant_id = Y` shape and which do not rely on custom-field/search
115
- * features.
111
+ * When this flag is set, the hybrid query engine delegates to the basic engine. The basic engine
112
+ * still applies `cf:*` filters/sorts, but `search_tokens` fulltext filtering, the JSONB index read
113
+ * path, and the vector-search branch are BYPASSED. Only use this on entities whose scoping does
114
+ * not match the standard `organization_id = X AND tenant_id = Y` shape.
116
115
  */
117
116
  omitAutomaticTenantOrgScope?: boolean
118
117
  // Soft-delete behavior: when false (default), rows with non-null deleted_at
@@ -1,8 +1,12 @@
1
1
  import {
2
+ DEFAULT_SEARCH_MAX_FIELD_CHARS,
3
+ DEFAULT_SEARCH_MAX_TOKENS_PER_FIELD,
4
+ DEFAULT_SEARCH_MAX_TOKENS_PER_RECORD,
2
5
  DEFAULT_SEARCH_MIN_TOKEN_LENGTH,
3
6
  isSearchFieldBlocklisted,
4
7
  resolveSearchConfig,
5
8
  resolveSearchMinTokenLength,
9
+ resolveSearchTokenLimits,
6
10
  } from '../config'
7
11
 
8
12
  describe('resolveSearchMinTokenLength', () => {
@@ -109,6 +113,61 @@ describe('OM_SEARCH_FIELD_BLOCKLIST parsing', () => {
109
113
  })
110
114
  })
111
115
 
116
+ describe('search token limits', () => {
117
+ const variableNames = [
118
+ 'OM_SEARCH_MAX_FIELD_CHARS',
119
+ 'OM_SEARCH_MAX_TOKENS_PER_FIELD',
120
+ 'OM_SEARCH_MAX_TOKENS_PER_RECORD',
121
+ ] as const
122
+ const originalValues = Object.fromEntries(variableNames.map((name) => [name, process.env[name]]))
123
+
124
+ afterEach(() => {
125
+ for (const name of variableNames) {
126
+ const original = originalValues[name]
127
+ if (original === undefined) delete process.env[name]
128
+ else process.env[name] = original
129
+ }
130
+ })
131
+
132
+ it('uses safe defaults when limits are unset', () => {
133
+ for (const name of variableNames) delete process.env[name]
134
+
135
+ expect(resolveSearchTokenLimits(resolveSearchConfig())).toEqual({
136
+ maxFieldChars: DEFAULT_SEARCH_MAX_FIELD_CHARS,
137
+ maxTokensPerField: DEFAULT_SEARCH_MAX_TOKENS_PER_FIELD,
138
+ maxTokensPerRecord: DEFAULT_SEARCH_MAX_TOKENS_PER_RECORD,
139
+ })
140
+ })
141
+
142
+ it('accepts zero to disable individual limits', () => {
143
+ for (const name of variableNames) process.env[name] = '0'
144
+
145
+ expect(resolveSearchTokenLimits(resolveSearchConfig())).toEqual({
146
+ maxFieldChars: 0,
147
+ maxTokensPerField: 0,
148
+ maxTokensPerRecord: 0,
149
+ })
150
+ })
151
+
152
+ it('normalizes invalid custom config values to defaults', () => {
153
+ expect(resolveSearchTokenLimits({
154
+ enabled: true,
155
+ minTokenLength: 3,
156
+ enablePartials: true,
157
+ hashAlgorithm: 'sha256',
158
+ storeRawTokens: false,
159
+ blocklistedFields: [],
160
+ maxFieldChars: Number.NaN,
161
+ maxTokensPerField: -1,
162
+ maxTokensPerRecord: 4.8,
163
+ })).toEqual({
164
+ maxFieldChars: DEFAULT_SEARCH_MAX_FIELD_CHARS,
165
+ maxTokensPerField: DEFAULT_SEARCH_MAX_TOKENS_PER_FIELD,
166
+ maxTokensPerRecord: 4,
167
+ })
168
+ })
169
+ })
170
+
112
171
  describe('isSearchFieldBlocklisted', () => {
113
172
  const baseConfig = {
114
173
  enabled: true,
@@ -0,0 +1,49 @@
1
+ import type { SearchConfig } from '../config'
2
+ import { tokenizeText } from '../tokenize'
3
+
4
+ const baseConfig: SearchConfig = {
5
+ enabled: true,
6
+ minTokenLength: 3,
7
+ enablePartials: true,
8
+ hashAlgorithm: 'sha256',
9
+ storeRawTokens: false,
10
+ blocklistedFields: [],
11
+ }
12
+
13
+ describe('tokenizeText limits', () => {
14
+ test('truncates oversized field text before tokenizing', () => {
15
+ const config = { ...baseConfig, enablePartials: false, maxFieldChars: 10 }
16
+
17
+ const { tokens } = tokenizeText('aaaa bbbb cccc', config)
18
+
19
+ expect(tokens).toEqual(['aaaa', 'bbbb'])
20
+ })
21
+
22
+ test('bounds prefix expansion while collecting tokens', () => {
23
+ const config = { ...baseConfig, maxFieldChars: 100, maxTokensPerField: 5 }
24
+
25
+ const { tokens, hashes } = tokenizeText('a'.repeat(100), config)
26
+
27
+ expect(tokens).toEqual(['aaa', 'aaaa', 'aaaaa', 'aaaaaa', 'aaaaaaa'])
28
+ expect(hashes).toHaveLength(5)
29
+ })
30
+
31
+ test('applies safe defaults to legacy configs without limit fields', () => {
32
+ const { tokens } = tokenizeText('a'.repeat(50_000), baseConfig)
33
+
34
+ expect(tokens).toHaveLength(5_000)
35
+ })
36
+
37
+ test('allows a limit to be disabled explicitly', () => {
38
+ const config = {
39
+ ...baseConfig,
40
+ enablePartials: false,
41
+ maxFieldChars: 0,
42
+ maxTokensPerField: 0,
43
+ }
44
+
45
+ const { tokens } = tokenizeText('alpha beta gamma delta', config)
46
+
47
+ expect(tokens).toEqual(['alpha', 'beta', 'gamma', 'delta'])
48
+ })
49
+ })
@@ -10,9 +10,21 @@ export type SearchConfig = {
10
10
  storeRawTokens: boolean
11
11
  blocklistedFields: string[]
12
12
  entityBlocklistedFields?: Record<string, string[]>
13
+ maxFieldChars?: number
14
+ maxTokensPerField?: number
15
+ maxTokensPerRecord?: number
13
16
  }
14
17
 
15
18
  export const DEFAULT_SEARCH_MIN_TOKEN_LENGTH = 3
19
+ export const DEFAULT_SEARCH_MAX_FIELD_CHARS = 20_000
20
+ export const DEFAULT_SEARCH_MAX_TOKENS_PER_FIELD = 5_000
21
+ export const DEFAULT_SEARCH_MAX_TOKENS_PER_RECORD = 20_000
22
+
23
+ export type SearchTokenLimits = {
24
+ maxFieldChars: number
25
+ maxTokensPerField: number
26
+ maxTokensPerRecord: number
27
+ }
16
28
 
17
29
  const DEFAULT_BLOCKLIST = ['password', 'token', 'secret', 'hash']
18
30
 
@@ -26,6 +38,19 @@ function parseNumber(raw: string | undefined, fallback: number, min = 1): number
26
38
  return parseNumberWithDefault(raw, fallback, { integer: true, min })
27
39
  }
28
40
 
41
+ export function resolveSearchTokenLimits(config: SearchConfig): SearchTokenLimits {
42
+ const resolveLimit = (value: number | undefined, fallback: number): number => {
43
+ if (value === undefined) return fallback
44
+ if (!Number.isFinite(value) || value < 0) return fallback
45
+ return Math.trunc(value)
46
+ }
47
+ return {
48
+ maxFieldChars: resolveLimit(config.maxFieldChars, DEFAULT_SEARCH_MAX_FIELD_CHARS),
49
+ maxTokensPerField: resolveLimit(config.maxTokensPerField, DEFAULT_SEARCH_MAX_TOKENS_PER_FIELD),
50
+ maxTokensPerRecord: resolveLimit(config.maxTokensPerRecord, DEFAULT_SEARCH_MAX_TOKENS_PER_RECORD),
51
+ }
52
+ }
53
+
29
54
  function parseHashAlgorithm(raw: string | undefined): 'sha256' | 'sha1' | 'md5' {
30
55
  const value = (raw ?? '').trim().toLowerCase()
31
56
  if (value === 'sha1') return 'sha1'
@@ -89,6 +114,9 @@ export function resolveSearchConfig(): SearchConfig {
89
114
  storeRawTokens: parseBoolean(process.env.OM_SEARCH_STORE_RAW_TOKENS, false),
90
115
  blocklistedFields: blocklist.global,
91
116
  entityBlocklistedFields: blocklist.byEntity,
117
+ maxFieldChars: parseNumber(process.env.OM_SEARCH_MAX_FIELD_CHARS, DEFAULT_SEARCH_MAX_FIELD_CHARS, 0),
118
+ maxTokensPerField: parseNumber(process.env.OM_SEARCH_MAX_TOKENS_PER_FIELD, DEFAULT_SEARCH_MAX_TOKENS_PER_FIELD, 0),
119
+ maxTokensPerRecord: parseNumber(process.env.OM_SEARCH_MAX_TOKENS_PER_RECORD, DEFAULT_SEARCH_MAX_TOKENS_PER_RECORD, 0),
92
120
  }
93
121
  }
94
122
 
@@ -1,5 +1,5 @@
1
1
  import crypto from 'crypto'
2
- import { resolveSearchConfig, type SearchConfig } from './config'
2
+ import { resolveSearchConfig, resolveSearchTokenLimits, type SearchConfig } from './config'
3
3
 
4
4
  export type TokenizationResult = {
5
5
  tokens: string[]
@@ -20,13 +20,28 @@ function splitTokens(text: string, minLength: number): string[] {
20
20
  .filter((token) => token.length >= minLength)
21
21
  }
22
22
 
23
- function expandToken(token: string, config: SearchConfig): string[] {
24
- if (!config.enablePartials) return [token]
25
- const results: string[] = []
26
- for (let i = config.minTokenLength; i <= token.length; i += 1) {
27
- results.push(token.slice(0, i))
23
+ function appendExpandedToken(
24
+ token: string,
25
+ config: SearchConfig,
26
+ seen: Set<string>,
27
+ tokens: string[],
28
+ limit: number,
29
+ ): void {
30
+ const append = (candidate: string): boolean => {
31
+ if (seen.has(candidate)) return tokens.length < limit
32
+ seen.add(candidate)
33
+ tokens.push(candidate)
34
+ return tokens.length < limit
35
+ }
36
+
37
+ if (!config.enablePartials) {
38
+ append(token)
39
+ return
40
+ }
41
+
42
+ for (let length = config.minTokenLength; length <= token.length; length += 1) {
43
+ if (!append(token.slice(0, length))) return
28
44
  }
29
- return results
30
45
  }
31
46
 
32
47
  export function hashToken(token: string, config?: SearchConfig): string {
@@ -36,10 +51,17 @@ export function hashToken(token: string, config?: SearchConfig): string {
36
51
 
37
52
  export function tokenizeText(text: string, config?: SearchConfig): TokenizationResult {
38
53
  const cfg = config ?? resolveSearchConfig()
39
- const baseTokens = splitTokens(text, cfg.minTokenLength)
40
- const expanded = baseTokens.flatMap((token) => expandToken(token, cfg))
41
- const unique = Array.from(new Set(expanded))
42
- const tokens = unique.filter((token) => token.length >= cfg.minTokenLength)
54
+ const limits = resolveSearchTokenLimits(cfg)
55
+ const boundedText = limits.maxFieldChars > 0 ? text.slice(0, limits.maxFieldChars) : text
56
+ const tokenLimit = limits.maxTokensPerField > 0 ? limits.maxTokensPerField : Number.POSITIVE_INFINITY
57
+ const seen = new Set<string>()
58
+ const tokens: string[] = []
59
+
60
+ for (const token of splitTokens(boundedText, cfg.minTokenLength)) {
61
+ if (tokens.length >= tokenLimit) break
62
+ appendExpandedToken(token, cfg, seen, tokens, tokenLimit)
63
+ }
64
+
43
65
  const hashes = tokens.map((token) => hashToken(token, cfg))
44
66
  return { tokens, hashes }
45
67
  }