@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.
- package/.turbo/turbo-build.log +1 -1
- package/AGENTS.md +57 -6
- package/build.mjs +2 -4
- package/dist/lib/ai/safety-identifier.js +32 -0
- package/dist/lib/ai/safety-identifier.js.map +7 -0
- package/dist/lib/auth/organizationScope.js +34 -0
- package/dist/lib/auth/organizationScope.js.map +7 -0
- package/dist/lib/auth/server.js +15 -2
- package/dist/lib/auth/server.js.map +2 -2
- package/dist/lib/bootstrap/clientOnlyModules.js +55 -0
- package/dist/lib/bootstrap/clientOnlyModules.js.map +7 -0
- package/dist/lib/bootstrap/dynamicLoader.js +117 -43
- package/dist/lib/bootstrap/dynamicLoader.js.map +2 -2
- package/dist/lib/commands/command-bus.js +5 -1
- package/dist/lib/commands/command-bus.js.map +2 -2
- package/dist/lib/commands/command-interceptor-runner.js +2 -2
- package/dist/lib/commands/command-interceptor-runner.js.map +2 -2
- package/dist/lib/crud/enricher-runner.js +2 -11
- package/dist/lib/crud/enricher-runner.js.map +2 -2
- package/dist/lib/crud/factory.js +72 -9
- package/dist/lib/crud/factory.js.map +2 -2
- package/dist/lib/crud/interceptor-runner.js +2 -2
- package/dist/lib/crud/interceptor-runner.js.map +2 -2
- package/dist/lib/crud/mutation-guard-registry.js +2 -2
- package/dist/lib/crud/mutation-guard-registry.js.map +2 -2
- package/dist/lib/crud/types.js +4 -0
- package/dist/lib/crud/types.js.map +3 -3
- package/dist/lib/data/consistency.js +19 -0
- package/dist/lib/data/consistency.js.map +7 -0
- package/dist/lib/data/engine.js +37 -11
- package/dist/lib/data/engine.js.map +2 -2
- package/dist/lib/db/entityMetadata.js +26 -0
- package/dist/lib/db/entityMetadata.js.map +7 -0
- package/dist/lib/db/pg-errors.js +42 -0
- package/dist/lib/db/pg-errors.js.map +2 -2
- package/dist/lib/di/container.js +48 -6
- 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/encryption/subscriber.js +2 -10
- package/dist/lib/encryption/subscriber.js.map +2 -2
- package/dist/lib/encryption/tenantDataEncryptionService.js +52 -17
- package/dist/lib/encryption/tenantDataEncryptionService.js.map +2 -2
- package/dist/lib/i18n/config.js +1 -1
- package/dist/lib/i18n/config.js.map +2 -2
- package/dist/lib/i18n/server.js +4 -4
- package/dist/lib/i18n/server.js.map +3 -3
- package/dist/lib/logger/extension.js +29 -0
- package/dist/lib/logger/extension.js.map +7 -0
- package/dist/lib/logger/index.js +47 -3
- package/dist/lib/logger/index.js.map +3 -3
- package/dist/lib/modules/registry.js +5 -1
- package/dist/lib/modules/registry.js.map +2 -2
- package/dist/lib/modules/surfaceFingerprint.js +47 -0
- package/dist/lib/modules/surfaceFingerprint.js.map +7 -0
- package/dist/lib/query/ciphertext-search-warning.js +45 -0
- package/dist/lib/query/ciphertext-search-warning.js.map +7 -0
- package/dist/lib/query/engine.js +54 -40
- package/dist/lib/query/engine.js.map +2 -2
- package/dist/lib/query/types.js.map +1 -1
- package/dist/lib/redis/connection.js +7 -2
- package/dist/lib/redis/connection.js.map +2 -2
- package/dist/lib/search/auto-indexing.js +14 -0
- package/dist/lib/search/auto-indexing.js.map +7 -0
- package/dist/lib/search/availability.js +107 -0
- package/dist/lib/search/availability.js.map +7 -0
- package/dist/lib/search/config.js +61 -2
- package/dist/lib/search/config.js.map +2 -2
- package/dist/lib/search/tokenLookup.js +46 -0
- package/dist/lib/search/tokenLookup.js.map +7 -0
- package/dist/lib/search/tokenize.js +23 -11
- package/dist/lib/search/tokenize.js.map +2 -2
- package/dist/lib/telemetry/runtime.js +35 -0
- package/dist/lib/telemetry/runtime.js.map +7 -0
- 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/inbound-types.js +1 -0
- package/dist/lib/webhooks/inbound-types.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/analytics.js.map +2 -2
- package/dist/modules/overrides.js +50 -1
- package/dist/modules/overrides.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/registry.js +1 -0
- package/dist/modules/registry.js.map +2 -2
- package/dist/modules/widgets/extension-points.js +96 -0
- package/dist/modules/widgets/extension-points.js.map +7 -0
- package/dist/security/enabledModulesRegistry.js +56 -11
- package/dist/security/enabledModulesRegistry.js.map +2 -2
- package/dist/security/featurePolicy.js +62 -0
- package/dist/security/featurePolicy.js.map +7 -0
- package/jest.config.cjs +2 -1
- package/package.json +19 -9
- package/scripts/versionSource.cjs +65 -0
- package/src/lib/__tests__/versionSource.test.ts +46 -0
- package/src/lib/ai/__tests__/llm-provider-contract.test.ts +59 -0
- package/src/lib/ai/__tests__/safety-identifier.test.ts +71 -0
- package/src/lib/ai/llm-provider.ts +33 -0
- package/src/lib/ai/safety-identifier.ts +73 -0
- package/src/lib/auth/__tests__/organizationScope.test.ts +107 -0
- package/src/lib/auth/__tests__/server.test.ts +23 -0
- package/src/lib/auth/organizationScope.ts +65 -0
- package/src/lib/auth/server.ts +23 -2
- package/src/lib/bootstrap/__tests__/clientOnlyModules.test.ts +189 -0
- package/src/lib/bootstrap/__tests__/dynamicLoader.aliasResolver.test.ts +98 -0
- package/src/lib/bootstrap/__tests__/dynamicLoader.appDi.test.ts +211 -0
- package/src/lib/bootstrap/__tests__/dynamicLoader.commandInterceptors.test.ts +63 -8
- package/src/lib/bootstrap/__tests__/dynamicLoader.generatedCacheRecovery.test.ts +256 -0
- package/src/lib/bootstrap/__tests__/dynamicLoader.tsconfig.test.ts +21 -1
- package/src/lib/bootstrap/clientOnlyModules.ts +85 -0
- package/src/lib/bootstrap/dynamicLoader.ts +213 -65
- package/src/lib/commands/__tests__/command-interceptor-runner.test.ts +28 -0
- package/src/lib/commands/command-bus.ts +5 -1
- package/src/lib/commands/command-interceptor-runner.ts +2 -2
- package/src/lib/crud/__tests__/crud-factory.cache-user-scope.test.ts +171 -0
- package/src/lib/crud/__tests__/crud-factory.test.ts +351 -0
- package/src/lib/crud/__tests__/mutation-guard-registry.test.ts +26 -0
- package/src/lib/crud/enricher-runner.ts +2 -11
- package/src/lib/crud/factory.ts +82 -6
- package/src/lib/crud/interceptor-runner.ts +2 -2
- package/src/lib/crud/mutation-guard-registry.ts +2 -2
- package/src/lib/crud/types.ts +3 -0
- package/src/lib/data/__tests__/consistency.test.ts +38 -0
- package/src/lib/data/__tests__/engine.bulk-suppress.test.ts +18 -3
- package/src/lib/data/consistency.ts +17 -0
- package/src/lib/data/engine.ts +50 -16
- package/src/lib/db/__tests__/entityMetadata.test.ts +38 -0
- package/src/lib/db/__tests__/pg-errors.test.ts +48 -0
- package/src/lib/db/entityMetadata.ts +43 -0
- package/src/lib/db/pg-errors.ts +58 -0
- package/src/lib/di/__tests__/container-app-di-absent.test.ts +155 -0
- package/src/lib/di/__tests__/container-app-di.test.ts +173 -0
- package/src/lib/di/__tests__/registrar-error-log.test.ts +154 -0
- package/src/lib/di/container.ts +72 -8
- package/src/lib/email/__tests__/send.test.ts +37 -1
- package/src/lib/email/send.ts +79 -0
- package/src/lib/encryption/__tests__/subscriber.test.ts +54 -2
- package/src/lib/encryption/__tests__/tenantDataEncryptionService.test.ts +58 -0
- package/src/lib/encryption/subscriber.ts +2 -14
- package/src/lib/encryption/tenantDataEncryptionService.ts +64 -18
- package/src/lib/i18n/__tests__/config.test.ts +8 -0
- package/src/lib/i18n/__tests__/server-dictionary-cache.test.ts +30 -0
- package/src/lib/i18n/__tests__/server-unbootstrapped.test.ts +29 -0
- package/src/lib/i18n/config.ts +2 -2
- package/src/lib/i18n/server.ts +6 -2
- package/src/lib/logger/__tests__/logger.test.ts +77 -0
- package/src/lib/logger/extension.ts +59 -0
- package/src/lib/logger/index.ts +59 -1
- package/src/lib/modules/__tests__/surfaceFingerprint.test.ts +122 -0
- package/src/lib/modules/registry.ts +10 -0
- package/src/lib/modules/surfaceFingerprint.ts +87 -0
- package/src/lib/query/__tests__/ciphertext-search-warning.test.ts +178 -0
- package/src/lib/query/__tests__/engine.test.ts +65 -13
- package/src/lib/query/ciphertext-search-warning.ts +95 -0
- package/src/lib/query/engine.ts +75 -58
- package/src/lib/query/types.ts +4 -5
- package/src/lib/redis/__tests__/connection.test.ts +16 -0
- package/src/lib/redis/connection.ts +10 -7
- package/src/lib/search/__tests__/availability.test.ts +211 -0
- package/src/lib/search/__tests__/config.test.ts +177 -0
- package/src/lib/search/__tests__/tokenLookup.test.ts +206 -0
- package/src/lib/search/__tests__/tokenize.test.ts +49 -0
- package/src/lib/search/auto-indexing.ts +22 -0
- package/src/lib/search/availability.ts +232 -0
- package/src/lib/search/config.ts +106 -8
- package/src/lib/search/tokenLookup.ts +133 -0
- package/src/lib/search/tokenize.ts +33 -11
- package/src/lib/telemetry/runtime.ts +72 -0
- 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/inbound-types.ts +125 -0
- package/src/lib/webhooks/index.ts +19 -1
- package/src/lib/webhooks/verify.ts +17 -9
- package/src/modules/__tests__/nav-group-order-override.test.ts +183 -0
- package/src/modules/__tests__/registry.test.ts +10 -2
- package/src/modules/analytics.ts +7 -0
- package/src/modules/customer-auth.ts +1 -0
- package/src/modules/encryption.ts +3 -0
- package/src/modules/navigation/backendChrome.ts +15 -0
- package/src/modules/overrides.ts +103 -0
- package/src/modules/payment_gateways/__tests__/types.test.ts +37 -0
- package/src/modules/payment_gateways/types.ts +41 -0
- package/src/modules/registry.ts +3 -0
- package/src/modules/widgets/__tests__/extension-points.test.ts +101 -0
- package/src/modules/widgets/extension-points.ts +524 -0
- package/src/security/__tests__/featurePolicy.test.ts +166 -0
- package/src/security/enabledModulesRegistry.ts +64 -12
- package/src/security/featurePolicy.ts +89 -0
|
@@ -196,6 +196,64 @@ describe('TenantDataEncryptionService.encryptFields (issue #2720)', () => {
|
|
|
196
196
|
})
|
|
197
197
|
})
|
|
198
198
|
|
|
199
|
+
describe('TenantDataEncryptionService system-scoped maps', () => {
|
|
200
|
+
const originalToggle = process.env.TENANT_DATA_ENCRYPTION
|
|
201
|
+
|
|
202
|
+
beforeEach(() => {
|
|
203
|
+
process.env.TENANT_DATA_ENCRYPTION = 'yes'
|
|
204
|
+
})
|
|
205
|
+
|
|
206
|
+
afterEach(() => {
|
|
207
|
+
if (originalToggle === undefined) delete process.env.TENANT_DATA_ENCRYPTION
|
|
208
|
+
else process.env.TENANT_DATA_ENCRYPTION = originalToggle
|
|
209
|
+
})
|
|
210
|
+
|
|
211
|
+
it('encrypts tenant-less payloads with a stable entity-scoped KMS key', async () => {
|
|
212
|
+
const entityId = 'onboarding:onboarding_request'
|
|
213
|
+
const systemKeyId = `system:${entityId}`
|
|
214
|
+
const getTenantDek = jest.fn(async (keyId: string) => (
|
|
215
|
+
keyId === systemKeyId
|
|
216
|
+
? { tenantId: keyId, key: fixedKey, fetchedAt: new Date() }
|
|
217
|
+
: null
|
|
218
|
+
))
|
|
219
|
+
const service = new TenantDataEncryptionService(
|
|
220
|
+
{
|
|
221
|
+
getConnection: () => ({ execute: jest.fn(async () => []) }),
|
|
222
|
+
} as never,
|
|
223
|
+
{
|
|
224
|
+
kms: {
|
|
225
|
+
getTenantDek,
|
|
226
|
+
createTenantDek: jest.fn(async () => null),
|
|
227
|
+
isHealthy: () => true,
|
|
228
|
+
},
|
|
229
|
+
defaultEncryptionMaps: [
|
|
230
|
+
{
|
|
231
|
+
entityId,
|
|
232
|
+
keyScope: 'system',
|
|
233
|
+
fields: [
|
|
234
|
+
{ field: 'email', hashField: 'email_hash' },
|
|
235
|
+
{ field: 'password_hash' },
|
|
236
|
+
],
|
|
237
|
+
},
|
|
238
|
+
],
|
|
239
|
+
} as never,
|
|
240
|
+
)
|
|
241
|
+
|
|
242
|
+
const encrypted = await service.encryptEntityPayload(entityId, {
|
|
243
|
+
email: 'person@example.com',
|
|
244
|
+
email_hash: null,
|
|
245
|
+
password_hash: 'bcrypt-value',
|
|
246
|
+
}, null, null)
|
|
247
|
+
|
|
248
|
+
expect(getTenantDek).toHaveBeenCalledWith(systemKeyId)
|
|
249
|
+
expect(encrypted.email).not.toBe('person@example.com')
|
|
250
|
+
expect(encrypted.password_hash).not.toBe('bcrypt-value')
|
|
251
|
+
expect(encrypted.email_hash).toBe(hashForLookup('person@example.com'))
|
|
252
|
+
expect(decryptWithAesGcm(encrypted.email as string, fixedKey)).toBe('person@example.com')
|
|
253
|
+
expect(decryptWithAesGcm(encrypted.password_hash as string, fixedKey)).toBe('bcrypt-value')
|
|
254
|
+
})
|
|
255
|
+
})
|
|
256
|
+
|
|
199
257
|
describe('TenantDataEncryptionService.getEncryptedFieldNames', () => {
|
|
200
258
|
it('returns active encryption-map field names for query planning', async () => {
|
|
201
259
|
const service = new TenantDataEncryptionService({} as never)
|
|
@@ -6,6 +6,7 @@ import { isTenantDataEncryptionEnabled } from './toggles'
|
|
|
6
6
|
import { isEncryptionDebugEnabled } from './toggles'
|
|
7
7
|
import { resolveTenantEncryptionService } from './customFieldValues'
|
|
8
8
|
import { createLogger } from '../logger'
|
|
9
|
+
import { listEntityMetadataFromRegistry } from '../db/entityMetadata'
|
|
9
10
|
|
|
10
11
|
const logger = createLogger('shared').child({ component: 'encryption' })
|
|
11
12
|
|
|
@@ -85,13 +86,8 @@ export class TenantEncryptionSubscriber implements EventSubscriber<any> {
|
|
|
85
86
|
try { return registry.find?.(ctor) } catch {}
|
|
86
87
|
try { return registry.get?.(name) } catch {}
|
|
87
88
|
try { return registry.get?.(ctor) } catch {}
|
|
88
|
-
const all =
|
|
89
|
-
(typeof registry.getAll === 'function' && registry.getAll()) ||
|
|
90
|
-
(Array.isArray((registry as any).metadata) ? (registry as any).metadata : undefined) ||
|
|
91
|
-
(registry as any).metadata ||
|
|
92
|
-
{}
|
|
93
89
|
try {
|
|
94
|
-
const entries =
|
|
90
|
+
const entries = listEntityMetadataFromRegistry(registry)
|
|
95
91
|
const match = entries.find(
|
|
96
92
|
(m: any) =>
|
|
97
93
|
m?.className === name ||
|
|
@@ -224,10 +220,6 @@ export class TenantEncryptionSubscriber implements EventSubscriber<any> {
|
|
|
224
220
|
return
|
|
225
221
|
}
|
|
226
222
|
const { tenantId, organizationId } = resolveScope(target)
|
|
227
|
-
if (!tenantId) {
|
|
228
|
-
debug('⚪️ subscriber.skip', { reason: 'no-tenant', entityId })
|
|
229
|
-
return
|
|
230
|
-
}
|
|
231
223
|
const encrypted = await this.service.encryptEntityPayload(entityId, target, tenantId, organizationId)
|
|
232
224
|
const metaProps: Record<string, unknown> = resolvedMeta?.properties && typeof resolvedMeta.properties === 'object'
|
|
233
225
|
? resolvedMeta.properties
|
|
@@ -325,10 +317,6 @@ export class TenantEncryptionSubscriber implements EventSubscriber<any> {
|
|
|
325
317
|
const { tenantId, organizationId } = resolveScope(target)
|
|
326
318
|
const scopedTenantId = tenantId ?? fallbackScope?.tenantId ?? null
|
|
327
319
|
const scopedOrgId = organizationId ?? fallbackScope?.organizationId ?? null
|
|
328
|
-
if (!scopedTenantId) {
|
|
329
|
-
debug('⚪️ subscriber.skip', { reason: 'no-tenant', entityId })
|
|
330
|
-
return
|
|
331
|
-
}
|
|
332
320
|
// Capture pending (un-flushed) changes BEFORE decrypt mutates the target. Re-baselining a
|
|
333
321
|
// managed entity that a command already mutated would clear its dirty changeset and silently
|
|
334
322
|
// drop the pending write (e.g. an undo handler that mutates an entity, then loads a related
|
|
@@ -4,6 +4,7 @@ import { decryptWithAesGcm, encryptWithAesGcm, hashForLookup } from './aes'
|
|
|
4
4
|
import { createKmsService, type KmsService, type TenantDek } from './kms'
|
|
5
5
|
import { isTenantDataEncryptionEnabled, isEncryptionDebugEnabled } from './toggles'
|
|
6
6
|
import { createLogger } from '../logger'
|
|
7
|
+
import type { EncryptionKeyScope, ModuleEncryptionMap } from '../../modules/encryption'
|
|
7
8
|
|
|
8
9
|
const logger = createLogger('shared').child({ component: 'tenant-encryption' })
|
|
9
10
|
|
|
@@ -14,6 +15,7 @@ export type EncryptedFieldRule = {
|
|
|
14
15
|
|
|
15
16
|
export type EncryptionMapRecord = {
|
|
16
17
|
entityId: string
|
|
18
|
+
keyScope?: EncryptionKeyScope
|
|
17
19
|
fields: EncryptedFieldRule[]
|
|
18
20
|
}
|
|
19
21
|
|
|
@@ -154,13 +156,23 @@ export class TenantDataEncryptionService {
|
|
|
154
156
|
private readonly inflightDeks = TenantDataEncryptionService.globalInflightDeks
|
|
155
157
|
private readonly inflightMaps = TenantDataEncryptionService.globalInflightMaps
|
|
156
158
|
private readonly missCache = TenantDataEncryptionService.globalMissCache
|
|
159
|
+
private readonly systemDefaultMaps: Map<string, ModuleEncryptionMap>
|
|
157
160
|
|
|
158
161
|
constructor(
|
|
159
162
|
private em: EntityManager,
|
|
160
|
-
opts?: {
|
|
163
|
+
opts?: {
|
|
164
|
+
cache?: CacheStrategy
|
|
165
|
+
kms?: KmsService
|
|
166
|
+
defaultEncryptionMaps?: readonly ModuleEncryptionMap[]
|
|
167
|
+
}
|
|
161
168
|
) {
|
|
162
169
|
this.cache = opts?.cache
|
|
163
170
|
this.kms = opts?.kms ?? createKmsService()
|
|
171
|
+
this.systemDefaultMaps = new Map(
|
|
172
|
+
(opts?.defaultEncryptionMaps ?? [])
|
|
173
|
+
.filter((map) => map.keyScope === 'system')
|
|
174
|
+
.map((map) => [map.entityId, map]),
|
|
175
|
+
)
|
|
164
176
|
}
|
|
165
177
|
|
|
166
178
|
isEnabled(): boolean {
|
|
@@ -239,6 +251,24 @@ export class TenantDataEncryptionService {
|
|
|
239
251
|
}
|
|
240
252
|
}
|
|
241
253
|
|
|
254
|
+
private applySystemDefault(record: EncryptionMapRecord | null, entityId: string): EncryptionMapRecord | null {
|
|
255
|
+
const declared = this.systemDefaultMaps.get(entityId)
|
|
256
|
+
if (!declared) return record
|
|
257
|
+
const fields: EncryptedFieldRule[] = declared.fields.map((field) => ({
|
|
258
|
+
field: field.field,
|
|
259
|
+
hashField: field.hashField ?? null,
|
|
260
|
+
}))
|
|
261
|
+
const declaredFields = new Set(fields.map((field) => field.field))
|
|
262
|
+
for (const field of record?.fields ?? []) {
|
|
263
|
+
if (!declaredFields.has(field.field)) fields.push(field)
|
|
264
|
+
}
|
|
265
|
+
return {
|
|
266
|
+
entityId,
|
|
267
|
+
keyScope: 'system',
|
|
268
|
+
fields,
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
242
272
|
private async getMap(key: MapCacheKey): Promise<EncryptionMapRecord | null> {
|
|
243
273
|
const shouldSkipLookup = (tag: string) => {
|
|
244
274
|
const expiresAt = this.missCache.get(tag)
|
|
@@ -262,13 +292,13 @@ export class TenantDataEncryptionService {
|
|
|
262
292
|
if (this.inflightMaps.has(tag)) {
|
|
263
293
|
const pending = this.inflightMaps.get(tag)!
|
|
264
294
|
const resolved = await pending
|
|
265
|
-
if (resolved) return resolved
|
|
295
|
+
if (resolved) return this.applySystemDefault(resolved, key.entityId)
|
|
266
296
|
}
|
|
267
297
|
const mem = this.memoryCache.get(tag)
|
|
268
|
-
if (mem) return mem
|
|
298
|
+
if (mem) return this.applySystemDefault(mem, key.entityId)
|
|
269
299
|
if (this.cache && typeof this.cache.get === 'function') {
|
|
270
300
|
const cached = await this.cache.get(tag)
|
|
271
|
-
if (cached) return cached as EncryptionMapRecord
|
|
301
|
+
if (cached) return this.applySystemDefault(cached as EncryptionMapRecord, key.entityId)
|
|
272
302
|
}
|
|
273
303
|
const pending = this.fetchMap(candidate)
|
|
274
304
|
this.inflightMaps.set(tag, pending)
|
|
@@ -288,9 +318,9 @@ export class TenantDataEncryptionService {
|
|
|
288
318
|
if (this.cache && typeof this.cache.set === 'function') {
|
|
289
319
|
await this.cache.set(tag, loaded, { ttl: 300 })
|
|
290
320
|
}
|
|
291
|
-
return loaded
|
|
321
|
+
return this.applySystemDefault(loaded, key.entityId)
|
|
292
322
|
}
|
|
293
|
-
return null
|
|
323
|
+
return this.applySystemDefault(null, key.entityId)
|
|
294
324
|
}
|
|
295
325
|
|
|
296
326
|
private async fetchAllOrganizationFieldNames(entityId: string, tenantId: string | null): Promise<string[]> {
|
|
@@ -336,12 +366,26 @@ export class TenantDataEncryptionService {
|
|
|
336
366
|
this.kms.invalidateDek?.(tenantId)
|
|
337
367
|
}
|
|
338
368
|
|
|
369
|
+
/**
|
|
370
|
+
* Lists the fields an encryption map marks as encrypted at rest.
|
|
371
|
+
*
|
|
372
|
+
* `isEnabled()` folds the environment toggle together with KMS health, so by default an
|
|
373
|
+
* unhealthy KMS reports "nothing is encrypted" — which is safe for write paths but wrong for
|
|
374
|
+
* readers that must decide whether a stored column holds ciphertext. `ignoreRuntimeHealth`
|
|
375
|
+
* answers the on-disk question instead: it consults the map even when the KMS cannot currently
|
|
376
|
+
* resolve a DEK, so callers can fail closed rather than treat ciphertext as plaintext (#4622).
|
|
377
|
+
*/
|
|
339
378
|
async getEncryptedFieldNames(
|
|
340
379
|
entityId: string,
|
|
341
380
|
tenantId: string | null | undefined,
|
|
342
|
-
organizationId?: string | null
|
|
381
|
+
organizationId?: string | null,
|
|
382
|
+
options?: { ignoreRuntimeHealth?: boolean }
|
|
343
383
|
): Promise<string[]> {
|
|
344
|
-
if (
|
|
384
|
+
if (options?.ignoreRuntimeHealth) {
|
|
385
|
+
if (!isTenantDataEncryptionEnabled()) return []
|
|
386
|
+
} else if (!this.isEnabled()) {
|
|
387
|
+
return []
|
|
388
|
+
}
|
|
345
389
|
const map = await this.getMap({ entityId, tenantId: tenantId ?? null, organizationId: organizationId ?? null })
|
|
346
390
|
const fields = new Set(normalizeEncryptedFieldNames(map?.fields))
|
|
347
391
|
if (organizationId == null) {
|
|
@@ -421,16 +465,17 @@ export class TenantDataEncryptionService {
|
|
|
421
465
|
debug('⚪️ encrypt.skip.disabled', { entityId, tenantId })
|
|
422
466
|
return payload
|
|
423
467
|
}
|
|
424
|
-
const dek = await this.resolveDekForEncrypt(tenantId ?? null)
|
|
425
|
-
if (!dek) {
|
|
426
|
-
debug('⚠️ encrypt.skip.no-dek', { entityId, tenantId })
|
|
427
|
-
return payload
|
|
428
|
-
}
|
|
429
468
|
const map = await this.getMap({ entityId, tenantId: tenantId ?? null, organizationId: organizationId ?? null })
|
|
430
469
|
if (!map || !map.fields?.length) {
|
|
431
470
|
debug('⚪️ encrypt.skip.no-map', { entityId, tenantId })
|
|
432
471
|
return payload
|
|
433
472
|
}
|
|
473
|
+
const keyId = map.keyScope === 'system' ? `system:${entityId}` : tenantId ?? null
|
|
474
|
+
const dek = await this.resolveDekForEncrypt(keyId)
|
|
475
|
+
if (!dek) {
|
|
476
|
+
debug('⚠️ encrypt.skip.no-dek', { entityId, tenantId, keyScope: map.keyScope ?? 'tenant' })
|
|
477
|
+
return payload
|
|
478
|
+
}
|
|
434
479
|
debug('🔒 encrypt_entity', { entityId, tenantId, organizationId, fields: map.fields.length })
|
|
435
480
|
return this.encryptFields(payload, map.fields, dek)
|
|
436
481
|
}
|
|
@@ -445,16 +490,17 @@ export class TenantDataEncryptionService {
|
|
|
445
490
|
debug('⚪️ decrypt.skip.disabled', { entityId, tenantId })
|
|
446
491
|
return payload
|
|
447
492
|
}
|
|
448
|
-
const dek = await this.getDek(tenantId ?? null)
|
|
449
|
-
if (!dek) {
|
|
450
|
-
debug('⚠️ decrypt.skip.no-dek', { entityId, tenantId })
|
|
451
|
-
return payload
|
|
452
|
-
}
|
|
453
493
|
const map = await this.getMap({ entityId, tenantId: tenantId ?? null, organizationId: organizationId ?? null })
|
|
454
494
|
if (!map || !map.fields?.length) {
|
|
455
495
|
debug('⚪️ decrypt.skip.no-map', { entityId, tenantId })
|
|
456
496
|
return payload
|
|
457
497
|
}
|
|
498
|
+
const keyId = map.keyScope === 'system' ? `system:${entityId}` : tenantId ?? null
|
|
499
|
+
const dek = await this.getDek(keyId)
|
|
500
|
+
if (!dek) {
|
|
501
|
+
debug('⚠️ decrypt.skip.no-dek', { entityId, tenantId, keyScope: map.keyScope ?? 'tenant' })
|
|
502
|
+
return payload
|
|
503
|
+
}
|
|
458
504
|
debug('🔓 decrypt_entity', { entityId, tenantId, organizationId, fields: map.fields.length })
|
|
459
505
|
return this.decryptFields(payload, map.fields, dek)
|
|
460
506
|
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { defaultLocale, locales, type Locale } from '../config'
|
|
2
|
+
|
|
3
|
+
describe('supported application locales', () => {
|
|
4
|
+
it('registers Korean without changing the default locale', () => {
|
|
5
|
+
expect(locales).toEqual<Locale[]>(['en', 'pl', 'es', 'de', 'ko'])
|
|
6
|
+
expect(defaultLocale).toBe('en')
|
|
7
|
+
})
|
|
8
|
+
})
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { loadDictionary, registerModules } from '../server'
|
|
2
|
+
import type { Module } from '../../../modules/registry'
|
|
3
|
+
|
|
4
|
+
describe('loadDictionary memoization', () => {
|
|
5
|
+
it('returns a cached dictionary for repeated calls with the same locale', async () => {
|
|
6
|
+
registerModules([
|
|
7
|
+
{ id: 'demo', translations: { en: { 'demo.hello': 'Hello' } } },
|
|
8
|
+
] satisfies Module[])
|
|
9
|
+
|
|
10
|
+
const first = await loadDictionary('en')
|
|
11
|
+
const second = await loadDictionary('en')
|
|
12
|
+
|
|
13
|
+
expect(second).toBe(first)
|
|
14
|
+
expect(first['demo.hello']).toBe('Hello')
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
it('busts the cache when the registered module set changes', async () => {
|
|
18
|
+
registerModules([{ id: 'a', translations: { en: { 'a.k': 'A' } } }] satisfies Module[])
|
|
19
|
+
const before = await loadDictionary('en')
|
|
20
|
+
|
|
21
|
+
registerModules([
|
|
22
|
+
{ id: 'a', translations: { en: { 'a.k': 'A' } } },
|
|
23
|
+
{ id: 'b', translations: { en: { 'b.k': 'B' } } },
|
|
24
|
+
] satisfies Module[])
|
|
25
|
+
const after = await loadDictionary('en')
|
|
26
|
+
|
|
27
|
+
expect(after).not.toBe(before)
|
|
28
|
+
expect(after['b.k']).toBe('B')
|
|
29
|
+
})
|
|
30
|
+
})
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { invalidateDictionaryCache, loadDictionary, registerModules, resolveTranslations } from '../server'
|
|
2
|
+
import type { Module } from '../../../modules/registry'
|
|
3
|
+
|
|
4
|
+
const REGISTRY_GLOBAL_KEY = '__openMercatoModulesRegistry__'
|
|
5
|
+
|
|
6
|
+
describe('loadDictionary without a bootstrapped module registry', () => {
|
|
7
|
+
beforeEach(() => {
|
|
8
|
+
delete (globalThis as Record<string, unknown>)[REGISTRY_GLOBAL_KEY]
|
|
9
|
+
invalidateDictionaryCache()
|
|
10
|
+
})
|
|
11
|
+
|
|
12
|
+
it('resolves the app dictionary instead of throwing a bootstrap error', async () => {
|
|
13
|
+
await expect(loadDictionary('en')).resolves.toEqual(expect.any(Object))
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
it('lets a route handler translate its response before bootstrap runs', async () => {
|
|
17
|
+
const { translate } = await resolveTranslations()
|
|
18
|
+
|
|
19
|
+
expect(translate('storage_s3.upload.errors.unsupported', 'Unsupported file type')).toBe('Unsupported file type')
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
it('picks up module translations once bootstrap registers them', async () => {
|
|
23
|
+
await loadDictionary('en')
|
|
24
|
+
|
|
25
|
+
registerModules([{ id: 'demo', translations: { en: { 'demo.hello': 'Hello' } } }] satisfies Module[])
|
|
26
|
+
|
|
27
|
+
await expect(loadDictionary('en')).resolves.toMatchObject({ 'demo.hello': 'Hello' })
|
|
28
|
+
})
|
|
29
|
+
})
|
package/src/lib/i18n/config.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export type Locale = 'en' | 'pl' | 'es' | 'de'
|
|
1
|
+
export type Locale = 'en' | 'pl' | 'es' | 'de' | 'ko'
|
|
2
2
|
|
|
3
|
-
export const locales: Locale[] = ['en', 'pl', 'es', 'de']
|
|
3
|
+
export const locales: Locale[] = ['en', 'pl', 'es', 'de', 'ko']
|
|
4
4
|
export const defaultLocale: Locale = 'en'
|
package/src/lib/i18n/server.ts
CHANGED
|
@@ -2,7 +2,7 @@ import { defaultLocale, locales, type Locale } from './config'
|
|
|
2
2
|
import type { Dict } from './context'
|
|
3
3
|
import { resolveForcedLocale, resolveLocaleFromAcceptLanguage } from './locale'
|
|
4
4
|
import { createFallbackTranslator, createTranslator } from './translate'
|
|
5
|
-
import {
|
|
5
|
+
import { tryGetModules } from '../modules/registry'
|
|
6
6
|
import { loadAppDictionary } from './app-dictionaries'
|
|
7
7
|
import { getCachedDictionary, setCachedDictionary } from './dictionary-cache'
|
|
8
8
|
|
|
@@ -61,7 +61,11 @@ export async function loadDictionary(locale: Locale): Promise<Dict> {
|
|
|
61
61
|
// Load from registry instead of @/ import (works in standalone packages)
|
|
62
62
|
const baseRaw = await loadAppDictionary(locale)
|
|
63
63
|
const merged: Dict = { ...flattenDictionary(baseRaw) }
|
|
64
|
-
|
|
64
|
+
// Route handlers translate their responses, so they resolve a dictionary even
|
|
65
|
+
// when they are exercised in isolation without a bootstrapped registry. The
|
|
66
|
+
// app dictionary alone is the right degraded answer there — `registerModules`
|
|
67
|
+
// invalidates this cache, so a later bootstrap still gets the merged result.
|
|
68
|
+
const modules = tryGetModules() ?? []
|
|
65
69
|
for (const m of modules) {
|
|
66
70
|
const dict = m.translations?.[locale]
|
|
67
71
|
if (dict) Object.assign(merged, flattenDictionary(dict))
|
|
@@ -3,7 +3,9 @@ import {
|
|
|
3
3
|
createLogger,
|
|
4
4
|
getLogLevel,
|
|
5
5
|
isLevelEnabled,
|
|
6
|
+
registerLoggerExtension,
|
|
6
7
|
resetLogLevelCache,
|
|
8
|
+
resetLoggerExtension,
|
|
7
9
|
resetLoggerRegistry,
|
|
8
10
|
} from '../index'
|
|
9
11
|
import { resolveLevel, type LogLevel } from '../level'
|
|
@@ -65,6 +67,7 @@ function resetLoggerState(): void {
|
|
|
65
67
|
resetLoggerRegistry()
|
|
66
68
|
resetServerLoggerCache()
|
|
67
69
|
resetLogPrettyCache()
|
|
70
|
+
resetLoggerExtension()
|
|
68
71
|
}
|
|
69
72
|
|
|
70
73
|
function forcePinoTransport(): void {
|
|
@@ -241,6 +244,80 @@ describe('structured logging facade', () => {
|
|
|
241
244
|
})
|
|
242
245
|
})
|
|
243
246
|
|
|
247
|
+
describe('process-wide logger extension', () => {
|
|
248
|
+
beforeEach(() => {
|
|
249
|
+
forcePinoTransport()
|
|
250
|
+
})
|
|
251
|
+
|
|
252
|
+
it('keeps one local line and observes one correlated record with child bindings', () => {
|
|
253
|
+
const fake = createFakePinoHarness()
|
|
254
|
+
mockPinoLoader(fake.factory)
|
|
255
|
+
const records: unknown[] = []
|
|
256
|
+
registerLoggerExtension({
|
|
257
|
+
enrich: () => ({ trace_id: 'trace-1', span_id: 'span-1' }),
|
|
258
|
+
emit: (record) => records.push(record),
|
|
259
|
+
})
|
|
260
|
+
|
|
261
|
+
createLogger('orders')
|
|
262
|
+
.child({ module: 'sales' })
|
|
263
|
+
.info('Order placed', { orderId: 'o-1' })
|
|
264
|
+
|
|
265
|
+
expect(fake.calls).toEqual([{
|
|
266
|
+
level: 'info',
|
|
267
|
+
args: [{
|
|
268
|
+
orderId: 'o-1',
|
|
269
|
+
trace_id: 'trace-1',
|
|
270
|
+
span_id: 'span-1',
|
|
271
|
+
}, 'Order placed'],
|
|
272
|
+
}])
|
|
273
|
+
expect(records).toHaveLength(1)
|
|
274
|
+
expect(records[0]).toMatchObject({
|
|
275
|
+
level: 'info',
|
|
276
|
+
namespace: 'orders',
|
|
277
|
+
message: 'Order placed',
|
|
278
|
+
fields: {
|
|
279
|
+
module: 'sales',
|
|
280
|
+
orderId: 'o-1',
|
|
281
|
+
trace_id: 'trace-1',
|
|
282
|
+
span_id: 'span-1',
|
|
283
|
+
},
|
|
284
|
+
})
|
|
285
|
+
})
|
|
286
|
+
|
|
287
|
+
it('applies the shared level gate to local and remote output', () => {
|
|
288
|
+
process.env.OM_LOG_LEVEL = 'warn'
|
|
289
|
+
resetLogLevelCache()
|
|
290
|
+
const fake = createFakePinoHarness()
|
|
291
|
+
mockPinoLoader(fake.factory)
|
|
292
|
+
const emit = jest.fn()
|
|
293
|
+
registerLoggerExtension({ emit })
|
|
294
|
+
const logger = createLogger('gated-extension')
|
|
295
|
+
|
|
296
|
+
logger.info('quiet')
|
|
297
|
+
logger.warn('visible')
|
|
298
|
+
|
|
299
|
+
expect(fake.calls.map((call) => call.level)).toEqual(['warn'])
|
|
300
|
+
expect(emit).toHaveBeenCalledTimes(1)
|
|
301
|
+
expect(emit.mock.calls[0][0]).toMatchObject({ level: 'warn', message: 'visible' })
|
|
302
|
+
})
|
|
303
|
+
|
|
304
|
+
it('isolates extension failures from application logging', () => {
|
|
305
|
+
const fake = createFakePinoHarness()
|
|
306
|
+
mockPinoLoader(fake.factory)
|
|
307
|
+
registerLoggerExtension({
|
|
308
|
+
enrich: () => {
|
|
309
|
+
throw new Error('context failed')
|
|
310
|
+
},
|
|
311
|
+
emit: () => {
|
|
312
|
+
throw new Error('sink failed')
|
|
313
|
+
},
|
|
314
|
+
})
|
|
315
|
+
|
|
316
|
+
expect(() => createLogger('resilient-extension').error('Still local')).not.toThrow()
|
|
317
|
+
expect(fake.calls).toEqual([{ level: 'error', args: [{}, 'Still local'] }])
|
|
318
|
+
})
|
|
319
|
+
})
|
|
320
|
+
|
|
244
321
|
describe('isomorphism', () => {
|
|
245
322
|
beforeEach(() => {
|
|
246
323
|
forcePinoTransport()
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import type { LogLevel } from './level'
|
|
2
|
+
|
|
3
|
+
export type LoggerExtensionRecord = {
|
|
4
|
+
level: LogLevel
|
|
5
|
+
namespace: string
|
|
6
|
+
message: string
|
|
7
|
+
fields: Record<string, unknown>
|
|
8
|
+
time: number
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export type LoggerExtension = {
|
|
12
|
+
/**
|
|
13
|
+
* Add process-local context (for example trace/span ids) to both the local
|
|
14
|
+
* line and the optional remote sink.
|
|
15
|
+
*/
|
|
16
|
+
enrich?(): Record<string, unknown> | undefined
|
|
17
|
+
/**
|
|
18
|
+
* Observe a record after the local transport writes it. Implementations must
|
|
19
|
+
* never throw into application code.
|
|
20
|
+
*/
|
|
21
|
+
emit?(record: LoggerExtensionRecord): void
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const GLOBAL_KEY = Symbol.for('@open-mercato/shared.loggerExtension')
|
|
25
|
+
|
|
26
|
+
type LoggerExtensionStore = {
|
|
27
|
+
active?: LoggerExtension
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function store(): LoggerExtensionStore {
|
|
31
|
+
const globalStore = globalThis as unknown as Record<symbol, LoggerExtensionStore | undefined>
|
|
32
|
+
let current = globalStore[GLOBAL_KEY]
|
|
33
|
+
if (!current) {
|
|
34
|
+
current = {}
|
|
35
|
+
globalStore[GLOBAL_KEY] = current
|
|
36
|
+
}
|
|
37
|
+
return current
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Register the single process-wide logger extension. The returned disposer only
|
|
42
|
+
* clears the extension when it is still the active registration.
|
|
43
|
+
*/
|
|
44
|
+
export function registerLoggerExtension(extension: LoggerExtension): () => void {
|
|
45
|
+
store().active = extension
|
|
46
|
+
return () => {
|
|
47
|
+
const current = store()
|
|
48
|
+
if (current.active === extension) current.active = undefined
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function getLoggerExtension(): LoggerExtension | undefined {
|
|
53
|
+
return store().active
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Test-only: clear the process-wide extension. */
|
|
57
|
+
export function resetLoggerExtension(): void {
|
|
58
|
+
store().active = undefined
|
|
59
|
+
}
|
package/src/lib/logger/index.ts
CHANGED
|
@@ -1,9 +1,17 @@
|
|
|
1
1
|
import { selectTransport } from './transport'
|
|
2
|
+
import { getLoggerExtension } from './extension'
|
|
3
|
+
import { isLevelEnabled, type LogLevel } from './level'
|
|
2
4
|
|
|
3
5
|
export type { LogLevel } from './level'
|
|
4
6
|
export { getLogLevel, isLevelEnabled, resetLogLevelCache, OM_LOG_LEVEL_ENV } from './level'
|
|
5
7
|
export { resetServerLoggerCache, OM_LOG_DESTINATION_ENV } from './transport.server'
|
|
6
8
|
export { resetLogPrettyCache, OM_LOG_PRETTY_ENV } from './transport.pretty'
|
|
9
|
+
export {
|
|
10
|
+
getLoggerExtension,
|
|
11
|
+
registerLoggerExtension,
|
|
12
|
+
resetLoggerExtension,
|
|
13
|
+
} from './extension'
|
|
14
|
+
export type { LoggerExtension, LoggerExtensionRecord } from './extension'
|
|
7
15
|
|
|
8
16
|
export type LogBindings = Record<string, unknown>
|
|
9
17
|
|
|
@@ -18,11 +26,61 @@ export interface Logger {
|
|
|
18
26
|
|
|
19
27
|
const loggerRegistry = new Map<string, Logger>()
|
|
20
28
|
|
|
29
|
+
function createExtendedLogger(
|
|
30
|
+
namespace: string,
|
|
31
|
+
transport: Logger,
|
|
32
|
+
bindings: LogBindings = {},
|
|
33
|
+
): Logger {
|
|
34
|
+
const emit = (
|
|
35
|
+
level: LogLevel,
|
|
36
|
+
msg: string,
|
|
37
|
+
fields?: LogBindings,
|
|
38
|
+
): void => {
|
|
39
|
+
if (!isLevelEnabled(level)) return
|
|
40
|
+
|
|
41
|
+
const extension = getLoggerExtension()
|
|
42
|
+
let context: LogBindings = {}
|
|
43
|
+
try {
|
|
44
|
+
context = extension?.enrich?.() ?? {}
|
|
45
|
+
} catch {
|
|
46
|
+
// Observability must never alter application behavior.
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const mergedFields = { ...fields, ...context }
|
|
50
|
+
transport[level](msg, mergedFields)
|
|
51
|
+
|
|
52
|
+
try {
|
|
53
|
+
extension?.emit?.({
|
|
54
|
+
level,
|
|
55
|
+
namespace,
|
|
56
|
+
message: msg,
|
|
57
|
+
fields: { ...bindings, ...mergedFields },
|
|
58
|
+
time: Date.now(),
|
|
59
|
+
})
|
|
60
|
+
} catch {
|
|
61
|
+
// Remote logging is best-effort; the local line has already been written.
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return {
|
|
66
|
+
debug: (msg, fields) => emit('debug', msg, fields),
|
|
67
|
+
info: (msg, fields) => emit('info', msg, fields),
|
|
68
|
+
warn: (msg, fields) => emit('warn', msg, fields),
|
|
69
|
+
error: (msg, fields) => emit('error', msg, fields),
|
|
70
|
+
child: (childBindings) =>
|
|
71
|
+
createExtendedLogger(
|
|
72
|
+
namespace,
|
|
73
|
+
transport.child(childBindings),
|
|
74
|
+
{ ...bindings, ...childBindings },
|
|
75
|
+
),
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
21
79
|
/** Create (or reuse) a namespaced logger. `namespace` is attached as `name`. */
|
|
22
80
|
export function createLogger(namespace: string): Logger {
|
|
23
81
|
const existing = loggerRegistry.get(namespace)
|
|
24
82
|
if (existing) return existing
|
|
25
|
-
const created = selectTransport(namespace)
|
|
83
|
+
const created = createExtendedLogger(namespace, selectTransport(namespace))
|
|
26
84
|
loggerRegistry.set(namespace, created)
|
|
27
85
|
return created
|
|
28
86
|
}
|