@open-mercato/shared 0.6.7 → 0.6.8-develop.6874.1.982d6097d8

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 (195) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/AGENTS.md +57 -6
  3. package/build.mjs +2 -4
  4. package/dist/lib/ai/safety-identifier.js +32 -0
  5. package/dist/lib/ai/safety-identifier.js.map +7 -0
  6. package/dist/lib/auth/organizationScope.js +34 -0
  7. package/dist/lib/auth/organizationScope.js.map +7 -0
  8. package/dist/lib/auth/server.js +15 -2
  9. package/dist/lib/auth/server.js.map +2 -2
  10. package/dist/lib/bootstrap/clientOnlyModules.js +55 -0
  11. package/dist/lib/bootstrap/clientOnlyModules.js.map +7 -0
  12. package/dist/lib/bootstrap/dynamicLoader.js +117 -43
  13. package/dist/lib/bootstrap/dynamicLoader.js.map +2 -2
  14. package/dist/lib/commands/command-bus.js +5 -1
  15. package/dist/lib/commands/command-bus.js.map +2 -2
  16. package/dist/lib/commands/command-interceptor-runner.js +2 -2
  17. package/dist/lib/commands/command-interceptor-runner.js.map +2 -2
  18. package/dist/lib/crud/enricher-runner.js +2 -11
  19. package/dist/lib/crud/enricher-runner.js.map +2 -2
  20. package/dist/lib/crud/factory.js +72 -9
  21. package/dist/lib/crud/factory.js.map +2 -2
  22. package/dist/lib/crud/interceptor-runner.js +2 -2
  23. package/dist/lib/crud/interceptor-runner.js.map +2 -2
  24. package/dist/lib/crud/mutation-guard-registry.js +2 -2
  25. package/dist/lib/crud/mutation-guard-registry.js.map +2 -2
  26. package/dist/lib/crud/types.js +4 -0
  27. package/dist/lib/crud/types.js.map +3 -3
  28. package/dist/lib/data/consistency.js +19 -0
  29. package/dist/lib/data/consistency.js.map +7 -0
  30. package/dist/lib/data/engine.js +37 -11
  31. package/dist/lib/data/engine.js.map +2 -2
  32. package/dist/lib/db/entityMetadata.js +26 -0
  33. package/dist/lib/db/entityMetadata.js.map +7 -0
  34. package/dist/lib/db/pg-errors.js +42 -0
  35. package/dist/lib/db/pg-errors.js.map +2 -2
  36. package/dist/lib/di/container.js +48 -6
  37. package/dist/lib/di/container.js.map +2 -2
  38. package/dist/lib/email/send.js +56 -0
  39. package/dist/lib/email/send.js.map +2 -2
  40. package/dist/lib/encryption/subscriber.js +2 -10
  41. package/dist/lib/encryption/subscriber.js.map +2 -2
  42. package/dist/lib/encryption/tenantDataEncryptionService.js +52 -17
  43. package/dist/lib/encryption/tenantDataEncryptionService.js.map +2 -2
  44. package/dist/lib/i18n/config.js +1 -1
  45. package/dist/lib/i18n/config.js.map +2 -2
  46. package/dist/lib/i18n/server.js +4 -4
  47. package/dist/lib/i18n/server.js.map +3 -3
  48. package/dist/lib/logger/extension.js +29 -0
  49. package/dist/lib/logger/extension.js.map +7 -0
  50. package/dist/lib/logger/index.js +47 -3
  51. package/dist/lib/logger/index.js.map +3 -3
  52. package/dist/lib/modules/registry.js +5 -1
  53. package/dist/lib/modules/registry.js.map +2 -2
  54. package/dist/lib/modules/surfaceFingerprint.js +47 -0
  55. package/dist/lib/modules/surfaceFingerprint.js.map +7 -0
  56. package/dist/lib/query/ciphertext-search-warning.js +45 -0
  57. package/dist/lib/query/ciphertext-search-warning.js.map +7 -0
  58. package/dist/lib/query/engine.js +54 -40
  59. package/dist/lib/query/engine.js.map +2 -2
  60. package/dist/lib/query/types.js.map +1 -1
  61. package/dist/lib/redis/connection.js +7 -2
  62. package/dist/lib/redis/connection.js.map +2 -2
  63. package/dist/lib/search/auto-indexing.js +14 -0
  64. package/dist/lib/search/auto-indexing.js.map +7 -0
  65. package/dist/lib/search/availability.js +107 -0
  66. package/dist/lib/search/availability.js.map +7 -0
  67. package/dist/lib/search/config.js +61 -2
  68. package/dist/lib/search/config.js.map +2 -2
  69. package/dist/lib/search/tokenLookup.js +46 -0
  70. package/dist/lib/search/tokenLookup.js.map +7 -0
  71. package/dist/lib/search/tokenize.js +23 -11
  72. package/dist/lib/search/tokenize.js.map +2 -2
  73. package/dist/lib/telemetry/runtime.js +35 -0
  74. package/dist/lib/telemetry/runtime.js.map +7 -0
  75. package/dist/lib/version.js +1 -1
  76. package/dist/lib/version.js.map +1 -1
  77. package/dist/lib/webhooks/body.js +57 -0
  78. package/dist/lib/webhooks/body.js.map +7 -0
  79. package/dist/lib/webhooks/inbound-types.js +1 -0
  80. package/dist/lib/webhooks/inbound-types.js.map +7 -0
  81. package/dist/lib/webhooks/index.js +13 -1
  82. package/dist/lib/webhooks/index.js.map +2 -2
  83. package/dist/lib/webhooks/verify.js +12 -7
  84. package/dist/lib/webhooks/verify.js.map +2 -2
  85. package/dist/modules/analytics.js.map +2 -2
  86. package/dist/modules/overrides.js +50 -1
  87. package/dist/modules/overrides.js.map +2 -2
  88. package/dist/modules/payment_gateways/types.js +8 -1
  89. package/dist/modules/payment_gateways/types.js.map +2 -2
  90. package/dist/modules/registry.js +1 -0
  91. package/dist/modules/registry.js.map +2 -2
  92. package/dist/modules/widgets/extension-points.js +96 -0
  93. package/dist/modules/widgets/extension-points.js.map +7 -0
  94. package/dist/security/enabledModulesRegistry.js +56 -11
  95. package/dist/security/enabledModulesRegistry.js.map +2 -2
  96. package/dist/security/featurePolicy.js +62 -0
  97. package/dist/security/featurePolicy.js.map +7 -0
  98. package/jest.config.cjs +2 -1
  99. package/package.json +19 -9
  100. package/scripts/versionSource.cjs +65 -0
  101. package/src/lib/__tests__/versionSource.test.ts +46 -0
  102. package/src/lib/ai/__tests__/llm-provider-contract.test.ts +59 -0
  103. package/src/lib/ai/__tests__/safety-identifier.test.ts +71 -0
  104. package/src/lib/ai/llm-provider.ts +33 -0
  105. package/src/lib/ai/safety-identifier.ts +73 -0
  106. package/src/lib/auth/__tests__/organizationScope.test.ts +107 -0
  107. package/src/lib/auth/__tests__/server.test.ts +23 -0
  108. package/src/lib/auth/organizationScope.ts +65 -0
  109. package/src/lib/auth/server.ts +23 -2
  110. package/src/lib/bootstrap/__tests__/clientOnlyModules.test.ts +189 -0
  111. package/src/lib/bootstrap/__tests__/dynamicLoader.aliasResolver.test.ts +98 -0
  112. package/src/lib/bootstrap/__tests__/dynamicLoader.appDi.test.ts +211 -0
  113. package/src/lib/bootstrap/__tests__/dynamicLoader.commandInterceptors.test.ts +63 -8
  114. package/src/lib/bootstrap/__tests__/dynamicLoader.generatedCacheRecovery.test.ts +256 -0
  115. package/src/lib/bootstrap/__tests__/dynamicLoader.tsconfig.test.ts +21 -1
  116. package/src/lib/bootstrap/clientOnlyModules.ts +85 -0
  117. package/src/lib/bootstrap/dynamicLoader.ts +213 -65
  118. package/src/lib/commands/__tests__/command-interceptor-runner.test.ts +28 -0
  119. package/src/lib/commands/command-bus.ts +5 -1
  120. package/src/lib/commands/command-interceptor-runner.ts +2 -2
  121. package/src/lib/crud/__tests__/crud-factory.cache-user-scope.test.ts +171 -0
  122. package/src/lib/crud/__tests__/crud-factory.test.ts +351 -0
  123. package/src/lib/crud/__tests__/mutation-guard-registry.test.ts +26 -0
  124. package/src/lib/crud/enricher-runner.ts +2 -11
  125. package/src/lib/crud/factory.ts +82 -6
  126. package/src/lib/crud/interceptor-runner.ts +2 -2
  127. package/src/lib/crud/mutation-guard-registry.ts +2 -2
  128. package/src/lib/crud/types.ts +3 -0
  129. package/src/lib/data/__tests__/consistency.test.ts +38 -0
  130. package/src/lib/data/__tests__/engine.bulk-suppress.test.ts +18 -3
  131. package/src/lib/data/consistency.ts +17 -0
  132. package/src/lib/data/engine.ts +50 -16
  133. package/src/lib/db/__tests__/entityMetadata.test.ts +38 -0
  134. package/src/lib/db/__tests__/pg-errors.test.ts +48 -0
  135. package/src/lib/db/entityMetadata.ts +43 -0
  136. package/src/lib/db/pg-errors.ts +58 -0
  137. package/src/lib/di/__tests__/container-app-di-absent.test.ts +155 -0
  138. package/src/lib/di/__tests__/container-app-di.test.ts +173 -0
  139. package/src/lib/di/__tests__/registrar-error-log.test.ts +154 -0
  140. package/src/lib/di/container.ts +72 -8
  141. package/src/lib/email/__tests__/send.test.ts +37 -1
  142. package/src/lib/email/send.ts +79 -0
  143. package/src/lib/encryption/__tests__/subscriber.test.ts +54 -2
  144. package/src/lib/encryption/__tests__/tenantDataEncryptionService.test.ts +58 -0
  145. package/src/lib/encryption/subscriber.ts +2 -14
  146. package/src/lib/encryption/tenantDataEncryptionService.ts +64 -18
  147. package/src/lib/i18n/__tests__/config.test.ts +8 -0
  148. package/src/lib/i18n/__tests__/server-dictionary-cache.test.ts +30 -0
  149. package/src/lib/i18n/__tests__/server-unbootstrapped.test.ts +29 -0
  150. package/src/lib/i18n/config.ts +2 -2
  151. package/src/lib/i18n/server.ts +6 -2
  152. package/src/lib/logger/__tests__/logger.test.ts +77 -0
  153. package/src/lib/logger/extension.ts +59 -0
  154. package/src/lib/logger/index.ts +59 -1
  155. package/src/lib/modules/__tests__/surfaceFingerprint.test.ts +122 -0
  156. package/src/lib/modules/registry.ts +10 -0
  157. package/src/lib/modules/surfaceFingerprint.ts +87 -0
  158. package/src/lib/query/__tests__/ciphertext-search-warning.test.ts +178 -0
  159. package/src/lib/query/__tests__/engine.test.ts +65 -13
  160. package/src/lib/query/ciphertext-search-warning.ts +95 -0
  161. package/src/lib/query/engine.ts +75 -58
  162. package/src/lib/query/types.ts +4 -5
  163. package/src/lib/redis/__tests__/connection.test.ts +16 -0
  164. package/src/lib/redis/connection.ts +10 -7
  165. package/src/lib/search/__tests__/availability.test.ts +211 -0
  166. package/src/lib/search/__tests__/config.test.ts +177 -0
  167. package/src/lib/search/__tests__/tokenLookup.test.ts +206 -0
  168. package/src/lib/search/__tests__/tokenize.test.ts +49 -0
  169. package/src/lib/search/auto-indexing.ts +22 -0
  170. package/src/lib/search/availability.ts +232 -0
  171. package/src/lib/search/config.ts +106 -8
  172. package/src/lib/search/tokenLookup.ts +133 -0
  173. package/src/lib/search/tokenize.ts +33 -11
  174. package/src/lib/telemetry/runtime.ts +72 -0
  175. package/src/lib/webhooks/__tests__/body.test.ts +95 -0
  176. package/src/lib/webhooks/__tests__/verify.test.ts +20 -1
  177. package/src/lib/webhooks/body.ts +70 -0
  178. package/src/lib/webhooks/inbound-types.ts +125 -0
  179. package/src/lib/webhooks/index.ts +19 -1
  180. package/src/lib/webhooks/verify.ts +17 -9
  181. package/src/modules/__tests__/nav-group-order-override.test.ts +183 -0
  182. package/src/modules/__tests__/registry.test.ts +10 -2
  183. package/src/modules/analytics.ts +7 -0
  184. package/src/modules/customer-auth.ts +1 -0
  185. package/src/modules/encryption.ts +3 -0
  186. package/src/modules/navigation/backendChrome.ts +15 -0
  187. package/src/modules/overrides.ts +103 -0
  188. package/src/modules/payment_gateways/__tests__/types.test.ts +37 -0
  189. package/src/modules/payment_gateways/types.ts +41 -0
  190. package/src/modules/registry.ts +3 -0
  191. package/src/modules/widgets/__tests__/extension-points.test.ts +101 -0
  192. package/src/modules/widgets/extension-points.ts +524 -0
  193. package/src/security/__tests__/featurePolicy.test.ts +166 -0
  194. package/src/security/enabledModulesRegistry.ts +64 -12
  195. package/src/security/featurePolicy.ts +89 -0
@@ -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
+ })
@@ -0,0 +1,154 @@
1
+ // A module DI registrar that throws is skipped fail-open (one broken module
2
+ // must not take down every request container), but the failure MUST be logged —
3
+ // a silent catch leaves the module's services missing with no trace until an
4
+ // unrelated Awilix resolution error surfaces much later.
5
+
6
+ import { asValue } from 'awilix'
7
+ import type { AwilixContainer } from 'awilix'
8
+
9
+ const mockLoggerError = jest.fn()
10
+
11
+ jest.mock('@open-mercato/shared/lib/logger', () => {
12
+ const makeLogger = (): Record<string, unknown> => ({
13
+ error: (...args: unknown[]) => mockLoggerError(...args),
14
+ warn: jest.fn(),
15
+ info: jest.fn(),
16
+ debug: jest.fn(),
17
+ trace: jest.fn(),
18
+ child: () => makeLogger(),
19
+ })
20
+ return {
21
+ __esModule: true,
22
+ createLogger: () => makeLogger(),
23
+ getLogLevel: () => 'info',
24
+ isLevelEnabled: () => false,
25
+ }
26
+ })
27
+
28
+ jest.mock(
29
+ '@open-mercato/shared/lib/db/mikro',
30
+ () => {
31
+ const baseEm: any = {
32
+ fork: () => baseEm,
33
+ getRepository: () => ({ find: async () => [], findOne: async () => null }),
34
+ }
35
+ return {
36
+ __esModule: true,
37
+ getOrm: async () => ({ em: baseEm }),
38
+ getOrmEntities: () => [],
39
+ registerOrmEntities: () => {},
40
+ }
41
+ },
42
+ { virtual: false },
43
+ )
44
+
45
+ jest.mock(
46
+ '@mikro-orm/core',
47
+ () => ({
48
+ __esModule: true,
49
+ RequestContext: { getEntityManager: () => null },
50
+ }),
51
+ { virtual: true },
52
+ )
53
+
54
+ jest.mock(
55
+ '@open-mercato/shared/lib/query/engine',
56
+ () => ({ __esModule: true, BasicQueryEngine: class {} }),
57
+ { virtual: false },
58
+ )
59
+
60
+ jest.mock(
61
+ '@open-mercato/shared/lib/data/engine',
62
+ () => ({ __esModule: true, DefaultDataEngine: class {} }),
63
+ { virtual: false },
64
+ )
65
+
66
+ jest.mock(
67
+ '@open-mercato/shared/lib/commands',
68
+ () => ({
69
+ __esModule: true,
70
+ commandRegistry: {},
71
+ CommandBus: class { constructor() {} },
72
+ }),
73
+ { virtual: false },
74
+ )
75
+
76
+ jest.mock(
77
+ '@open-mercato/shared/modules/overrides',
78
+ () => ({
79
+ __esModule: true,
80
+ applyDiOverridesToContainer: () => {},
81
+ applyModuleOverridesToModules: (modules: unknown[]) => modules,
82
+ applyComponentOverridesToEntries: (entries: unknown[]) => entries,
83
+ }),
84
+ { virtual: false },
85
+ )
86
+
87
+ jest.mock(
88
+ '@open-mercato/core/bootstrap',
89
+ () => ({
90
+ __esModule: true,
91
+ bootstrap: async () => {},
92
+ }),
93
+ { virtual: true },
94
+ )
95
+
96
+ jest.mock(
97
+ '@open-mercato/shared/lib/encryption/subscriber',
98
+ () => ({
99
+ __esModule: true,
100
+ registerTenantEncryptionSubscriber: () => {},
101
+ }),
102
+ { virtual: true },
103
+ )
104
+
105
+ const {
106
+ createRequestContainer,
107
+ registerAppDiRegistrar,
108
+ registerDiRegistrars,
109
+ resetBootstrapCache,
110
+ } = require('@open-mercato/shared/lib/di/container')
111
+
112
+ describe('createRequestContainer — module DI registrar failures', () => {
113
+ beforeEach(() => {
114
+ resetBootstrapCache()
115
+ registerAppDiRegistrar(null)
116
+ mockLoggerError.mockClear()
117
+ })
118
+
119
+ afterAll(() => {
120
+ registerDiRegistrars([])
121
+ })
122
+
123
+ it('logs the error and continues with the remaining registrars', async () => {
124
+ const boom = new Error('di exploded')
125
+ const workingRegistrar = jest.fn((container: AwilixContainer) => {
126
+ container.register({ registrarProbeService: asValue('registered') })
127
+ })
128
+ registerDiRegistrars([
129
+ () => { throw boom },
130
+ workingRegistrar,
131
+ ])
132
+
133
+ const container = await createRequestContainer()
134
+
135
+ expect(workingRegistrar).toHaveBeenCalledTimes(1)
136
+ expect(container.resolve('registrarProbeService')).toBe('registered')
137
+ expect(mockLoggerError).toHaveBeenCalledWith('Module DI registrar failed', {
138
+ registrarIndex: 0,
139
+ err: boom,
140
+ })
141
+ })
142
+
143
+ it('does not log when every registrar succeeds', async () => {
144
+ registerDiRegistrars([
145
+ (container: AwilixContainer) => {
146
+ container.register({ registrarProbeService: asValue('ok') })
147
+ },
148
+ ])
149
+
150
+ await createRequestContainer()
151
+
152
+ expect(mockLoggerError).not.toHaveBeenCalled()
153
+ })
154
+ })
@@ -24,6 +24,13 @@ 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__'
29
+ // Set once the legacy `@/di` fallback proves the specifier is unresolvable in this process.
30
+ // Worker/CLI processes run against built package output where the app's `@/` alias does not
31
+ // exist, so retrying the import per request container only repeats a failed module resolution
32
+ // (and its log line) once per job.
33
+ const APP_DI_MODULE_UNRESOLVABLE_KEY = '__openMercatoAppDiModuleUnresolvable__'
27
34
  // Phase 5 — process-scoped bootstrap cache. The cache/event-bus/encryption
28
35
  // services bootstrap() creates are inherently process-scoped (they hold
29
36
  // state across requests). Caching them on globalThis after the first
@@ -118,6 +125,9 @@ export function registerDiRegistrars(registrars: DiRegistrar[]) {
118
125
  // Force re-bootstrap on HMR — module subscribers may have changed.
119
126
  ;(globalThis as any)[BOOTSTRAP_CACHE_KEY] = null
120
127
  ;(globalThis as any)[ENCRYPTION_ENABLED_KEY] = undefined
128
+ // An app that gains a src/di.ts mid-session reloads through here, so the negative
129
+ // resolution result must not outlive the reload.
130
+ ;(globalThis as Record<string, unknown>)[APP_DI_MODULE_UNRESOLVABLE_KEY] = undefined
121
131
  }
122
132
 
123
133
  export function getDiRegistrars(): DiRegistrar[] {
@@ -137,10 +147,44 @@ export function registerAppDiRegistrar(registrar: AppDiRegistrar | null): void {
137
147
  ;(globalThis as Record<string, unknown>)[APP_DI_REGISTRAR_KEY] = registrar
138
148
  }
139
149
 
140
- /** Test-only helper to drop the process-scoped bootstrap cache. */
150
+ /** Test-only helper to drop process-scoped request-container state. */
141
151
  export function resetBootstrapCache(): void {
142
152
  (globalThis as any)[BOOTSTRAP_CACHE_KEY] = null
143
153
  ;(globalThis as any)[ENCRYPTION_ENABLED_KEY] = undefined
154
+ ;(globalThis as Record<string, unknown>)[APP_DI_LOAD_WARNING_KEY] = undefined
155
+ ;(globalThis as Record<string, unknown>)[APP_DI_REGISTER_WARNING_KEY] = undefined
156
+ ;(globalThis as Record<string, unknown>)[APP_DI_MODULE_UNRESOLVABLE_KEY] = undefined
157
+ }
158
+
159
+ function isAppDiModuleUnresolvable(): boolean {
160
+ return (globalThis as Record<string, unknown>)[APP_DI_MODULE_UNRESOLVABLE_KEY] === true
161
+ }
162
+
163
+ function markAppDiModuleUnresolvable(): void {
164
+ ;(globalThis as Record<string, unknown>)[APP_DI_MODULE_UNRESOLVABLE_KEY] = true
165
+ }
166
+
167
+ function isAppDiModuleNotFound(error: unknown): boolean {
168
+ if (!error || typeof error !== 'object') return false
169
+ const { code, message } = error as { code?: unknown; message?: unknown }
170
+ const text = typeof message === 'string' ? message : ''
171
+ const moduleNotFound =
172
+ code === 'MODULE_NOT_FOUND'
173
+ || code === 'ERR_MODULE_NOT_FOUND'
174
+ || text.startsWith('Cannot find module')
175
+ || text.startsWith('Cannot find package')
176
+ return moduleNotFound && /(?:module|package) ['"]@\/di['"]/.test(text)
177
+ }
178
+
179
+ function warnAppDiFailureOnce(
180
+ key: typeof APP_DI_LOAD_WARNING_KEY | typeof APP_DI_REGISTER_WARNING_KEY,
181
+ message: string,
182
+ error: unknown,
183
+ ): void {
184
+ const globalScope = globalThis as Record<string, unknown>
185
+ if (globalScope[key] === true) return
186
+ globalScope[key] = true
187
+ logger.warn(message, { err: error })
144
188
  }
145
189
 
146
190
  function isAwilixResolver(value: unknown): value is Resolver<unknown> {
@@ -197,9 +241,14 @@ export async function createRequestContainer(): Promise<AppContainer> {
197
241
  createCommandOptimisticLockGuardService(),
198
242
  ).scoped(),
199
243
  })
200
- // Allow modules to override/extend
201
- for (const reg of diRegistrars) {
202
- try { reg?.(container) } catch {}
244
+ // Allow modules to override/extend. Fail-open by design (one broken module's
245
+ // di.ts must not take down every request container), but never silently: the
246
+ // module's services would otherwise vanish with no trace until an unrelated
247
+ // Awilix resolution error surfaces much later.
248
+ for (const [registrarIndex, reg] of diRegistrars.entries()) {
249
+ try { reg?.(container) } catch (error) {
250
+ logger.error('Module DI registrar failed', { registrarIndex, err: error })
251
+ }
203
252
  }
204
253
  // Core bootstrap (cache, event bus, encryption subscriber/KMS, module subscribers)
205
254
  // Phase 5 — process-scoped once-guard. The first request runs the full
@@ -235,7 +284,7 @@ export async function createRequestContainer(): Promise<AppContainer> {
235
284
  } catch (error) {
236
285
  logger.error('App-level DI registrar failed', { err: error })
237
286
  }
238
- } else {
287
+ } else if (!isAppDiModuleUnresolvable()) {
239
288
  // Backward-compatible fallback for apps that have not adopted explicit wiring.
240
289
  try {
241
290
  // @ts-ignore - @/di only exists in app context, not in packages
@@ -244,11 +293,26 @@ export async function createRequestContainer(): Promise<AppContainer> {
244
293
  try {
245
294
  const maybe = appDi.register(container)
246
295
  if (maybe && typeof maybe.then === 'function') await maybe
247
- } catch (error) {
248
- logger.error('App-level DI registrar failed', { err: error })
296
+ } catch (err) {
297
+ warnAppDiFailureOnce(
298
+ APP_DI_REGISTER_WARNING_KEY,
299
+ 'App-level DI override (src/di.ts register()) threw; its registrations are skipped',
300
+ err,
301
+ )
249
302
  }
250
303
  }
251
- } catch {}
304
+ } catch (err) {
305
+ if (isAppDiModuleNotFound(err)) {
306
+ markAppDiModuleUnresolvable()
307
+ logger.debug('App-level DI override module (@/di) not resolvable; skipping', { err })
308
+ } else {
309
+ warnAppDiFailureOnce(
310
+ APP_DI_LOAD_WARNING_KEY,
311
+ 'App-level DI override module (@/di) failed to load; its registrations are skipped',
312
+ err,
313
+ )
314
+ }
315
+ }
252
316
  }
253
317
  applyDiOverridesToContainer({
254
318
  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
@@ -4,9 +4,11 @@ import {
4
4
  } from '../subscriber'
5
5
  import type { TenantDataEncryptionService } from '../tenantDataEncryptionService'
6
6
 
7
- // Give every entity a resolvable id so the real decrypt() path proceeds to decryptEntityPayload.
7
+ // Give every entity a resolvable id so the real decrypt() path proceeds to decryptEntityPayload,
8
+ // keying off the class name so a test's fixture and its assertion describe the same entity.
8
9
  jest.mock('../entityIds', () => ({
9
- resolveEntityIdFromMetadata: jest.fn(() => 'customers:customer_address'),
10
+ resolveEntityIdFromMetadata: jest.fn((meta?: { className?: string }) =>
11
+ meta?.className === 'OnboardingRequest' ? 'onboarding:onboarding_request' : 'customers:customer_address'),
10
12
  }))
11
13
 
12
14
  describe('decryptEntitiesWithFallbackScope subscriber memoization (issue #2235)', () => {
@@ -150,3 +152,53 @@ describe('decryptEntitiesWithFallbackScope per-row scope (multi-org safety)', ()
150
152
  expect(calls).toHaveLength(3)
151
153
  })
152
154
  })
155
+
156
+ describe('tenant-less entity encryption delegation', () => {
157
+ const originalToggle = process.env.TENANT_DATA_ENCRYPTION
158
+
159
+ beforeEach(() => {
160
+ process.env.TENANT_DATA_ENCRYPTION = 'yes'
161
+ })
162
+
163
+ afterEach(() => {
164
+ if (originalToggle === undefined) delete process.env.TENANT_DATA_ENCRYPTION
165
+ else process.env.TENANT_DATA_ENCRYPTION = originalToggle
166
+ })
167
+
168
+ it('lets the encryption service resolve an explicit system-scoped map', async () => {
169
+ const encryptEntityPayload = jest.fn(async (
170
+ _entityId: string,
171
+ target: Record<string, unknown>,
172
+ ) => ({ ...target, secret: 'encrypted' }))
173
+ const service = {
174
+ isEnabled: () => true,
175
+ encryptEntityPayload,
176
+ } as unknown as TenantDataEncryptionService
177
+ const subscriber = new TenantEncryptionSubscriber(service)
178
+ const entity = { secret: 'plaintext' }
179
+ const meta = {
180
+ className: 'OnboardingRequest',
181
+ tableName: 'onboarding_requests',
182
+ properties: {
183
+ secret: { name: 'secret', fieldName: 'secret' },
184
+ },
185
+ }
186
+ const changeSet = { payload: { secret: 'plaintext' } }
187
+
188
+ await subscriber.beforeCreate({
189
+ entity,
190
+ meta,
191
+ em: {},
192
+ changeSet,
193
+ } as never)
194
+
195
+ expect(encryptEntityPayload).toHaveBeenCalledWith(
196
+ 'onboarding:onboarding_request',
197
+ expect.any(Object),
198
+ null,
199
+ null,
200
+ )
201
+ expect(entity.secret).toBe('encrypted')
202
+ expect(changeSet.payload.secret).toBe('encrypted')
203
+ })
204
+ })