@open-mercato/shared 0.4.2-canary-c02407ff85
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/build.mjs +101 -0
- package/dist/index.js +1 -0
- package/dist/index.js.map +7 -0
- package/dist/lib/api/crud.js +47 -0
- package/dist/lib/api/crud.js.map +7 -0
- package/dist/lib/api/scoped.js +140 -0
- package/dist/lib/api/scoped.js.map +7 -0
- package/dist/lib/auth/jwt.js +34 -0
- package/dist/lib/auth/jwt.js.map +7 -0
- package/dist/lib/auth/server.js +157 -0
- package/dist/lib/auth/server.js.map +7 -0
- package/dist/lib/boolean.js +22 -0
- package/dist/lib/boolean.js.map +7 -0
- package/dist/lib/bootstrap/appResolver.js +43 -0
- package/dist/lib/bootstrap/appResolver.js.map +7 -0
- package/dist/lib/bootstrap/dynamicLoader.js +108 -0
- package/dist/lib/bootstrap/dynamicLoader.js.map +7 -0
- package/dist/lib/bootstrap/factory.js +59 -0
- package/dist/lib/bootstrap/factory.js.map +7 -0
- package/dist/lib/bootstrap/index.js +11 -0
- package/dist/lib/bootstrap/index.js.map +7 -0
- package/dist/lib/bootstrap/types.js +1 -0
- package/dist/lib/bootstrap/types.js.map +7 -0
- package/dist/lib/cache/segments.js +36 -0
- package/dist/lib/cache/segments.js.map +7 -0
- package/dist/lib/cli/progress.js +46 -0
- package/dist/lib/cli/progress.js.map +7 -0
- package/dist/lib/commands/command-bus.js +285 -0
- package/dist/lib/commands/command-bus.js.map +7 -0
- package/dist/lib/commands/customFieldSnapshots.js +66 -0
- package/dist/lib/commands/customFieldSnapshots.js.map +7 -0
- package/dist/lib/commands/helpers.js +98 -0
- package/dist/lib/commands/helpers.js.map +7 -0
- package/dist/lib/commands/index.js +8 -0
- package/dist/lib/commands/index.js.map +7 -0
- package/dist/lib/commands/operationMetadata.js +32 -0
- package/dist/lib/commands/operationMetadata.js.map +7 -0
- package/dist/lib/commands/registry.js +43 -0
- package/dist/lib/commands/registry.js.map +7 -0
- package/dist/lib/commands/scope.js +44 -0
- package/dist/lib/commands/scope.js.map +7 -0
- package/dist/lib/commands/types.js +8 -0
- package/dist/lib/commands/types.js.map +7 -0
- package/dist/lib/crud/cache-stats.js +98 -0
- package/dist/lib/crud/cache-stats.js.map +7 -0
- package/dist/lib/crud/cache.js +175 -0
- package/dist/lib/crud/cache.js.map +7 -0
- package/dist/lib/crud/custom-fields-client.js +52 -0
- package/dist/lib/crud/custom-fields-client.js.map +7 -0
- package/dist/lib/crud/custom-fields.js +467 -0
- package/dist/lib/crud/custom-fields.js.map +7 -0
- package/dist/lib/crud/errors.js +24 -0
- package/dist/lib/crud/errors.js.map +7 -0
- package/dist/lib/crud/exporters.js +154 -0
- package/dist/lib/crud/exporters.js.map +7 -0
- package/dist/lib/crud/factory.js +1311 -0
- package/dist/lib/crud/factory.js.map +7 -0
- package/dist/lib/crud/types.js +1 -0
- package/dist/lib/crud/types.js.map +7 -0
- package/dist/lib/custom-fields/normalize.js +36 -0
- package/dist/lib/custom-fields/normalize.js.map +7 -0
- package/dist/lib/data/engine.js +396 -0
- package/dist/lib/data/engine.js.map +7 -0
- package/dist/lib/db/escapeLikePattern.js +5 -0
- package/dist/lib/db/escapeLikePattern.js.map +7 -0
- package/dist/lib/db/mikro.js +82 -0
- package/dist/lib/db/mikro.js.map +7 -0
- package/dist/lib/di/container.js +94 -0
- package/dist/lib/di/container.js.map +7 -0
- package/dist/lib/email/send.js +12 -0
- package/dist/lib/email/send.js.map +7 -0
- package/dist/lib/encryption/aes.js +58 -0
- package/dist/lib/encryption/aes.js.map +7 -0
- package/dist/lib/encryption/customFieldValues.js +49 -0
- package/dist/lib/encryption/customFieldValues.js.map +7 -0
- package/dist/lib/encryption/entityFields.js +26 -0
- package/dist/lib/encryption/entityFields.js.map +7 -0
- package/dist/lib/encryption/entityIds.js +80 -0
- package/dist/lib/encryption/entityIds.js.map +7 -0
- package/dist/lib/encryption/find.js +45 -0
- package/dist/lib/encryption/find.js.map +7 -0
- package/dist/lib/encryption/indexDoc.js +69 -0
- package/dist/lib/encryption/indexDoc.js.map +7 -0
- package/dist/lib/encryption/kms.js +282 -0
- package/dist/lib/encryption/kms.js.map +7 -0
- package/dist/lib/encryption/subscriber.js +330 -0
- package/dist/lib/encryption/subscriber.js.map +7 -0
- package/dist/lib/encryption/tenantDataEncryptionService.js +252 -0
- package/dist/lib/encryption/tenantDataEncryptionService.js.map +7 -0
- package/dist/lib/encryption/toggles.js +18 -0
- package/dist/lib/encryption/toggles.js.map +7 -0
- package/dist/lib/entities/naming.js +9 -0
- package/dist/lib/entities/naming.js.map +7 -0
- package/dist/lib/entities/system-entities.js +43 -0
- package/dist/lib/entities/system-entities.js.map +7 -0
- package/dist/lib/frontend/organizationEvents.js +41 -0
- package/dist/lib/frontend/organizationEvents.js.map +7 -0
- package/dist/lib/frontend/useOrganizationScope.js +32 -0
- package/dist/lib/frontend/useOrganizationScope.js.map +7 -0
- package/dist/lib/hotkeys/index.js +128 -0
- package/dist/lib/hotkeys/index.js.map +7 -0
- package/dist/lib/i18n/app-dictionaries.js +17 -0
- package/dist/lib/i18n/app-dictionaries.js.map +7 -0
- package/dist/lib/i18n/config.js +7 -0
- package/dist/lib/i18n/config.js.map +7 -0
- package/dist/lib/i18n/context.js +50 -0
- package/dist/lib/i18n/context.js.map +7 -0
- package/dist/lib/i18n/server.js +68 -0
- package/dist/lib/i18n/server.js.map +7 -0
- package/dist/lib/i18n/translate.js +45 -0
- package/dist/lib/i18n/translate.js.map +7 -0
- package/dist/lib/indexers/error-log.js +82 -0
- package/dist/lib/indexers/error-log.js.map +7 -0
- package/dist/lib/indexers/status-log.js +80 -0
- package/dist/lib/indexers/status-log.js.map +7 -0
- package/dist/lib/lib/auth/jwt.js +34 -0
- package/dist/lib/lib/auth/jwt.js.map +7 -0
- package/dist/lib/lib/auth/server.js +77 -0
- package/dist/lib/lib/auth/server.js.map +7 -0
- package/dist/lib/lib/email/send.js +12 -0
- package/dist/lib/lib/email/send.js.map +7 -0
- package/dist/lib/lib/i18n/config.js +7 -0
- package/dist/lib/lib/i18n/config.js.map +7 -0
- package/dist/lib/lib/i18n/context.js +31 -0
- package/dist/lib/lib/i18n/context.js.map +7 -0
- package/dist/lib/lib/utils.js +9 -0
- package/dist/lib/lib/utils.js.map +7 -0
- package/dist/lib/location/countries.js +68 -0
- package/dist/lib/location/countries.js.map +7 -0
- package/dist/lib/modules/index.js +6 -0
- package/dist/lib/modules/index.js.map +7 -0
- package/dist/lib/modules/registry.js +18 -0
- package/dist/lib/modules/registry.js.map +7 -0
- package/dist/lib/openapi/crud.js +137 -0
- package/dist/lib/openapi/crud.js.map +7 -0
- package/dist/lib/openapi/generator.js +1131 -0
- package/dist/lib/openapi/generator.js.map +7 -0
- package/dist/lib/openapi/index.js +10 -0
- package/dist/lib/openapi/index.js.map +7 -0
- package/dist/lib/openapi/sanitize.js +110 -0
- package/dist/lib/openapi/sanitize.js.map +7 -0
- package/dist/lib/openapi/types.js +1 -0
- package/dist/lib/openapi/types.js.map +7 -0
- package/dist/lib/profiler/index.js +258 -0
- package/dist/lib/profiler/index.js.map +7 -0
- package/dist/lib/query/engine.js +729 -0
- package/dist/lib/query/engine.js.map +7 -0
- package/dist/lib/query/join-utils.js +195 -0
- package/dist/lib/query/join-utils.js.map +7 -0
- package/dist/lib/query/types.js +9 -0
- package/dist/lib/query/types.js.map +7 -0
- package/dist/lib/search/config.js +32 -0
- package/dist/lib/search/config.js.map +7 -0
- package/dist/lib/search/tokenize.js +34 -0
- package/dist/lib/search/tokenize.js.map +7 -0
- package/dist/lib/slugify.js +24 -0
- package/dist/lib/slugify.js.map +7 -0
- package/dist/lib/testing/bootstrap.js +51 -0
- package/dist/lib/testing/bootstrap.js.map +7 -0
- package/dist/lib/testing/index.js +17 -0
- package/dist/lib/testing/index.js.map +7 -0
- package/dist/lib/testing/renderWithProviders.js +15 -0
- package/dist/lib/testing/renderWithProviders.js.map +7 -0
- package/dist/lib/url.js +12 -0
- package/dist/lib/url.js.map +7 -0
- package/dist/lib/utils.js +13 -0
- package/dist/lib/utils.js.map +7 -0
- package/dist/lib/version.js +7 -0
- package/dist/lib/version.js.map +7 -0
- package/dist/modules/dashboard/widgets.js +1 -0
- package/dist/modules/dashboard/widgets.js.map +7 -0
- package/dist/modules/dsl.js +30 -0
- package/dist/modules/dsl.js.map +7 -0
- package/dist/modules/entities/kinds.js +22 -0
- package/dist/modules/entities/kinds.js.map +7 -0
- package/dist/modules/entities/options.js +26 -0
- package/dist/modules/entities/options.js.map +7 -0
- package/dist/modules/entities/validation.js +102 -0
- package/dist/modules/entities/validation.js.map +7 -0
- package/dist/modules/entities/validators.js +88 -0
- package/dist/modules/entities/validators.js.map +7 -0
- package/dist/modules/entities.js +1 -0
- package/dist/modules/entities.js.map +7 -0
- package/dist/modules/navigation/sidebarPreferences.js +50 -0
- package/dist/modules/navigation/sidebarPreferences.js.map +7 -0
- package/dist/modules/perspectives/types.js +1 -0
- package/dist/modules/perspectives/types.js.map +7 -0
- package/dist/modules/registry.js +96 -0
- package/dist/modules/registry.js.map +7 -0
- package/dist/modules/search.js +15 -0
- package/dist/modules/search.js.map +7 -0
- package/dist/modules/vector.js +1 -0
- package/dist/modules/vector.js.map +7 -0
- package/dist/modules/widgets/injection-loader.js +180 -0
- package/dist/modules/widgets/injection-loader.js.map +7 -0
- package/dist/modules/widgets/injection.js +1 -0
- package/dist/modules/widgets/injection.js.map +7 -0
- package/dist/security/features.js +23 -0
- package/dist/security/features.js.map +7 -0
- package/dist/types/pg.d.js +1 -0
- package/dist/types/pg.d.js.map +7 -0
- package/dist/types/react-email.d.js +1 -0
- package/dist/types/react-email.d.js.map +7 -0
- package/dist/types/resend.d.js +1 -0
- package/dist/types/resend.d.js.map +7 -0
- package/jest.config.cjs +22 -0
- package/package.json +88 -0
- package/src/index.ts +0 -0
- package/src/lib/api/__tests__/scoped.test.ts +38 -0
- package/src/lib/api/crud.ts +59 -0
- package/src/lib/api/scoped.ts +239 -0
- package/src/lib/auth/jwt.ts +39 -0
- package/src/lib/auth/server.ts +199 -0
- package/src/lib/boolean.ts +17 -0
- package/src/lib/bootstrap/appResolver.ts +85 -0
- package/src/lib/bootstrap/dynamicLoader.ts +177 -0
- package/src/lib/bootstrap/factory.ts +108 -0
- package/src/lib/bootstrap/index.ts +23 -0
- package/src/lib/bootstrap/types.ts +31 -0
- package/src/lib/cache/segments.ts +56 -0
- package/src/lib/cli/progress.ts +55 -0
- package/src/lib/commands/__tests__/command-bus.test.ts +84 -0
- package/src/lib/commands/__tests__/helpers.test.ts +42 -0
- package/src/lib/commands/command-bus.ts +349 -0
- package/src/lib/commands/customFieldSnapshots.ts +86 -0
- package/src/lib/commands/helpers.ts +143 -0
- package/src/lib/commands/index.ts +4 -0
- package/src/lib/commands/operationMetadata.ts +40 -0
- package/src/lib/commands/registry.ts +46 -0
- package/src/lib/commands/scope.ts +59 -0
- package/src/lib/commands/types.ts +63 -0
- package/src/lib/crud/__tests__/crud-factory.test.ts +333 -0
- package/src/lib/crud/__tests__/custom-fields.test.ts +150 -0
- package/src/lib/crud/cache-stats.ts +127 -0
- package/src/lib/crud/cache.ts +205 -0
- package/src/lib/crud/custom-fields-client.ts +54 -0
- package/src/lib/crud/custom-fields.ts +607 -0
- package/src/lib/crud/errors.ts +23 -0
- package/src/lib/crud/exporters.ts +188 -0
- package/src/lib/crud/factory.ts +1622 -0
- package/src/lib/crud/types.ts +29 -0
- package/src/lib/custom-fields/normalize.ts +45 -0
- package/src/lib/data/engine.ts +562 -0
- package/src/lib/db/escapeLikePattern.ts +2 -0
- package/src/lib/db/mikro.ts +100 -0
- package/src/lib/di/container.ts +105 -0
- package/src/lib/email/send.ts +18 -0
- package/src/lib/encryption/__tests__/customFieldValues.test.ts +63 -0
- package/src/lib/encryption/__tests__/indexDoc.test.ts +115 -0
- package/src/lib/encryption/aes.ts +64 -0
- package/src/lib/encryption/customFieldValues.ts +67 -0
- package/src/lib/encryption/entityFields.ts +39 -0
- package/src/lib/encryption/entityIds.ts +107 -0
- package/src/lib/encryption/find.ts +81 -0
- package/src/lib/encryption/indexDoc.ts +104 -0
- package/src/lib/encryption/kms.ts +337 -0
- package/src/lib/encryption/subscriber.ts +416 -0
- package/src/lib/encryption/tenantDataEncryptionService.ts +313 -0
- package/src/lib/encryption/toggles.ts +15 -0
- package/src/lib/entities/naming.ts +6 -0
- package/src/lib/entities/system-entities.ts +43 -0
- package/src/lib/frontend/organizationEvents.ts +55 -0
- package/src/lib/frontend/useOrganizationScope.ts +30 -0
- package/src/lib/hotkeys/index.ts +168 -0
- package/src/lib/i18n/app-dictionaries.ts +18 -0
- package/src/lib/i18n/config.ts +4 -0
- package/src/lib/i18n/context.tsx +66 -0
- package/src/lib/i18n/server.ts +74 -0
- package/src/lib/i18n/translate.ts +54 -0
- package/src/lib/indexers/error-log.ts +106 -0
- package/src/lib/indexers/status-log.ts +119 -0
- package/src/lib/lib/auth/jwt.ts +39 -0
- package/src/lib/lib/auth/server.ts +94 -0
- package/src/lib/lib/email/send.ts +18 -0
- package/src/lib/lib/i18n/config.ts +4 -0
- package/src/lib/lib/i18n/context.tsx +38 -0
- package/src/lib/lib/utils.ts +6 -0
- package/src/lib/location/countries.ts +97 -0
- package/src/lib/modules/index.ts +1 -0
- package/src/lib/modules/registry.ts +18 -0
- package/src/lib/openapi/crud.ts +218 -0
- package/src/lib/openapi/generator.ts +1311 -0
- package/src/lib/openapi/index.ts +4 -0
- package/src/lib/openapi/sanitize.ts +137 -0
- package/src/lib/openapi/types.ts +79 -0
- package/src/lib/profiler/index.ts +371 -0
- package/src/lib/query/__tests__/engine.test.ts +274 -0
- package/src/lib/query/engine.ts +837 -0
- package/src/lib/query/join-utils.ts +238 -0
- package/src/lib/query/types.ts +121 -0
- package/src/lib/search/config.ts +49 -0
- package/src/lib/search/tokenize.ts +45 -0
- package/src/lib/slugify.ts +28 -0
- package/src/lib/testing/bootstrap.ts +124 -0
- package/src/lib/testing/index.ts +15 -0
- package/src/lib/testing/renderWithProviders.tsx +31 -0
- package/src/lib/url.ts +12 -0
- package/src/lib/utils.ts +17 -0
- package/src/lib/version.ts +5 -0
- package/src/modules/__tests__/dsl.test.ts +35 -0
- package/src/modules/__tests__/registry.test.ts +300 -0
- package/src/modules/dashboard/widgets.ts +57 -0
- package/src/modules/dsl.ts +32 -0
- package/src/modules/entities/__tests__/validation.test.ts +52 -0
- package/src/modules/entities/kinds.ts +20 -0
- package/src/modules/entities/options.ts +36 -0
- package/src/modules/entities/validation.ts +118 -0
- package/src/modules/entities/validators.ts +93 -0
- package/src/modules/entities.ts +102 -0
- package/src/modules/navigation/sidebarPreferences.ts +62 -0
- package/src/modules/perspectives/types.ts +40 -0
- package/src/modules/registry.ts +249 -0
- package/src/modules/search.ts +325 -0
- package/src/modules/vector.ts +122 -0
- package/src/modules/widgets/__tests__/injection.test.ts +48 -0
- package/src/modules/widgets/injection-loader.ts +235 -0
- package/src/modules/widgets/injection.ts +120 -0
- package/src/security/features.ts +22 -0
- package/src/types/pg.d.ts +2 -0
- package/src/types/react-email.d.ts +2 -0
- package/src/types/resend.d.ts +2 -0
- package/tsconfig.build.json +11 -0
- package/tsconfig.json +9 -0
- package/watch.mjs +6 -0
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
import type { EntityManager } from '@mikro-orm/postgresql'
|
|
2
|
+
import type { CacheStrategy } from '@open-mercato/cache'
|
|
3
|
+
import { decryptWithAesGcm, encryptWithAesGcm, hashForLookup } from './aes'
|
|
4
|
+
import { createKmsService, type KmsService, type TenantDek } from './kms'
|
|
5
|
+
import { isTenantDataEncryptionEnabled, isEncryptionDebugEnabled } from './toggles'
|
|
6
|
+
import { EncryptionMap } from '@open-mercato/core/modules/entities/data/entities'
|
|
7
|
+
|
|
8
|
+
export type EncryptedFieldRule = {
|
|
9
|
+
field: string
|
|
10
|
+
hashField?: string | null
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export type EncryptionMapRecord = {
|
|
14
|
+
entityId: string
|
|
15
|
+
fields: EncryptedFieldRule[]
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
type MapCacheKey = {
|
|
19
|
+
entityId: string
|
|
20
|
+
tenantId: string | null
|
|
21
|
+
organizationId: string | null
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const MAP_MISS_TTL_MS = 5 * 60 * 1000
|
|
25
|
+
|
|
26
|
+
function cacheKey(key: MapCacheKey): string {
|
|
27
|
+
return [
|
|
28
|
+
'encmap',
|
|
29
|
+
key.entityId.toLowerCase(),
|
|
30
|
+
key.tenantId ?? 'null',
|
|
31
|
+
key.organizationId ?? 'null',
|
|
32
|
+
].join(':')
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function debug(event: string, payload: Record<string, unknown>) {
|
|
36
|
+
if (!isEncryptionDebugEnabled()) return
|
|
37
|
+
try {
|
|
38
|
+
// eslint-disable-next-line no-console
|
|
39
|
+
console.debug(`${event} [tenant-encryption]`, payload)
|
|
40
|
+
} catch {
|
|
41
|
+
// ignore
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const toSnakeCase = (value: string): string =>
|
|
46
|
+
value.replace(/([A-Z])/g, '_$1').replace(/__/g, '_').toLowerCase()
|
|
47
|
+
|
|
48
|
+
const toCamelCase = (value: string): string =>
|
|
49
|
+
value.replace(/_([a-z])/g, (_, c) => c.toUpperCase())
|
|
50
|
+
|
|
51
|
+
function findKey(obj: Record<string, unknown>, key: string): string | null {
|
|
52
|
+
const candidates = [key, toSnakeCase(key), toCamelCase(key)]
|
|
53
|
+
for (const candidate of candidates) {
|
|
54
|
+
if (Object.prototype.hasOwnProperty.call(obj, candidate)) return candidate
|
|
55
|
+
}
|
|
56
|
+
return null
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function isEncryptedPayload(value: unknown): boolean {
|
|
60
|
+
if (typeof value !== 'string') return false
|
|
61
|
+
const parts = value.split(':')
|
|
62
|
+
return parts.length === 4 && parts[3] === 'v1'
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export class TenantDataEncryptionService {
|
|
66
|
+
private static globalMemoryCache = new Map<string, EncryptionMapRecord>()
|
|
67
|
+
private static globalInflightMaps = new Map<string, Promise<EncryptionMapRecord | null>>()
|
|
68
|
+
private static globalDekCache = new Map<string, TenantDek>()
|
|
69
|
+
private static globalMissCache = new Map<string, number>()
|
|
70
|
+
private readonly kms: KmsService
|
|
71
|
+
private readonly cache?: CacheStrategy
|
|
72
|
+
private readonly memoryCache = TenantDataEncryptionService.globalMemoryCache
|
|
73
|
+
private readonly dekCache = TenantDataEncryptionService.globalDekCache
|
|
74
|
+
private readonly inflightMaps = TenantDataEncryptionService.globalInflightMaps
|
|
75
|
+
private readonly missCache = TenantDataEncryptionService.globalMissCache
|
|
76
|
+
|
|
77
|
+
constructor(
|
|
78
|
+
private em: EntityManager,
|
|
79
|
+
opts?: { cache?: CacheStrategy; kms?: KmsService }
|
|
80
|
+
) {
|
|
81
|
+
this.cache = opts?.cache
|
|
82
|
+
this.kms = opts?.kms ?? createKmsService()
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
isEnabled(): boolean {
|
|
86
|
+
return isTenantDataEncryptionEnabled() && this.kms.isHealthy()
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
async getDek(tenantId: string | null | undefined): Promise<TenantDek | null> {
|
|
90
|
+
if (!tenantId) return null
|
|
91
|
+
const cached = this.dekCache.get(tenantId)
|
|
92
|
+
if (cached) return cached
|
|
93
|
+
const dek = await this.kms.getTenantDek(tenantId)
|
|
94
|
+
if (!dek) {
|
|
95
|
+
debug('🔎 dek.miss', { tenantId })
|
|
96
|
+
} else {
|
|
97
|
+
debug('✅ dek.hit', { tenantId })
|
|
98
|
+
}
|
|
99
|
+
if (dek) this.dekCache.set(tenantId, dek)
|
|
100
|
+
return dek
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
private async resolveDekForEncrypt(tenantId: string | null): Promise<TenantDek | null> {
|
|
104
|
+
const existing = await this.getDek(tenantId)
|
|
105
|
+
if (existing || !tenantId) return existing ?? null
|
|
106
|
+
if (typeof this.kms.createTenantDek !== 'function') return existing ?? null
|
|
107
|
+
const created = await this.kms.createTenantDek(tenantId)
|
|
108
|
+
if (created) this.dekCache.set(tenantId, created)
|
|
109
|
+
return created ?? null
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
async createDek(tenantId: string): Promise<TenantDek | null> {
|
|
113
|
+
const dek = await this.kms.createTenantDek(tenantId)
|
|
114
|
+
if (dek) this.dekCache.set(tenantId, dek)
|
|
115
|
+
return dek
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
private async fetchMap(key: MapCacheKey): Promise<EncryptionMapRecord | null> {
|
|
119
|
+
// Bypass ORM lifecycle hooks to avoid recursive decrypt loops by querying directly.
|
|
120
|
+
const conn: any = (this.em as any)?.getConnection?.()
|
|
121
|
+
if (!conn || typeof conn.execute !== 'function') return null
|
|
122
|
+
const sql = `
|
|
123
|
+
select entity_id, fields_json
|
|
124
|
+
from encryption_maps
|
|
125
|
+
where entity_id = ?
|
|
126
|
+
and tenant_id is not distinct from ?
|
|
127
|
+
and organization_id is not distinct from ?
|
|
128
|
+
and is_active = true
|
|
129
|
+
and deleted_at is null
|
|
130
|
+
limit 1
|
|
131
|
+
`
|
|
132
|
+
const rows = await conn.execute(sql, [key.entityId, key.tenantId ?? null, key.organizationId ?? null])
|
|
133
|
+
const row = Array.isArray(rows) && rows.length ? rows[0] : null
|
|
134
|
+
if (!row) return null
|
|
135
|
+
return {
|
|
136
|
+
entityId: row.entity_id || row.entityId || key.entityId,
|
|
137
|
+
fields: Array.isArray(row.fields_json)
|
|
138
|
+
? (row.fields_json as EncryptedFieldRule[])
|
|
139
|
+
: Array.isArray(row.fieldsJson)
|
|
140
|
+
? (row.fieldsJson as EncryptedFieldRule[])
|
|
141
|
+
: [],
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
private async getMap(key: MapCacheKey): Promise<EncryptionMapRecord | null> {
|
|
146
|
+
const shouldSkipLookup = (tag: string) => {
|
|
147
|
+
const expiresAt = this.missCache.get(tag)
|
|
148
|
+
if (!expiresAt) return false
|
|
149
|
+
if (expiresAt > Date.now()) return true
|
|
150
|
+
this.missCache.delete(tag)
|
|
151
|
+
return false
|
|
152
|
+
}
|
|
153
|
+
const recordMiss = (tag: string) => {
|
|
154
|
+
this.missCache.set(tag, Date.now() + MAP_MISS_TTL_MS)
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const candidates: MapCacheKey[] = [
|
|
158
|
+
key,
|
|
159
|
+
{ entityId: key.entityId, tenantId: key.tenantId ?? null, organizationId: null },
|
|
160
|
+
{ entityId: key.entityId, tenantId: null, organizationId: null },
|
|
161
|
+
]
|
|
162
|
+
for (const candidate of candidates) {
|
|
163
|
+
const tag = cacheKey(candidate)
|
|
164
|
+
if (shouldSkipLookup(tag)) continue
|
|
165
|
+
if (this.inflightMaps.has(tag)) {
|
|
166
|
+
const pending = this.inflightMaps.get(tag)!
|
|
167
|
+
const resolved = await pending
|
|
168
|
+
if (resolved) return resolved
|
|
169
|
+
}
|
|
170
|
+
const mem = this.memoryCache.get(tag)
|
|
171
|
+
if (mem) return mem
|
|
172
|
+
if (this.cache && typeof this.cache.get === 'function') {
|
|
173
|
+
const cached = await this.cache.get(tag)
|
|
174
|
+
if (cached) return cached as EncryptionMapRecord
|
|
175
|
+
}
|
|
176
|
+
const pending = this.fetchMap(candidate)
|
|
177
|
+
this.inflightMaps.set(tag, pending)
|
|
178
|
+
const loaded = await pending
|
|
179
|
+
this.inflightMaps.delete(tag)
|
|
180
|
+
if (!loaded) {
|
|
181
|
+
recordMiss(tag)
|
|
182
|
+
debug('🔍 encmap.miss', {
|
|
183
|
+
entityId: candidate.entityId,
|
|
184
|
+
tenantId: candidate.tenantId,
|
|
185
|
+
organizationId: candidate.organizationId,
|
|
186
|
+
})
|
|
187
|
+
continue
|
|
188
|
+
}
|
|
189
|
+
this.missCache.delete(tag)
|
|
190
|
+
this.memoryCache.set(tag, loaded)
|
|
191
|
+
if (this.cache && typeof this.cache.set === 'function') {
|
|
192
|
+
await this.cache.set(tag, loaded, { ttl: 300 })
|
|
193
|
+
}
|
|
194
|
+
return loaded
|
|
195
|
+
}
|
|
196
|
+
return null
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
async invalidateMap(entityId: string, tenantId: string | null, organizationId: string | null): Promise<void> {
|
|
200
|
+
const tag = cacheKey({ entityId, tenantId, organizationId })
|
|
201
|
+
this.memoryCache.delete(tag)
|
|
202
|
+
this.inflightMaps.delete(tag)
|
|
203
|
+
this.missCache.delete(tag)
|
|
204
|
+
if (this.cache && typeof (this.cache as any).delete === 'function') {
|
|
205
|
+
await (this.cache as any).delete(tag)
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
private encryptFields(
|
|
210
|
+
obj: Record<string, unknown>,
|
|
211
|
+
fields: EncryptedFieldRule[],
|
|
212
|
+
dek: TenantDek
|
|
213
|
+
): Record<string, unknown> {
|
|
214
|
+
const clone: Record<string, unknown> = { ...obj }
|
|
215
|
+
for (const rule of fields) {
|
|
216
|
+
const key = findKey(clone, rule.field)
|
|
217
|
+
if (!key) continue
|
|
218
|
+
const value = clone[key]
|
|
219
|
+
if (value === null || value === undefined) continue
|
|
220
|
+
// Avoid double-encrypting already encrypted payloads
|
|
221
|
+
if (isEncryptedPayload(value)) continue
|
|
222
|
+
const serialized = typeof value === 'string' ? value : JSON.stringify(value)
|
|
223
|
+
const payload = encryptWithAesGcm(serialized, dek.key)
|
|
224
|
+
clone[key] = payload.value
|
|
225
|
+
if (rule.hashField) {
|
|
226
|
+
const hashKey = findKey(clone, rule.hashField) ?? rule.hashField
|
|
227
|
+
clone[hashKey] = hashForLookup(serialized)
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
return clone
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
private decryptFields(
|
|
234
|
+
obj: Record<string, unknown>,
|
|
235
|
+
fields: EncryptedFieldRule[],
|
|
236
|
+
dek: TenantDek
|
|
237
|
+
): Record<string, unknown> {
|
|
238
|
+
const clone: Record<string, unknown> = { ...obj }
|
|
239
|
+
const maybeDecrypt = (payload: string): string | null => {
|
|
240
|
+
const first = decryptWithAesGcm(payload, dek.key)
|
|
241
|
+
if (first === null) return null
|
|
242
|
+
// Handle accidental double-encryption: if the first pass still looks like a v1 payload, try once more.
|
|
243
|
+
const parts = first.split(':')
|
|
244
|
+
if (parts.length === 4 && parts[3] === 'v1') {
|
|
245
|
+
const second = decryptWithAesGcm(first, dek.key)
|
|
246
|
+
return second ?? first
|
|
247
|
+
}
|
|
248
|
+
return first
|
|
249
|
+
}
|
|
250
|
+
for (const rule of fields) {
|
|
251
|
+
const key = findKey(clone, rule.field)
|
|
252
|
+
if (!key) continue
|
|
253
|
+
const value = clone[key]
|
|
254
|
+
if (typeof value !== 'string') continue
|
|
255
|
+
const decrypted = maybeDecrypt(value)
|
|
256
|
+
if (decrypted === null) continue
|
|
257
|
+
try {
|
|
258
|
+
clone[key] = JSON.parse(decrypted)
|
|
259
|
+
} catch {
|
|
260
|
+
clone[key] = decrypted
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
return clone
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
async encryptEntityPayload(
|
|
267
|
+
entityId: string,
|
|
268
|
+
payload: Record<string, unknown>,
|
|
269
|
+
tenantId: string | null | undefined,
|
|
270
|
+
organizationId?: string | null
|
|
271
|
+
): Promise<Record<string, unknown>> {
|
|
272
|
+
if (!this.isEnabled()) {
|
|
273
|
+
debug('⚪️ encrypt.skip.disabled', { entityId, tenantId })
|
|
274
|
+
return payload
|
|
275
|
+
}
|
|
276
|
+
const dek = await this.resolveDekForEncrypt(tenantId ?? null)
|
|
277
|
+
if (!dek) {
|
|
278
|
+
debug('⚠️ encrypt.skip.no-dek', { entityId, tenantId })
|
|
279
|
+
return payload
|
|
280
|
+
}
|
|
281
|
+
const map = await this.getMap({ entityId, tenantId: tenantId ?? null, organizationId: organizationId ?? null })
|
|
282
|
+
if (!map || !map.fields?.length) {
|
|
283
|
+
debug('⚪️ encrypt.skip.no-map', { entityId, tenantId })
|
|
284
|
+
return payload
|
|
285
|
+
}
|
|
286
|
+
debug('🔒 encrypt_entity', { entityId, tenantId, organizationId, fields: map.fields.length })
|
|
287
|
+
return this.encryptFields(payload, map.fields, dek)
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
async decryptEntityPayload(
|
|
291
|
+
entityId: string,
|
|
292
|
+
payload: Record<string, unknown>,
|
|
293
|
+
tenantId: string | null | undefined,
|
|
294
|
+
organizationId?: string | null
|
|
295
|
+
): Promise<Record<string, unknown>> {
|
|
296
|
+
if (!isTenantDataEncryptionEnabled()) {
|
|
297
|
+
debug('⚪️ decrypt.skip.disabled', { entityId, tenantId })
|
|
298
|
+
return payload
|
|
299
|
+
}
|
|
300
|
+
const dek = await this.getDek(tenantId ?? null)
|
|
301
|
+
if (!dek) {
|
|
302
|
+
debug('⚠️ decrypt.skip.no-dek', { entityId, tenantId })
|
|
303
|
+
return payload
|
|
304
|
+
}
|
|
305
|
+
const map = await this.getMap({ entityId, tenantId: tenantId ?? null, organizationId: organizationId ?? null })
|
|
306
|
+
if (!map || !map.fields?.length) {
|
|
307
|
+
debug('⚪️ decrypt.skip.no-map', { entityId, tenantId })
|
|
308
|
+
return payload
|
|
309
|
+
}
|
|
310
|
+
debug('🔓 decrypt_entity', { entityId, tenantId, organizationId, fields: map.fields.length })
|
|
311
|
+
return this.decryptFields(payload, map.fields, dek)
|
|
312
|
+
}
|
|
313
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { parseBooleanToken } from '../boolean'
|
|
2
|
+
|
|
3
|
+
export function isTenantDataEncryptionEnabled(): boolean {
|
|
4
|
+
const rawEnv = process.env.TENANT_DATA_ENCRYPTION
|
|
5
|
+
if (rawEnv === undefined) return true
|
|
6
|
+
const trimmed = rawEnv.trim()
|
|
7
|
+
if (!trimmed) return true
|
|
8
|
+
const parsed = parseBooleanToken(trimmed)
|
|
9
|
+
return parsed === null ? true : parsed
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function isEncryptionDebugEnabled(): boolean {
|
|
13
|
+
const parsed = parseBooleanToken(process.env.TENANT_DATA_ENCRYPTION_DEBUG ?? '')
|
|
14
|
+
return parsed === true
|
|
15
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
const RESERVED_SYSTEM_ENTITY_TYPES = new Set<string>([
|
|
2
|
+
'entities:custom_entity',
|
|
3
|
+
'entities:custom_entity_storage',
|
|
4
|
+
'entities:custom_field_def',
|
|
5
|
+
'entities:custom_field_value',
|
|
6
|
+
'query_index:entity_index_row',
|
|
7
|
+
'query_index:entity_index_coverage',
|
|
8
|
+
'query_index:search_token',
|
|
9
|
+
])
|
|
10
|
+
|
|
11
|
+
export function isSystemEntitySelectable(entityId: string): boolean {
|
|
12
|
+
if (!entityId) return false
|
|
13
|
+
return !RESERVED_SYSTEM_ENTITY_TYPES.has(entityId)
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function flattenSystemEntityIds(
|
|
17
|
+
allEntities: Record<string, Record<string, string>>,
|
|
18
|
+
options?: { predicate?: (entityType: string) => boolean },
|
|
19
|
+
): string[] {
|
|
20
|
+
if (!allEntities) return []
|
|
21
|
+
const predicate = options?.predicate || isSystemEntitySelectable
|
|
22
|
+
const seen = new Set<string>()
|
|
23
|
+
for (const bucket of Object.values(allEntities)) {
|
|
24
|
+
for (const id of Object.values(bucket ?? {})) {
|
|
25
|
+
if (typeof id !== 'string' || id.length === 0) continue
|
|
26
|
+
if (!predicate(id)) continue
|
|
27
|
+
seen.add(id)
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
return Array.from(seen).sort()
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function filterSelectableSystemEntityIds(entityIds: Iterable<string>): string[] {
|
|
34
|
+
const selected: string[] = []
|
|
35
|
+
for (const id of entityIds) {
|
|
36
|
+
if (isSystemEntitySelectable(id)) selected.push(id)
|
|
37
|
+
}
|
|
38
|
+
return selected
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function isReservedSystemEntityType(entityId: string): boolean {
|
|
42
|
+
return RESERVED_SYSTEM_ENTITY_TYPES.has(entityId)
|
|
43
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
export const ORGANIZATION_SCOPE_CHANGED_EVENT = 'om:organization-scope-changed'
|
|
2
|
+
|
|
3
|
+
export type OrganizationScopeChangedDetail = {
|
|
4
|
+
organizationId: string | null
|
|
5
|
+
tenantId: string | null
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
// Module-level state to track current scope and version
|
|
9
|
+
let currentScope: OrganizationScopeChangedDetail = {
|
|
10
|
+
organizationId: null,
|
|
11
|
+
tenantId: null
|
|
12
|
+
}
|
|
13
|
+
let currentVersion = 0
|
|
14
|
+
|
|
15
|
+
export function getCurrentOrganizationScope(): OrganizationScopeChangedDetail {
|
|
16
|
+
return { ...currentScope }
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function getCurrentOrganizationScopeVersion(): number {
|
|
20
|
+
return currentVersion
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function emitOrganizationScopeChanged(detail: OrganizationScopeChangedDetail): void {
|
|
24
|
+
if (typeof window === 'undefined' || typeof CustomEvent === 'undefined') return
|
|
25
|
+
|
|
26
|
+
// Detect actual changes
|
|
27
|
+
const hasChanged =
|
|
28
|
+
currentScope.organizationId !== detail.organizationId ||
|
|
29
|
+
currentScope.tenantId !== detail.tenantId
|
|
30
|
+
|
|
31
|
+
// Update module-level state
|
|
32
|
+
currentScope = { ...detail }
|
|
33
|
+
|
|
34
|
+
// Increment version only if actual change detected
|
|
35
|
+
if (hasChanged) {
|
|
36
|
+
currentVersion++
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Emit event
|
|
40
|
+
window.dispatchEvent(new CustomEvent<OrganizationScopeChangedDetail>(ORGANIZATION_SCOPE_CHANGED_EVENT, { detail }))
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function subscribeOrganizationScopeChanged(
|
|
44
|
+
handler: (detail: OrganizationScopeChangedDetail) => void
|
|
45
|
+
): () => void {
|
|
46
|
+
if (typeof window === 'undefined') return () => {}
|
|
47
|
+
const listener = (event: Event) => {
|
|
48
|
+
const detail = (event as CustomEvent<OrganizationScopeChangedDetail>).detail ?? { organizationId: null, tenantId: null }
|
|
49
|
+
handler(detail)
|
|
50
|
+
}
|
|
51
|
+
window.addEventListener(ORGANIZATION_SCOPE_CHANGED_EVENT, listener as EventListener)
|
|
52
|
+
return () => {
|
|
53
|
+
window.removeEventListener(ORGANIZATION_SCOPE_CHANGED_EVENT, listener as EventListener)
|
|
54
|
+
}
|
|
55
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
import * as React from 'react'
|
|
3
|
+
import {
|
|
4
|
+
subscribeOrganizationScopeChanged,
|
|
5
|
+
getCurrentOrganizationScope,
|
|
6
|
+
getCurrentOrganizationScopeVersion,
|
|
7
|
+
type OrganizationScopeChangedDetail
|
|
8
|
+
} from './organizationEvents'
|
|
9
|
+
|
|
10
|
+
export function useOrganizationScopeVersion(): number {
|
|
11
|
+
const [version, setVersion] = React.useState(getCurrentOrganizationScopeVersion)
|
|
12
|
+
React.useEffect(() => {
|
|
13
|
+
return subscribeOrganizationScopeChanged(() => {
|
|
14
|
+
setVersion(getCurrentOrganizationScopeVersion())
|
|
15
|
+
})
|
|
16
|
+
}, [])
|
|
17
|
+
return version
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function useOrganizationScopeDetail(): OrganizationScopeChangedDetail {
|
|
21
|
+
const [detail, setDetail] = React.useState<OrganizationScopeChangedDetail>(
|
|
22
|
+
getCurrentOrganizationScope
|
|
23
|
+
)
|
|
24
|
+
React.useEffect(() => {
|
|
25
|
+
return subscribeOrganizationScopeChanged((next) => {
|
|
26
|
+
setDetail(next)
|
|
27
|
+
})
|
|
28
|
+
}, [])
|
|
29
|
+
return detail
|
|
30
|
+
}
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
type HotkeyRegistration = {
|
|
4
|
+
combos: Set<string>
|
|
5
|
+
callback: (event: KeyboardEvent) => void
|
|
6
|
+
debounce: number
|
|
7
|
+
lastTriggered: number
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const scopes = new Map<string, Set<HotkeyRegistration>>()
|
|
11
|
+
|
|
12
|
+
const MODIFIER_SYNONYMS: Record<string, string> = {
|
|
13
|
+
cmd: 'meta',
|
|
14
|
+
command: 'meta',
|
|
15
|
+
option: 'alt',
|
|
16
|
+
opt: 'alt',
|
|
17
|
+
control: 'ctrl',
|
|
18
|
+
ctrl: 'ctrl',
|
|
19
|
+
shift: 'shift',
|
|
20
|
+
meta: 'meta',
|
|
21
|
+
alt: 'alt',
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
let listenersAttached = false
|
|
25
|
+
|
|
26
|
+
const THIRTY_FPS_INTERVAL = 1000 / 30
|
|
27
|
+
|
|
28
|
+
function normalizeToken(token: string): string {
|
|
29
|
+
const trimmed = token.trim().toLowerCase()
|
|
30
|
+
if (!trimmed) return ''
|
|
31
|
+
if (trimmed in MODIFIER_SYNONYMS) return MODIFIER_SYNONYMS[trimmed]
|
|
32
|
+
if (trimmed === 'return') return 'enter'
|
|
33
|
+
if (trimmed === 'space') return ' '
|
|
34
|
+
return trimmed
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function serializeCombination(tokens: string[]): string {
|
|
38
|
+
const filtered = tokens.filter(Boolean)
|
|
39
|
+
filtered.sort()
|
|
40
|
+
return filtered.join('+')
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function parseHotkeys(hotkeys: string): Set<string> {
|
|
44
|
+
return new Set(
|
|
45
|
+
hotkeys
|
|
46
|
+
.split(/\s+/)
|
|
47
|
+
.map((combo) =>
|
|
48
|
+
serializeCombination(
|
|
49
|
+
combo
|
|
50
|
+
.split('+')
|
|
51
|
+
.map(normalizeToken)
|
|
52
|
+
.filter(Boolean),
|
|
53
|
+
),
|
|
54
|
+
)
|
|
55
|
+
.filter(Boolean),
|
|
56
|
+
)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function createRegistration(
|
|
60
|
+
hotkeys: string,
|
|
61
|
+
callback: (event: KeyboardEvent) => void,
|
|
62
|
+
debounce: number,
|
|
63
|
+
): HotkeyRegistration {
|
|
64
|
+
const combos = parseHotkeys(hotkeys)
|
|
65
|
+
return {
|
|
66
|
+
combos,
|
|
67
|
+
callback,
|
|
68
|
+
debounce: Math.max(debounce, THIRTY_FPS_INTERVAL),
|
|
69
|
+
lastTriggered: 0,
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function activeCombination(event: KeyboardEvent): string | null {
|
|
74
|
+
const keys: string[] = []
|
|
75
|
+
if (event.metaKey) keys.push('meta')
|
|
76
|
+
if (event.ctrlKey) keys.push('ctrl')
|
|
77
|
+
if (event.altKey) keys.push('alt')
|
|
78
|
+
if (event.shiftKey) keys.push('shift')
|
|
79
|
+
|
|
80
|
+
const key = normalizeToken(event.key)
|
|
81
|
+
if (key && !(key in MODIFIER_SYNONYMS)) {
|
|
82
|
+
keys.push(key.length === 1 ? key : normalizeToken(key))
|
|
83
|
+
} else if (keys.length === 0) {
|
|
84
|
+
// Ignore pure modifier presses
|
|
85
|
+
return null
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return serializeCombination(keys)
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function handleKeydown(event: KeyboardEvent) {
|
|
92
|
+
if (event.defaultPrevented) return
|
|
93
|
+
const combo = activeCombination(event)
|
|
94
|
+
if (!combo) return
|
|
95
|
+
|
|
96
|
+
const now = Date.now()
|
|
97
|
+
scopes.forEach((registrations) => {
|
|
98
|
+
registrations.forEach((registration) => {
|
|
99
|
+
if (!registration.combos.has(combo)) return
|
|
100
|
+
if (event.repeat && now - registration.lastTriggered < registration.debounce) return
|
|
101
|
+
if (now - registration.lastTriggered < registration.debounce) return
|
|
102
|
+
registration.lastTriggered = now
|
|
103
|
+
registration.callback(event)
|
|
104
|
+
})
|
|
105
|
+
})
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function handleKeyup() {
|
|
109
|
+
// No-op placeholder for future extensibility (mirrors API pattern from article reference)
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function ensureListeners() {
|
|
113
|
+
if (listenersAttached) return
|
|
114
|
+
if (typeof document === 'undefined') return
|
|
115
|
+
document.addEventListener('keydown', handleKeydown)
|
|
116
|
+
document.addEventListener('keyup', handleKeyup)
|
|
117
|
+
listenersAttached = true
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function detachListenersIfIdle() {
|
|
121
|
+
if (!listenersAttached) return
|
|
122
|
+
if (scopes.size > 0) return
|
|
123
|
+
if (typeof document === 'undefined') return
|
|
124
|
+
document.removeEventListener('keydown', handleKeydown)
|
|
125
|
+
document.removeEventListener('keyup', handleKeyup)
|
|
126
|
+
listenersAttached = false
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export function registerHotkey(
|
|
130
|
+
hotkeys: string,
|
|
131
|
+
scopeName: string,
|
|
132
|
+
callback: (event: KeyboardEvent) => void,
|
|
133
|
+
debounceTimeInMilliseconds = 150,
|
|
134
|
+
) {
|
|
135
|
+
if (typeof document === 'undefined') {
|
|
136
|
+
return {
|
|
137
|
+
bind() {},
|
|
138
|
+
unbind() {},
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const registration = createRegistration(hotkeys, callback, debounceTimeInMilliseconds)
|
|
143
|
+
let scope = scopes.get(scopeName)
|
|
144
|
+
|
|
145
|
+
const bind = () => {
|
|
146
|
+
if (!scope) {
|
|
147
|
+
scope = new Set()
|
|
148
|
+
scopes.set(scopeName, scope)
|
|
149
|
+
}
|
|
150
|
+
scope.add(registration)
|
|
151
|
+
ensureListeners()
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const unbind = () => {
|
|
155
|
+
const existingScope = scopes.get(scopeName)
|
|
156
|
+
if (existingScope) {
|
|
157
|
+
existingScope.delete(registration)
|
|
158
|
+
if (existingScope.size === 0) {
|
|
159
|
+
scopes.delete(scopeName)
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
detachListenersIfIdle()
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
bind()
|
|
166
|
+
|
|
167
|
+
return { bind, unbind }
|
|
168
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { Locale } from './config'
|
|
2
|
+
|
|
3
|
+
type DictionaryLoader = (locale: Locale) => Promise<Record<string, unknown>>
|
|
4
|
+
|
|
5
|
+
let _appDictionaryLoader: DictionaryLoader | null = null
|
|
6
|
+
|
|
7
|
+
export function registerAppDictionaryLoader(loader: DictionaryLoader): void {
|
|
8
|
+
_appDictionaryLoader = loader
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export async function loadAppDictionary(locale: Locale): Promise<Record<string, unknown>> {
|
|
12
|
+
if (!_appDictionaryLoader) return {}
|
|
13
|
+
try {
|
|
14
|
+
return await _appDictionaryLoader(locale)
|
|
15
|
+
} catch {
|
|
16
|
+
return {}
|
|
17
|
+
}
|
|
18
|
+
}
|