@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.
- package/.turbo/turbo-build.log +1 -1
- package/dist/lib/crud/factory.js +3 -2
- package/dist/lib/crud/factory.js.map +2 -2
- package/dist/lib/di/container.js +33 -3
- package/dist/lib/di/container.js.map +2 -2
- package/dist/lib/email/send.js +56 -0
- package/dist/lib/email/send.js.map +2 -2
- package/dist/lib/query/types.js.map +1 -1
- package/dist/lib/search/config.js +24 -2
- package/dist/lib/search/config.js.map +2 -2
- package/dist/lib/search/tokenize.js +23 -11
- package/dist/lib/search/tokenize.js.map +2 -2
- package/dist/lib/version.js +1 -1
- package/dist/lib/version.js.map +1 -1
- package/dist/lib/webhooks/body.js +57 -0
- package/dist/lib/webhooks/body.js.map +7 -0
- package/dist/lib/webhooks/index.js +13 -1
- package/dist/lib/webhooks/index.js.map +2 -2
- package/dist/lib/webhooks/verify.js +12 -7
- package/dist/lib/webhooks/verify.js.map +2 -2
- package/dist/modules/payment_gateways/types.js +8 -1
- package/dist/modules/payment_gateways/types.js.map +2 -2
- package/dist/modules/widgets/extension-points.js.map +1 -1
- package/package.json +2 -2
- package/src/lib/crud/__tests__/crud-factory.test.ts +79 -0
- package/src/lib/crud/factory.ts +2 -1
- package/src/lib/di/__tests__/container-app-di-absent.test.ts +139 -0
- package/src/lib/di/__tests__/container-app-di.test.ts +173 -0
- package/src/lib/di/container.ts +45 -4
- package/src/lib/email/__tests__/send.test.ts +37 -1
- package/src/lib/email/send.ts +79 -0
- package/src/lib/query/types.ts +4 -5
- package/src/lib/search/__tests__/config.test.ts +59 -0
- package/src/lib/search/__tests__/tokenize.test.ts +49 -0
- package/src/lib/search/config.ts +28 -0
- package/src/lib/search/tokenize.ts +33 -11
- package/src/lib/webhooks/__tests__/body.test.ts +95 -0
- package/src/lib/webhooks/__tests__/verify.test.ts +20 -1
- package/src/lib/webhooks/body.ts +70 -0
- package/src/lib/webhooks/index.ts +7 -1
- package/src/lib/webhooks/verify.ts +17 -9
- package/src/modules/payment_gateways/__tests__/types.test.ts +37 -0
- package/src/modules/payment_gateways/types.ts +15 -0
- 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
|
+
})
|
package/src/lib/di/container.ts
CHANGED
|
@@ -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
|
|
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 (
|
|
253
|
-
|
|
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
|
})
|
package/src/lib/email/send.ts
CHANGED
|
@@ -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
|
package/src/lib/query/types.ts
CHANGED
|
@@ -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
|
|
112
|
-
*
|
|
113
|
-
*
|
|
114
|
-
* `organization_id = X AND tenant_id = Y` shape
|
|
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
|
+
})
|
package/src/lib/search/config.ts
CHANGED
|
@@ -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
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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
|
|
40
|
-
const
|
|
41
|
-
const
|
|
42
|
-
const
|
|
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
|
}
|