@open-mercato/shared 0.5.1-develop.3043.1a796c3920 → 0.6.0
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/dist/lib/crud/custom-fields.js +12 -0
- package/dist/lib/crud/custom-fields.js.map +2 -2
- package/dist/lib/crud/factory.js +4 -6
- package/dist/lib/crud/factory.js.map +2 -2
- package/dist/lib/encryption/tenantDataEncryptionService.js +1 -1
- package/dist/lib/encryption/tenantDataEncryptionService.js.map +2 -2
- package/dist/lib/version.js +1 -1
- package/dist/lib/version.js.map +1 -1
- package/package.json +6 -7
- package/src/lib/crud/__tests__/custom-fields.test.ts +57 -0
- package/src/lib/crud/custom-fields.ts +26 -0
- package/src/lib/crud/factory.ts +12 -6
- package/src/lib/encryption/__tests__/tenantDataEncryptionService.test.ts +27 -6
- package/src/lib/encryption/tenantDataEncryptionService.ts +12 -1
|
@@ -211,7 +211,7 @@ class TenantDataEncryptionService {
|
|
|
211
211
|
if (typeof value !== "string") continue;
|
|
212
212
|
const decrypted = maybeDecrypt(value);
|
|
213
213
|
if (decrypted === null) continue;
|
|
214
|
-
clone[key] =
|
|
214
|
+
clone[key] = decrypted;
|
|
215
215
|
}
|
|
216
216
|
return clone;
|
|
217
217
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../src/lib/encryption/tenantDataEncryptionService.ts"],
|
|
4
|
-
"sourcesContent": ["import type { EntityManager } from '@mikro-orm/postgresql'\nimport type { CacheStrategy } from '@open-mercato/cache'\nimport { decryptWithAesGcm, encryptWithAesGcm, hashForLookup } from './aes'\nimport { createKmsService, type KmsService, type TenantDek } from './kms'\nimport { isTenantDataEncryptionEnabled, isEncryptionDebugEnabled } from './toggles'\nimport { EncryptionMap } from '@open-mercato/core/modules/entities/data/entities'\n\nexport type EncryptedFieldRule = {\n field: string\n hashField?: string | null\n}\n\nexport type EncryptionMapRecord = {\n entityId: string\n fields: EncryptedFieldRule[]\n}\n\ntype MapCacheKey = {\n entityId: string\n tenantId: string | null\n organizationId: string | null\n}\n\nconst MAP_MISS_TTL_MS = 5 * 60 * 1000\n\nfunction cacheKey(key: MapCacheKey): string {\n return [\n 'encmap',\n key.entityId.toLowerCase(),\n key.tenantId ?? 'null',\n key.organizationId ?? 'null',\n ].join(':')\n}\n\nfunction debug(event: string, payload: Record<string, unknown>) {\n if (!isEncryptionDebugEnabled()) return\n try {\n // eslint-disable-next-line no-console\n console.debug(`${event} [tenant-encryption]`, payload)\n } catch {\n // ignore\n }\n}\n\nconst toSnakeCase = (value: string): string =>\n value.replace(/([A-Z])/g, '_$1').replace(/__/g, '_').toLowerCase()\n\nconst toCamelCase = (value: string): string =>\n value.replace(/_([a-z])/g, (_, c) => c.toUpperCase())\n\nfunction findKey(obj: Record<string, unknown>, key: string): string | null {\n const candidates = [key, toSnakeCase(key), toCamelCase(key)]\n for (const candidate of candidates) {\n if (Object.prototype.hasOwnProperty.call(obj, candidate)) return candidate\n }\n return null\n}\n\n/**\n * Decode a decrypted entity-field payload back into its original value.\n *\n * The encrypt path stores raw strings unwrapped and JSON-stringifies non-string\n * values. Blindly running `JSON.parse` on every decrypted value would coerce\n * text columns whose contents happen to be valid JSON primitives \u2014 e.g. the\n * string `\"123\"` \u2014 back into numbers/booleans, which then breaks string-typed\n * consumers (see issue #1734). Only restructure the value when the decrypted\n * payload is unambiguously a JSON object or array; otherwise return the raw\n * decrypted string. Numeric/boolean entity columns are not in any current\n * encryption map, so this is backward-compatible.\n */\nexport function parseDecryptedFieldValue(decrypted: string): unknown {\n if (decrypted.length === 0) return decrypted\n const first = decrypted[0]\n if (first !== '{' && first !== '[') return decrypted\n try {\n return JSON.parse(decrypted)\n } catch {\n return decrypted\n }\n}\n\nfunction isEncryptedPayload(value: unknown): boolean {\n if (typeof value !== 'string') return false\n const parts = value.split(':')\n return parts.length === 4 && parts[3] === 'v1'\n}\n\nexport class TenantDataEncryptionService {\n private static globalMemoryCache = new Map<string, EncryptionMapRecord>()\n private static globalInflightMaps = new Map<string, Promise<EncryptionMapRecord | null>>()\n private static globalDekCache = new Map<string, TenantDek>()\n private static globalMissCache = new Map<string, number>()\n private readonly kms: KmsService\n private readonly cache?: CacheStrategy\n private readonly memoryCache = TenantDataEncryptionService.globalMemoryCache\n private readonly dekCache = TenantDataEncryptionService.globalDekCache\n private readonly inflightMaps = TenantDataEncryptionService.globalInflightMaps\n private readonly missCache = TenantDataEncryptionService.globalMissCache\n\n constructor(\n private em: EntityManager,\n opts?: { cache?: CacheStrategy; kms?: KmsService }\n ) {\n this.cache = opts?.cache\n this.kms = opts?.kms ?? createKmsService()\n }\n\n isEnabled(): boolean {\n return isTenantDataEncryptionEnabled() && this.kms.isHealthy()\n }\n\n async getDek(tenantId: string | null | undefined): Promise<TenantDek | null> {\n if (!tenantId) return null\n const cached = this.dekCache.get(tenantId)\n if (cached) return cached\n const dek = await this.kms.getTenantDek(tenantId)\n if (!dek) {\n debug('\uD83D\uDD0E dek.miss', { tenantId })\n } else {\n debug('\u2705 dek.hit', { tenantId })\n }\n if (dek) this.dekCache.set(tenantId, dek)\n return dek\n }\n\n private async resolveDekForEncrypt(tenantId: string | null): Promise<TenantDek | null> {\n const existing = await this.getDek(tenantId)\n if (existing || !tenantId) return existing ?? null\n if (typeof this.kms.createTenantDek !== 'function') return existing ?? null\n const created = await this.kms.createTenantDek(tenantId)\n if (created) this.dekCache.set(tenantId, created)\n return created ?? null\n }\n\n async createDek(tenantId: string): Promise<TenantDek | null> {\n const dek = await this.kms.createTenantDek(tenantId)\n if (dek) this.dekCache.set(tenantId, dek)\n return dek\n }\n\n private async fetchMap(key: MapCacheKey): Promise<EncryptionMapRecord | null> {\n // Bypass ORM lifecycle hooks to avoid recursive decrypt loops by querying directly.\n const conn: any = (this.em as any)?.getConnection?.()\n if (!conn || typeof conn.execute !== 'function') return null\n const sql = `\n select entity_id, fields_json\n from encryption_maps\n where entity_id = ?\n and tenant_id is not distinct from ?\n and organization_id is not distinct from ?\n and is_active = true\n and deleted_at is null\n limit 1\n `\n const rows = await conn.execute(sql, [key.entityId, key.tenantId ?? null, key.organizationId ?? null])\n const row = Array.isArray(rows) && rows.length ? rows[0] : null\n if (!row) return null\n return {\n entityId: row.entity_id || row.entityId || key.entityId,\n fields: Array.isArray(row.fields_json)\n ? (row.fields_json as EncryptedFieldRule[])\n : Array.isArray(row.fieldsJson)\n ? (row.fieldsJson as EncryptedFieldRule[])\n : [],\n }\n }\n\n private async getMap(key: MapCacheKey): Promise<EncryptionMapRecord | null> {\n const shouldSkipLookup = (tag: string) => {\n const expiresAt = this.missCache.get(tag)\n if (!expiresAt) return false\n if (expiresAt > Date.now()) return true\n this.missCache.delete(tag)\n return false\n }\n const recordMiss = (tag: string) => {\n this.missCache.set(tag, Date.now() + MAP_MISS_TTL_MS)\n }\n\n const candidates: MapCacheKey[] = [\n key,\n { entityId: key.entityId, tenantId: key.tenantId ?? null, organizationId: null },\n { entityId: key.entityId, tenantId: null, organizationId: null },\n ]\n for (const candidate of candidates) {\n const tag = cacheKey(candidate)\n if (shouldSkipLookup(tag)) continue\n if (this.inflightMaps.has(tag)) {\n const pending = this.inflightMaps.get(tag)!\n const resolved = await pending\n if (resolved) return resolved\n }\n const mem = this.memoryCache.get(tag)\n if (mem) return mem\n if (this.cache && typeof this.cache.get === 'function') {\n const cached = await this.cache.get(tag)\n if (cached) return cached as EncryptionMapRecord\n }\n const pending = this.fetchMap(candidate)\n this.inflightMaps.set(tag, pending)\n const loaded = await pending\n this.inflightMaps.delete(tag)\n if (!loaded) {\n recordMiss(tag)\n debug('\uD83D\uDD0D encmap.miss', {\n entityId: candidate.entityId,\n tenantId: candidate.tenantId,\n organizationId: candidate.organizationId,\n })\n continue\n }\n this.missCache.delete(tag)\n this.memoryCache.set(tag, loaded)\n if (this.cache && typeof this.cache.set === 'function') {\n await this.cache.set(tag, loaded, { ttl: 300 })\n }\n return loaded\n }\n return null\n }\n\n async invalidateMap(entityId: string, tenantId: string | null, organizationId: string | null): Promise<void> {\n const tag = cacheKey({ entityId, tenantId, organizationId })\n this.memoryCache.delete(tag)\n this.inflightMaps.delete(tag)\n this.missCache.delete(tag)\n if (this.cache && typeof (this.cache as any).delete === 'function') {\n await (this.cache as any).delete(tag)\n }\n }\n\n private encryptFields(\n obj: Record<string, unknown>,\n fields: EncryptedFieldRule[],\n dek: TenantDek\n ): Record<string, unknown> {\n const clone: Record<string, unknown> = { ...obj }\n for (const rule of fields) {\n const key = findKey(clone, rule.field)\n if (!key) continue\n const value = clone[key]\n if (value === null || value === undefined) continue\n // Avoid double-encrypting already encrypted payloads\n if (isEncryptedPayload(value)) continue\n const serialized = typeof value === 'string' ? value : JSON.stringify(value)\n const payload = encryptWithAesGcm(serialized, dek.key)\n clone[key] = payload.value\n if (rule.hashField) {\n const hashKey = findKey(clone, rule.hashField) ?? rule.hashField\n clone[hashKey] = hashForLookup(serialized)\n }\n }\n return clone\n }\n\n private decryptFields(\n obj: Record<string, unknown>,\n fields: EncryptedFieldRule[],\n dek: TenantDek\n ): Record<string, unknown> {\n const clone: Record<string, unknown> = { ...obj }\n const maybeDecrypt = (payload: string): string | null => {\n const first = decryptWithAesGcm(payload, dek.key)\n if (first === null) return null\n // Handle accidental double-encryption: if the first pass still looks like a v1 payload, try once more.\n const parts = first.split(':')\n if (parts.length === 4 && parts[3] === 'v1') {\n const second = decryptWithAesGcm(first, dek.key)\n return second ?? first\n }\n return first\n }\n for (const rule of fields) {\n const key = findKey(clone, rule.field)\n if (!key) continue\n const value = clone[key]\n if (typeof value !== 'string') continue\n const decrypted = maybeDecrypt(value)\n if (decrypted === null) continue\n clone[key] = parseDecryptedFieldValue(decrypted)\n }\n return clone\n }\n\n async encryptEntityPayload(\n entityId: string,\n payload: Record<string, unknown>,\n tenantId: string | null | undefined,\n organizationId?: string | null\n ): Promise<Record<string, unknown>> {\n if (!this.isEnabled()) {\n debug('\u26AA\uFE0F encrypt.skip.disabled', { entityId, tenantId })\n return payload\n }\n const dek = await this.resolveDekForEncrypt(tenantId ?? null)\n if (!dek) {\n debug('\u26A0\uFE0F encrypt.skip.no-dek', { entityId, tenantId })\n return payload\n }\n const map = await this.getMap({ entityId, tenantId: tenantId ?? null, organizationId: organizationId ?? null })\n if (!map || !map.fields?.length) {\n debug('\u26AA\uFE0F encrypt.skip.no-map', { entityId, tenantId })\n return payload\n }\n debug('\uD83D\uDD12 encrypt_entity', { entityId, tenantId, organizationId, fields: map.fields.length })\n return this.encryptFields(payload, map.fields, dek)\n }\n\n async decryptEntityPayload(\n entityId: string,\n payload: Record<string, unknown>,\n tenantId: string | null | undefined,\n organizationId?: string | null\n ): Promise<Record<string, unknown>> {\n if (!isTenantDataEncryptionEnabled()) {\n debug('\u26AA\uFE0F decrypt.skip.disabled', { entityId, tenantId })\n return payload\n }\n const dek = await this.getDek(tenantId ?? null)\n if (!dek) {\n debug('\u26A0\uFE0F decrypt.skip.no-dek', { entityId, tenantId })\n return payload\n }\n const map = await this.getMap({ entityId, tenantId: tenantId ?? null, organizationId: organizationId ?? null })\n if (!map || !map.fields?.length) {\n debug('\u26AA\uFE0F decrypt.skip.no-map', { entityId, tenantId })\n return payload\n }\n debug('\uD83D\uDD13 decrypt_entity', { entityId, tenantId, organizationId, fields: map.fields.length })\n return this.decryptFields(payload, map.fields, dek)\n }\n}\n"],
|
|
5
|
-
"mappings": "AAEA,SAAS,mBAAmB,mBAAmB,qBAAqB;AACpE,SAAS,wBAAyD;AAClE,SAAS,+BAA+B,gCAAgC;AAmBxE,MAAM,kBAAkB,IAAI,KAAK;AAEjC,SAAS,SAAS,KAA0B;AAC1C,SAAO;AAAA,IACL;AAAA,IACA,IAAI,SAAS,YAAY;AAAA,IACzB,IAAI,YAAY;AAAA,IAChB,IAAI,kBAAkB;AAAA,EACxB,EAAE,KAAK,GAAG;AACZ;AAEA,SAAS,MAAM,OAAe,SAAkC;AAC9D,MAAI,CAAC,yBAAyB,EAAG;AACjC,MAAI;AAEF,YAAQ,MAAM,GAAG,KAAK,wBAAwB,OAAO;AAAA,EACvD,QAAQ;AAAA,EAER;AACF;AAEA,MAAM,cAAc,CAAC,UACnB,MAAM,QAAQ,YAAY,KAAK,EAAE,QAAQ,OAAO,GAAG,EAAE,YAAY;AAEnE,MAAM,cAAc,CAAC,UACnB,MAAM,QAAQ,aAAa,CAAC,GAAG,MAAM,EAAE,YAAY,CAAC;AAEtD,SAAS,QAAQ,KAA8B,KAA4B;AACzE,QAAM,aAAa,CAAC,KAAK,YAAY,GAAG,GAAG,YAAY,GAAG,CAAC;AAC3D,aAAW,aAAa,YAAY;AAClC,QAAI,OAAO,UAAU,eAAe,KAAK,KAAK,SAAS,EAAG,QAAO;AAAA,EACnE;AACA,SAAO;AACT;
|
|
4
|
+
"sourcesContent": ["import type { EntityManager } from '@mikro-orm/postgresql'\nimport type { CacheStrategy } from '@open-mercato/cache'\nimport { decryptWithAesGcm, encryptWithAesGcm, hashForLookup } from './aes'\nimport { createKmsService, type KmsService, type TenantDek } from './kms'\nimport { isTenantDataEncryptionEnabled, isEncryptionDebugEnabled } from './toggles'\nimport { EncryptionMap } from '@open-mercato/core/modules/entities/data/entities'\n\nexport type EncryptedFieldRule = {\n field: string\n hashField?: string | null\n}\n\nexport type EncryptionMapRecord = {\n entityId: string\n fields: EncryptedFieldRule[]\n}\n\ntype MapCacheKey = {\n entityId: string\n tenantId: string | null\n organizationId: string | null\n}\n\nconst MAP_MISS_TTL_MS = 5 * 60 * 1000\n\nfunction cacheKey(key: MapCacheKey): string {\n return [\n 'encmap',\n key.entityId.toLowerCase(),\n key.tenantId ?? 'null',\n key.organizationId ?? 'null',\n ].join(':')\n}\n\nfunction debug(event: string, payload: Record<string, unknown>) {\n if (!isEncryptionDebugEnabled()) return\n try {\n // eslint-disable-next-line no-console\n console.debug(`${event} [tenant-encryption]`, payload)\n } catch {\n // ignore\n }\n}\n\nconst toSnakeCase = (value: string): string =>\n value.replace(/([A-Z])/g, '_$1').replace(/__/g, '_').toLowerCase()\n\nconst toCamelCase = (value: string): string =>\n value.replace(/_([a-z])/g, (_, c) => c.toUpperCase())\n\nfunction findKey(obj: Record<string, unknown>, key: string): string | null {\n const candidates = [key, toSnakeCase(key), toCamelCase(key)]\n for (const candidate of candidates) {\n if (Object.prototype.hasOwnProperty.call(obj, candidate)) return candidate\n }\n return null\n}\n\n/**\n * Decode a decrypted entity-field payload back into its original value.\n *\n * The encrypt path stores raw strings unwrapped and JSON-stringifies non-string\n * values. Blindly running `JSON.parse` on every decrypted value would coerce\n * text columns whose contents happen to be valid JSON primitives \u2014 e.g. the\n * string `\"123\"` \u2014 back into numbers/booleans, which then breaks string-typed\n * consumers (see issue #1734). Only restructure the value when the decrypted\n * payload is unambiguously a JSON object or array; otherwise return the raw\n * decrypted string. Numeric/boolean entity columns are not in any current\n * encryption map, so this is backward-compatible.\n *\n * NOTE (issue #1810 follow-up): `decryptFields` no longer calls this helper for\n * entity-field decryption \u2014 typed string columns whose contents happen to look\n * like JSON (e.g. a display name `{\"a\":1}`) must remain raw strings to avoid\n * downstream React-render crashes. Callers that legitimately need the parse\n * (audit-log jsonb columns, custom-field rotation, encryption CLI) MUST invoke\n * `parseDecryptedFieldValue` themselves on the decrypted payload.\n */\nexport function parseDecryptedFieldValue(decrypted: string): unknown {\n if (decrypted.length === 0) return decrypted\n const first = decrypted[0]\n if (first !== '{' && first !== '[') return decrypted\n try {\n return JSON.parse(decrypted)\n } catch {\n return decrypted\n }\n}\n\nfunction isEncryptedPayload(value: unknown): boolean {\n if (typeof value !== 'string') return false\n const parts = value.split(':')\n return parts.length === 4 && parts[3] === 'v1'\n}\n\nexport class TenantDataEncryptionService {\n private static globalMemoryCache = new Map<string, EncryptionMapRecord>()\n private static globalInflightMaps = new Map<string, Promise<EncryptionMapRecord | null>>()\n private static globalDekCache = new Map<string, TenantDek>()\n private static globalMissCache = new Map<string, number>()\n private readonly kms: KmsService\n private readonly cache?: CacheStrategy\n private readonly memoryCache = TenantDataEncryptionService.globalMemoryCache\n private readonly dekCache = TenantDataEncryptionService.globalDekCache\n private readonly inflightMaps = TenantDataEncryptionService.globalInflightMaps\n private readonly missCache = TenantDataEncryptionService.globalMissCache\n\n constructor(\n private em: EntityManager,\n opts?: { cache?: CacheStrategy; kms?: KmsService }\n ) {\n this.cache = opts?.cache\n this.kms = opts?.kms ?? createKmsService()\n }\n\n isEnabled(): boolean {\n return isTenantDataEncryptionEnabled() && this.kms.isHealthy()\n }\n\n async getDek(tenantId: string | null | undefined): Promise<TenantDek | null> {\n if (!tenantId) return null\n const cached = this.dekCache.get(tenantId)\n if (cached) return cached\n const dek = await this.kms.getTenantDek(tenantId)\n if (!dek) {\n debug('\uD83D\uDD0E dek.miss', { tenantId })\n } else {\n debug('\u2705 dek.hit', { tenantId })\n }\n if (dek) this.dekCache.set(tenantId, dek)\n return dek\n }\n\n private async resolveDekForEncrypt(tenantId: string | null): Promise<TenantDek | null> {\n const existing = await this.getDek(tenantId)\n if (existing || !tenantId) return existing ?? null\n if (typeof this.kms.createTenantDek !== 'function') return existing ?? null\n const created = await this.kms.createTenantDek(tenantId)\n if (created) this.dekCache.set(tenantId, created)\n return created ?? null\n }\n\n async createDek(tenantId: string): Promise<TenantDek | null> {\n const dek = await this.kms.createTenantDek(tenantId)\n if (dek) this.dekCache.set(tenantId, dek)\n return dek\n }\n\n private async fetchMap(key: MapCacheKey): Promise<EncryptionMapRecord | null> {\n // Bypass ORM lifecycle hooks to avoid recursive decrypt loops by querying directly.\n const conn: any = (this.em as any)?.getConnection?.()\n if (!conn || typeof conn.execute !== 'function') return null\n const sql = `\n select entity_id, fields_json\n from encryption_maps\n where entity_id = ?\n and tenant_id is not distinct from ?\n and organization_id is not distinct from ?\n and is_active = true\n and deleted_at is null\n limit 1\n `\n const rows = await conn.execute(sql, [key.entityId, key.tenantId ?? null, key.organizationId ?? null])\n const row = Array.isArray(rows) && rows.length ? rows[0] : null\n if (!row) return null\n return {\n entityId: row.entity_id || row.entityId || key.entityId,\n fields: Array.isArray(row.fields_json)\n ? (row.fields_json as EncryptedFieldRule[])\n : Array.isArray(row.fieldsJson)\n ? (row.fieldsJson as EncryptedFieldRule[])\n : [],\n }\n }\n\n private async getMap(key: MapCacheKey): Promise<EncryptionMapRecord | null> {\n const shouldSkipLookup = (tag: string) => {\n const expiresAt = this.missCache.get(tag)\n if (!expiresAt) return false\n if (expiresAt > Date.now()) return true\n this.missCache.delete(tag)\n return false\n }\n const recordMiss = (tag: string) => {\n this.missCache.set(tag, Date.now() + MAP_MISS_TTL_MS)\n }\n\n const candidates: MapCacheKey[] = [\n key,\n { entityId: key.entityId, tenantId: key.tenantId ?? null, organizationId: null },\n { entityId: key.entityId, tenantId: null, organizationId: null },\n ]\n for (const candidate of candidates) {\n const tag = cacheKey(candidate)\n if (shouldSkipLookup(tag)) continue\n if (this.inflightMaps.has(tag)) {\n const pending = this.inflightMaps.get(tag)!\n const resolved = await pending\n if (resolved) return resolved\n }\n const mem = this.memoryCache.get(tag)\n if (mem) return mem\n if (this.cache && typeof this.cache.get === 'function') {\n const cached = await this.cache.get(tag)\n if (cached) return cached as EncryptionMapRecord\n }\n const pending = this.fetchMap(candidate)\n this.inflightMaps.set(tag, pending)\n const loaded = await pending\n this.inflightMaps.delete(tag)\n if (!loaded) {\n recordMiss(tag)\n debug('\uD83D\uDD0D encmap.miss', {\n entityId: candidate.entityId,\n tenantId: candidate.tenantId,\n organizationId: candidate.organizationId,\n })\n continue\n }\n this.missCache.delete(tag)\n this.memoryCache.set(tag, loaded)\n if (this.cache && typeof this.cache.set === 'function') {\n await this.cache.set(tag, loaded, { ttl: 300 })\n }\n return loaded\n }\n return null\n }\n\n async invalidateMap(entityId: string, tenantId: string | null, organizationId: string | null): Promise<void> {\n const tag = cacheKey({ entityId, tenantId, organizationId })\n this.memoryCache.delete(tag)\n this.inflightMaps.delete(tag)\n this.missCache.delete(tag)\n if (this.cache && typeof (this.cache as any).delete === 'function') {\n await (this.cache as any).delete(tag)\n }\n }\n\n private encryptFields(\n obj: Record<string, unknown>,\n fields: EncryptedFieldRule[],\n dek: TenantDek\n ): Record<string, unknown> {\n const clone: Record<string, unknown> = { ...obj }\n for (const rule of fields) {\n const key = findKey(clone, rule.field)\n if (!key) continue\n const value = clone[key]\n if (value === null || value === undefined) continue\n // Avoid double-encrypting already encrypted payloads\n if (isEncryptedPayload(value)) continue\n const serialized = typeof value === 'string' ? value : JSON.stringify(value)\n const payload = encryptWithAesGcm(serialized, dek.key)\n clone[key] = payload.value\n if (rule.hashField) {\n const hashKey = findKey(clone, rule.hashField) ?? rule.hashField\n clone[hashKey] = hashForLookup(serialized)\n }\n }\n return clone\n }\n\n private decryptFields(\n obj: Record<string, unknown>,\n fields: EncryptedFieldRule[],\n dek: TenantDek\n ): Record<string, unknown> {\n const clone: Record<string, unknown> = { ...obj }\n const maybeDecrypt = (payload: string): string | null => {\n const first = decryptWithAesGcm(payload, dek.key)\n if (first === null) return null\n // Handle accidental double-encryption: if the first pass still looks like a v1 payload, try once more.\n const parts = first.split(':')\n if (parts.length === 4 && parts[3] === 'v1') {\n const second = decryptWithAesGcm(first, dek.key)\n return second ?? first\n }\n return first\n }\n for (const rule of fields) {\n const key = findKey(clone, rule.field)\n if (!key) continue\n const value = clone[key]\n if (typeof value !== 'string') continue\n const decrypted = maybeDecrypt(value)\n if (decrypted === null) continue\n // Entity fields are typed columns (string/text). Never auto-parse to an object \u2014\n // it triggers React-render crashes when a string value happens to be valid JSON\n // (issue #1810 follow-up). Custom field values use a separate helper that\n // preserves their typed-JSON contract.\n clone[key] = decrypted\n }\n return clone\n }\n\n async encryptEntityPayload(\n entityId: string,\n payload: Record<string, unknown>,\n tenantId: string | null | undefined,\n organizationId?: string | null\n ): Promise<Record<string, unknown>> {\n if (!this.isEnabled()) {\n debug('\u26AA\uFE0F encrypt.skip.disabled', { entityId, tenantId })\n return payload\n }\n const dek = await this.resolveDekForEncrypt(tenantId ?? null)\n if (!dek) {\n debug('\u26A0\uFE0F encrypt.skip.no-dek', { entityId, tenantId })\n return payload\n }\n const map = await this.getMap({ entityId, tenantId: tenantId ?? null, organizationId: organizationId ?? null })\n if (!map || !map.fields?.length) {\n debug('\u26AA\uFE0F encrypt.skip.no-map', { entityId, tenantId })\n return payload\n }\n debug('\uD83D\uDD12 encrypt_entity', { entityId, tenantId, organizationId, fields: map.fields.length })\n return this.encryptFields(payload, map.fields, dek)\n }\n\n async decryptEntityPayload(\n entityId: string,\n payload: Record<string, unknown>,\n tenantId: string | null | undefined,\n organizationId?: string | null\n ): Promise<Record<string, unknown>> {\n if (!isTenantDataEncryptionEnabled()) {\n debug('\u26AA\uFE0F decrypt.skip.disabled', { entityId, tenantId })\n return payload\n }\n const dek = await this.getDek(tenantId ?? null)\n if (!dek) {\n debug('\u26A0\uFE0F decrypt.skip.no-dek', { entityId, tenantId })\n return payload\n }\n const map = await this.getMap({ entityId, tenantId: tenantId ?? null, organizationId: organizationId ?? null })\n if (!map || !map.fields?.length) {\n debug('\u26AA\uFE0F decrypt.skip.no-map', { entityId, tenantId })\n return payload\n }\n debug('\uD83D\uDD13 decrypt_entity', { entityId, tenantId, organizationId, fields: map.fields.length })\n return this.decryptFields(payload, map.fields, dek)\n }\n}\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
6
|
"names": ["pending"]
|
|
7
7
|
}
|
package/dist/lib/version.js
CHANGED
package/dist/lib/version.js.map
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../src/lib/version.ts"],
|
|
4
|
-
"sourcesContent": ["// Build-time generated version\nexport const APP_VERSION = '0.
|
|
4
|
+
"sourcesContent": ["// Build-time generated version\nexport const APP_VERSION = '0.6.0'\nexport const appVersion = APP_VERSION\n"],
|
|
5
5
|
"mappings": "AACO,MAAM,cAAc;AACpB,MAAM,aAAa;",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@open-mercato/shared",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "./dist/index.js",
|
|
6
6
|
"scripts": {
|
|
@@ -89,10 +89,10 @@
|
|
|
89
89
|
}
|
|
90
90
|
},
|
|
91
91
|
"dependencies": {
|
|
92
|
-
"@mikro-orm/core": "^7.0.
|
|
93
|
-
"@mikro-orm/decorators": "^7.0.
|
|
94
|
-
"@mikro-orm/postgresql": "^7.0.
|
|
95
|
-
"@open-mercato/cache": "0.
|
|
92
|
+
"@mikro-orm/core": "^7.0.14",
|
|
93
|
+
"@mikro-orm/decorators": "^7.0.14",
|
|
94
|
+
"@mikro-orm/postgresql": "^7.0.14",
|
|
95
|
+
"@open-mercato/cache": "0.6.0",
|
|
96
96
|
"dotenv": "^17.4.2",
|
|
97
97
|
"rate-limiter-flexible": "^11.0.1",
|
|
98
98
|
"reflect-metadata": "^0.2.2",
|
|
@@ -112,6 +112,5 @@
|
|
|
112
112
|
"type": "git",
|
|
113
113
|
"url": "https://github.com/open-mercato/open-mercato",
|
|
114
114
|
"directory": "packages/shared"
|
|
115
|
-
}
|
|
116
|
-
"stableVersion": "0.5.0"
|
|
115
|
+
}
|
|
117
116
|
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import {
|
|
2
|
+
applyCustomFieldsNormalization,
|
|
2
3
|
buildCustomFieldFiltersFromQuery,
|
|
3
4
|
decorateRecordWithCustomFields,
|
|
4
5
|
extractAllCustomFieldEntries,
|
|
@@ -298,6 +299,62 @@ describe('decorateRecordWithCustomFields', () => {
|
|
|
298
299
|
})
|
|
299
300
|
})
|
|
300
301
|
|
|
302
|
+
describe('applyCustomFieldsNormalization', () => {
|
|
303
|
+
const definitionIndex: CustomFieldDefinitionIndex = new Map([
|
|
304
|
+
[
|
|
305
|
+
'priority',
|
|
306
|
+
[
|
|
307
|
+
{
|
|
308
|
+
key: 'priority',
|
|
309
|
+
label: 'Priority',
|
|
310
|
+
kind: 'integer',
|
|
311
|
+
multi: false,
|
|
312
|
+
organizationId: null,
|
|
313
|
+
tenantId: null,
|
|
314
|
+
priority: 0,
|
|
315
|
+
updatedAt: 1,
|
|
316
|
+
},
|
|
317
|
+
],
|
|
318
|
+
],
|
|
319
|
+
])
|
|
320
|
+
|
|
321
|
+
it('preserves cf_* keys by default for backward compatibility', () => {
|
|
322
|
+
const record = { id: 'r-1', name: 'Item', cf_priority: 5, 'cf:priority': 5 }
|
|
323
|
+
const decorated = decorateRecordWithCustomFields(record, definitionIndex, {})
|
|
324
|
+
const result = applyCustomFieldsNormalization(record, decorated)
|
|
325
|
+
|
|
326
|
+
expect(result.id).toBe('r-1')
|
|
327
|
+
expect(result.cf_priority).toBe(5)
|
|
328
|
+
expect(result['cf:priority']).toBe(5)
|
|
329
|
+
expect(result.customValues).toEqual({ priority: 5 })
|
|
330
|
+
expect(Array.isArray(result.customFields)).toBe(true)
|
|
331
|
+
expect((result.customFields as any[])[0]).toMatchObject({ key: 'priority', value: 5 })
|
|
332
|
+
})
|
|
333
|
+
|
|
334
|
+
it('strips cf_* and cf:* keys when stripPrefixedKeys is enabled (issue #1769)', () => {
|
|
335
|
+
const record = { id: 'r-1', name: 'Item', cf_priority: 5, 'cf:priority': 5 }
|
|
336
|
+
const decorated = decorateRecordWithCustomFields(record, definitionIndex, {})
|
|
337
|
+
const result = applyCustomFieldsNormalization(record, decorated, { stripPrefixedKeys: true })
|
|
338
|
+
|
|
339
|
+
expect(result.id).toBe('r-1')
|
|
340
|
+
expect(result.name).toBe('Item')
|
|
341
|
+
expect('cf_priority' in result).toBe(false)
|
|
342
|
+
expect('cf:priority' in result).toBe(false)
|
|
343
|
+
expect(result.customValues).toEqual({ priority: 5 })
|
|
344
|
+
expect(Array.isArray(result.customFields)).toBe(true)
|
|
345
|
+
})
|
|
346
|
+
|
|
347
|
+
it('emits null customValues when no active definitions match', () => {
|
|
348
|
+
const record = { id: 'r-1', cf_unknown: 'leftover' }
|
|
349
|
+
const decorated = decorateRecordWithCustomFields(record, new Map(), {})
|
|
350
|
+
const result = applyCustomFieldsNormalization(record, decorated, { stripPrefixedKeys: true })
|
|
351
|
+
|
|
352
|
+
expect(result.customValues).toBeNull()
|
|
353
|
+
expect(result.customFields).toEqual([])
|
|
354
|
+
expect('cf_unknown' in result).toBe(false)
|
|
355
|
+
})
|
|
356
|
+
})
|
|
357
|
+
|
|
301
358
|
describe('loadCustomFieldDefinitionIndex', () => {
|
|
302
359
|
it('filters definition summaries by selected fieldset membership', async () => {
|
|
303
360
|
const em = mockEntityManager([
|
|
@@ -406,6 +406,32 @@ export async function loadCustomFieldDefinitionIndex(opts: {
|
|
|
406
406
|
return index
|
|
407
407
|
}
|
|
408
408
|
|
|
409
|
+
export type ApplyCustomFieldsNormalizationOptions = {
|
|
410
|
+
/**
|
|
411
|
+
* When true, removes raw `cf_*` and `cf:*` keys from the record once they
|
|
412
|
+
* have been extracted into `customValues` / `customFields`. Produces a single
|
|
413
|
+
* canonical response shape (issue #1769). Defaults to `false` to preserve the
|
|
414
|
+
* existing wire format for callers that read `cf_*` from the top level.
|
|
415
|
+
*/
|
|
416
|
+
stripPrefixedKeys?: boolean
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
export function applyCustomFieldsNormalization(
|
|
420
|
+
record: Record<string, unknown>,
|
|
421
|
+
decorated: CustomFieldDisplayPayload,
|
|
422
|
+
options: ApplyCustomFieldsNormalizationOptions = {},
|
|
423
|
+
): Record<string, unknown> {
|
|
424
|
+
const stripPrefixedKeys = options.stripPrefixedKeys === true
|
|
425
|
+
const base: Record<string, unknown> = {}
|
|
426
|
+
for (const [key, value] of Object.entries(record)) {
|
|
427
|
+
if (stripPrefixedKeys && (key.startsWith('cf_') || key.startsWith('cf:'))) continue
|
|
428
|
+
base[key] = value
|
|
429
|
+
}
|
|
430
|
+
base.customValues = decorated.customValues
|
|
431
|
+
base.customFields = decorated.customFields
|
|
432
|
+
return base
|
|
433
|
+
}
|
|
434
|
+
|
|
409
435
|
export function decorateRecordWithCustomFields(
|
|
410
436
|
record: Record<string, unknown>,
|
|
411
437
|
definitions: CustomFieldDefinitionIndex,
|
package/src/lib/crud/factory.ts
CHANGED
|
@@ -30,6 +30,7 @@ import {
|
|
|
30
30
|
extractCustomFieldValuesFromPayload,
|
|
31
31
|
extractAllCustomFieldEntries,
|
|
32
32
|
decorateRecordWithCustomFields,
|
|
33
|
+
applyCustomFieldsNormalization,
|
|
33
34
|
loadCustomFieldDefinitionIndex,
|
|
34
35
|
} from './custom-fields'
|
|
35
36
|
import { serializeExport, normalizeExportFormat, defaultExportFilename, ensureColumns, type CrudExportFormat, type PreparedExport } from './exporters'
|
|
@@ -162,6 +163,14 @@ export type CustomFieldsConfig =
|
|
|
162
163
|
export type CrudListCustomFieldDecorator = {
|
|
163
164
|
entityIds: EntityId | EntityId[]
|
|
164
165
|
resolveContext?: (item: any, ctx: CrudCtx) => { organizationId?: string | null; tenantId?: string | null }
|
|
166
|
+
/**
|
|
167
|
+
* When true, the factory removes raw `cf_*` and `cf:*` keys from each list
|
|
168
|
+
* item after extracting them into `customValues` / `customFields`. Recommended
|
|
169
|
+
* for new modules — produces the single canonical response shape requested in
|
|
170
|
+
* #1769. Defaults to `false` so existing callers that read `cf_*` from the
|
|
171
|
+
* top level keep working until they migrate.
|
|
172
|
+
*/
|
|
173
|
+
stripPrefixedKeys?: boolean
|
|
165
174
|
}
|
|
166
175
|
|
|
167
176
|
export type ListConfig<TList> = {
|
|
@@ -924,12 +933,9 @@ export function makeCrudRoute<TCreate = any, TUpdate = any, TList = any>(opts: C
|
|
|
924
933
|
organizationId: organizationId ?? null,
|
|
925
934
|
tenantId: tenantId ?? null,
|
|
926
935
|
})
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
customFields: decorated.customFields,
|
|
931
|
-
}
|
|
932
|
-
return output
|
|
936
|
+
return applyCustomFieldsNormalization(item, decorated, {
|
|
937
|
+
stripPrefixedKeys: listCustomFieldDecorator.stripPrefixedKeys === true,
|
|
938
|
+
})
|
|
933
939
|
})
|
|
934
940
|
cfProfiler.mark('decorate_complete', { itemCount: decoratedItems.length })
|
|
935
941
|
endProfile({
|
|
@@ -76,20 +76,41 @@ describe('TenantDataEncryptionService.decryptFields (issue #1734)', () => {
|
|
|
76
76
|
expect(out.primary_email).toBe('mail@example.com')
|
|
77
77
|
})
|
|
78
78
|
|
|
79
|
-
it('
|
|
79
|
+
it('returns the raw JSON-string payload for JSON-object values (issue #1810 follow-up)', () => {
|
|
80
|
+
// After the issue #1810 follow-up, decryptFields no longer auto-parses
|
|
81
|
+
// decrypted entity-field strings — even when they happen to look like
|
|
82
|
+
// JSON. Callers that legitimately need the parsed shape (audit_logs jsonb
|
|
83
|
+
// columns, custom-field rotation, encryption CLI) MUST invoke
|
|
84
|
+
// `parseDecryptedFieldValue` themselves on the decrypted payload.
|
|
80
85
|
const service = makeService()
|
|
81
86
|
const payload = { actor: 'user-1', changes: { name: 'old → new' } }
|
|
82
|
-
const
|
|
87
|
+
const serialized = JSON.stringify(payload)
|
|
88
|
+
const obj = { context_json: encrypt(serialized) }
|
|
83
89
|
const out = service.decryptFields(obj, [{ field: 'context_json' }], { key: fixedKey } as never)
|
|
84
|
-
expect(out.context_json).
|
|
90
|
+
expect(out.context_json).toBe(serialized)
|
|
91
|
+
expect(typeof out.context_json).toBe('string')
|
|
85
92
|
})
|
|
86
93
|
|
|
87
|
-
it('
|
|
94
|
+
it('returns the raw JSON-string payload for JSON-array values', () => {
|
|
88
95
|
const service = makeService()
|
|
89
96
|
const arr = [{ id: 1 }, { id: 2 }]
|
|
90
|
-
const
|
|
97
|
+
const serialized = JSON.stringify(arr)
|
|
98
|
+
const obj = { thread_messages: encrypt(serialized) }
|
|
91
99
|
const out = service.decryptFields(obj, [{ field: 'thread_messages' }], { key: fixedKey } as never)
|
|
92
|
-
expect(out.thread_messages).
|
|
100
|
+
expect(out.thread_messages).toBe(serialized)
|
|
101
|
+
expect(typeof out.thread_messages).toBe('string')
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
it('preserves JSON-object-shaped display names as raw strings (regression: issue #1810)', () => {
|
|
105
|
+
// Display names like `{"a":1,"qa":12345}` are typed as text and must remain
|
|
106
|
+
// strings so React can render them safely in detail/list views. Auto-parsing
|
|
107
|
+
// them used to throw "Objects are not valid as a React child".
|
|
108
|
+
const service = makeService()
|
|
109
|
+
const raw = '{"a":1,"qa":12345}'
|
|
110
|
+
const obj = { display_name: encrypt(raw) }
|
|
111
|
+
const out = service.decryptFields(obj, [{ field: 'display_name' }], { key: fixedKey } as never)
|
|
112
|
+
expect(out.display_name).toBe(raw)
|
|
113
|
+
expect(typeof out.display_name).toBe('string')
|
|
93
114
|
})
|
|
94
115
|
|
|
95
116
|
it('keeps boolean-like and null-like text strings as strings', () => {
|
|
@@ -67,6 +67,13 @@ function findKey(obj: Record<string, unknown>, key: string): string | null {
|
|
|
67
67
|
* payload is unambiguously a JSON object or array; otherwise return the raw
|
|
68
68
|
* decrypted string. Numeric/boolean entity columns are not in any current
|
|
69
69
|
* encryption map, so this is backward-compatible.
|
|
70
|
+
*
|
|
71
|
+
* NOTE (issue #1810 follow-up): `decryptFields` no longer calls this helper for
|
|
72
|
+
* entity-field decryption — typed string columns whose contents happen to look
|
|
73
|
+
* like JSON (e.g. a display name `{"a":1}`) must remain raw strings to avoid
|
|
74
|
+
* downstream React-render crashes. Callers that legitimately need the parse
|
|
75
|
+
* (audit-log jsonb columns, custom-field rotation, encryption CLI) MUST invoke
|
|
76
|
+
* `parseDecryptedFieldValue` themselves on the decrypted payload.
|
|
70
77
|
*/
|
|
71
78
|
export function parseDecryptedFieldValue(decrypted: string): unknown {
|
|
72
79
|
if (decrypted.length === 0) return decrypted
|
|
@@ -277,7 +284,11 @@ export class TenantDataEncryptionService {
|
|
|
277
284
|
if (typeof value !== 'string') continue
|
|
278
285
|
const decrypted = maybeDecrypt(value)
|
|
279
286
|
if (decrypted === null) continue
|
|
280
|
-
|
|
287
|
+
// Entity fields are typed columns (string/text). Never auto-parse to an object —
|
|
288
|
+
// it triggers React-render crashes when a string value happens to be valid JSON
|
|
289
|
+
// (issue #1810 follow-up). Custom field values use a separate helper that
|
|
290
|
+
// preserves their typed-JSON contract.
|
|
291
|
+
clone[key] = decrypted
|
|
281
292
|
}
|
|
282
293
|
return clone
|
|
283
294
|
}
|