@open-mercato/shared 0.10.1-develop.7679.1.cb2c13f49e → 0.10.1-develop.7687.1.e1a6f9de51

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (31) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/dist/lib/crud/factory.js +3 -0
  3. package/dist/lib/crud/factory.js.map +2 -2
  4. package/dist/lib/encryption/decryptScope.js +51 -0
  5. package/dist/lib/encryption/decryptScope.js.map +7 -0
  6. package/dist/lib/encryption/find.js +24 -10
  7. package/dist/lib/encryption/find.js.map +2 -2
  8. package/dist/lib/encryption/subscriber.js +73 -15
  9. package/dist/lib/encryption/subscriber.js.map +2 -2
  10. package/dist/lib/query/encrypted-sort.js +15 -6
  11. package/dist/lib/query/encrypted-sort.js.map +2 -2
  12. package/dist/lib/query/engine.js +57 -10
  13. package/dist/lib/query/engine.js.map +2 -2
  14. package/dist/lib/query/types.js.map +1 -1
  15. package/dist/lib/version.js +1 -1
  16. package/dist/lib/version.js.map +1 -1
  17. package/package.json +2 -2
  18. package/src/lib/crud/__tests__/crud-factory.test.ts +16 -0
  19. package/src/lib/crud/factory.ts +13 -0
  20. package/src/lib/encryption/__tests__/decrypt-scope.test.ts +134 -0
  21. package/src/lib/encryption/__tests__/subscriber.onload-tenant-binding.test.ts +234 -0
  22. package/src/lib/encryption/__tests__/subscriber.refusal-aggregation.test.ts +105 -0
  23. package/src/lib/encryption/__tests__/subscriber.tenant-binding.test.ts +131 -0
  24. package/src/lib/encryption/decryptScope.ts +119 -0
  25. package/src/lib/encryption/find.ts +17 -12
  26. package/src/lib/encryption/subscriber.ts +101 -19
  27. package/src/lib/query/__tests__/engine-decrypt-refusal-log.test.ts +168 -0
  28. package/src/lib/query/__tests__/engine.test.ts +237 -0
  29. package/src/lib/query/encrypted-sort.ts +34 -8
  30. package/src/lib/query/engine.ts +70 -10
  31. package/src/lib/query/types.ts +15 -0
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../src/lib/query/encrypted-sort.ts"],
4
- "sourcesContent": ["import type { EntityId } from '@open-mercato/shared/modules/entities'\nimport { SortDir, type Sort } from './types'\n\nexport type QueryEncryptionService = {\n getEncryptedFieldNames?: (\n entityId: EntityId,\n tenantId?: string | null,\n organizationId?: string | null,\n ) => Promise<readonly string[]>\n isEnabled?: () => boolean\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\nexport function fieldNameCandidates(field: string): string[] {\n const raw = String(field || '').trim()\n if (!raw) return []\n const candidates = [raw, toSnakeCase(raw), toCamelCase(raw)]\n if (raw.startsWith('cf:')) {\n const sanitized = raw.replace(/[^a-zA-Z0-9_]/g, '_')\n // The ORM query engine's plaintext-sort candidate scan projects a `cf:`\n // sort key only under its dedicated `<alias>__sort` projection alias, never\n // the plain jsonb-aggregate alias \u2014 so the in-memory sort needs that exact\n // name to find the value on the phase-1 candidate rows (#5674 review).\n candidates.push(sanitized, `${sanitized}__sort`)\n }\n return Array.from(new Set(candidates))\n}\n\n/**\n * Opt-in cap on how many candidate rows the plaintext-sort path may fetch\n * for sort-column decryption. Unset or invalid input means uncapped \u2014 the\n * default must stay byte-identical to the pre-cap behavior.\n */\nexport function resolveEncryptedSortMaxRows(): number | null {\n const raw = process.env.OM_ENCRYPTED_SORT_MAX_ROWS\n if (raw === undefined || raw === '') return null\n const parsed = Number(raw)\n if (!Number.isFinite(parsed) || parsed <= 0) return null\n return Math.floor(parsed)\n}\n\nexport async function resolveEncryptedSortFields(\n service: QueryEncryptionService | null | undefined,\n entity: EntityId,\n sortFields: readonly string[],\n tenantId?: string | null,\n organizationId?: string | null,\n): Promise<Set<string>> {\n if (!service?.getEncryptedFieldNames) return new Set()\n if (service.isEnabled && !service.isEnabled()) return new Set()\n const encrypted = await service.getEncryptedFieldNames(entity, tenantId ?? null, organizationId ?? null)\n const encryptedCandidates = new Set<string>()\n for (const field of encrypted) {\n for (const candidate of fieldNameCandidates(field)) encryptedCandidates.add(candidate)\n }\n const result = new Set<string>()\n for (const field of sortFields) {\n if (field.startsWith('cf:')) continue\n const matches = fieldNameCandidates(field).some((candidate) => encryptedCandidates.has(candidate))\n if (matches) result.add(field)\n }\n return result\n}\n\nfunction readField(row: Record<string, unknown>, field: string): unknown {\n for (const candidate of fieldNameCandidates(field)) {\n if (Object.prototype.hasOwnProperty.call(row, candidate)) return row[candidate]\n }\n return undefined\n}\n\nfunction compareValues(left: unknown, right: unknown): number {\n const leftMissing = left === null || left === undefined\n const rightMissing = right === null || right === undefined\n if (leftMissing && rightMissing) return 0\n if (leftMissing) return 1\n if (rightMissing) return -1\n if (left instanceof Date && right instanceof Date) return left.getTime() - right.getTime()\n if (typeof left === 'number' && typeof right === 'number') return left - right\n if (typeof left === 'boolean' && typeof right === 'boolean') return Number(left) - Number(right)\n return String(left).localeCompare(String(right), undefined, {\n sensitivity: 'base',\n numeric: true,\n })\n}\n\nexport function sortRowsInMemory<T extends Record<string, unknown>>(\n rows: readonly T[],\n sorts: readonly Sort[],\n): T[] {\n return [...rows].sort((left, right) => {\n for (const sort of sorts) {\n const direction = sort.dir === SortDir.Desc ? -1 : 1\n const compared = compareValues(readField(left, sort.field), readField(right, sort.field))\n if (compared !== 0) return compared * direction\n }\n const leftId = readField(left, 'id')\n const rightId = readField(right, 'id')\n return compareValues(leftId, rightId)\n })\n}\n"],
5
- "mappings": "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",
4
+ "sourcesContent": ["import type { EntityId } from '@open-mercato/shared/modules/entities'\nimport { SortDir, type Sort } from './types'\n\nexport type QueryEncryptionService = {\n getEncryptedFieldNames?: (\n entityId: EntityId,\n tenantId?: string | null,\n organizationId?: string | null,\n ) => Promise<readonly string[]>\n isEnabled?: () => boolean\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\nexport function fieldNameCandidates(field: string): string[] {\n const raw = String(field || '').trim()\n if (!raw) return []\n const candidates = [raw, toSnakeCase(raw), toCamelCase(raw)]\n if (raw.startsWith('cf:')) {\n const sanitized = raw.replace(/[^a-zA-Z0-9_]/g, '_')\n // The ORM query engine's plaintext-sort candidate scan projects a `cf:`\n // sort key only under its dedicated `<alias>__sort` projection alias, never\n // the plain jsonb-aggregate alias \u2014 so the in-memory sort needs that exact\n // name to find the value on the phase-1 candidate rows (#5674 review).\n candidates.push(sanitized, `${sanitized}__sort`)\n }\n return Array.from(new Set(candidates))\n}\n\n/**\n * Opt-in cap on how many candidate rows the plaintext-sort path may fetch\n * for sort-column decryption. Unset or invalid input means uncapped \u2014 the\n * default must stay byte-identical to the pre-cap behavior.\n */\nexport function resolveEncryptedSortMaxRows(): number | null {\n const raw = process.env.OM_ENCRYPTED_SORT_MAX_ROWS\n if (raw === undefined || raw === '') return null\n const parsed = Number(raw)\n if (!Number.isFinite(parsed) || parsed <= 0) return null\n return Math.floor(parsed)\n}\n\n/**\n * Reads the entity's encryption map once so a query can answer both \"which sort fields are\n * encrypted\" and \"must the projection carry `tenant_id` into the decrypt decision\" without paying\n * for two lookups.\n *\n * `null` means the map is unavailable \u2014 no service, or one that cannot report field names. A query\n * that reads `null` keeps today's behaviour for sorting (no plaintext-sort path) but must assume a\n * decrypt attempt may still follow. An empty array means the entity genuinely encrypts nothing.\n */\nexport async function resolveEncryptedFieldNames(\n service: QueryEncryptionService | null | undefined,\n entity: EntityId,\n tenantId?: string | null,\n organizationId?: string | null,\n): Promise<readonly string[] | null> {\n if (service?.isEnabled && !service.isEnabled()) return []\n if (!service?.getEncryptedFieldNames) return null\n return await service.getEncryptedFieldNames(entity, tenantId ?? null, organizationId ?? null)\n}\n\nexport function matchEncryptedSortFields(\n encryptedFieldNames: readonly string[] | null,\n sortFields: readonly string[],\n): Set<string> {\n const result = new Set<string>()\n if (!encryptedFieldNames || encryptedFieldNames.length === 0) return result\n const encryptedCandidates = new Set<string>()\n for (const field of encryptedFieldNames) {\n for (const candidate of fieldNameCandidates(field)) encryptedCandidates.add(candidate)\n }\n for (const field of sortFields) {\n if (field.startsWith('cf:')) continue\n const matches = fieldNameCandidates(field).some((candidate) => encryptedCandidates.has(candidate))\n if (matches) result.add(field)\n }\n return result\n}\n\nexport async function resolveEncryptedSortFields(\n service: QueryEncryptionService | null | undefined,\n entity: EntityId,\n sortFields: readonly string[],\n tenantId?: string | null,\n organizationId?: string | null,\n): Promise<Set<string>> {\n const encryptedFieldNames = await resolveEncryptedFieldNames(service, entity, tenantId, organizationId)\n return matchEncryptedSortFields(encryptedFieldNames, sortFields)\n}\n\nfunction readField(row: Record<string, unknown>, field: string): unknown {\n for (const candidate of fieldNameCandidates(field)) {\n if (Object.prototype.hasOwnProperty.call(row, candidate)) return row[candidate]\n }\n return undefined\n}\n\nfunction compareValues(left: unknown, right: unknown): number {\n const leftMissing = left === null || left === undefined\n const rightMissing = right === null || right === undefined\n if (leftMissing && rightMissing) return 0\n if (leftMissing) return 1\n if (rightMissing) return -1\n if (left instanceof Date && right instanceof Date) return left.getTime() - right.getTime()\n if (typeof left === 'number' && typeof right === 'number') return left - right\n if (typeof left === 'boolean' && typeof right === 'boolean') return Number(left) - Number(right)\n return String(left).localeCompare(String(right), undefined, {\n sensitivity: 'base',\n numeric: true,\n })\n}\n\nexport function sortRowsInMemory<T extends Record<string, unknown>>(\n rows: readonly T[],\n sorts: readonly Sort[],\n): T[] {\n return [...rows].sort((left, right) => {\n for (const sort of sorts) {\n const direction = sort.dir === SortDir.Desc ? -1 : 1\n const compared = compareValues(readField(left, sort.field), readField(right, sort.field))\n if (compared !== 0) return compared * direction\n }\n const leftId = readField(left, 'id')\n const rightId = readField(right, 'id')\n return compareValues(leftId, rightId)\n })\n}\n"],
5
+ "mappings": "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",
6
6
  "names": []
7
7
  }
@@ -21,9 +21,22 @@ import {
21
21
  resolveCfDefIndexOrgCandidates
22
22
  } from "../crud/custom-field-definition-index.js";
23
23
  import { warnOnCiphertextLikeFallback } from "./ciphertext-search-warning.js";
24
- import { resolveEncryptedSortFields, resolveEncryptedSortMaxRows, sortRowsInMemory } from "./encrypted-sort.js";
24
+ import {
25
+ matchEncryptedSortFields,
26
+ resolveEncryptedFieldNames,
27
+ resolveEncryptedSortFields,
28
+ resolveEncryptedSortMaxRows,
29
+ sortRowsInMemory
30
+ } from "./encrypted-sort.js";
25
31
  import { resolveListCountCap } from "./count-cap.js";
26
32
  import { mapWithConcurrency } from "./bounded-decrypt.js";
33
+ import {
34
+ DECLINED_ENCRYPTED_SORT_LOG_MESSAGE,
35
+ DECRYPT_REFUSAL_LOG_MESSAGE,
36
+ DecryptRefusalTally,
37
+ resolveDecryptEnabled,
38
+ resolveDecryptScope
39
+ } from "../encryption/decryptScope.js";
27
40
  import { parseNumberWithDefault } from "../number.js";
28
41
  import { createLogger } from "../logger/index.js";
29
42
  import {
@@ -473,7 +486,8 @@ class BasicQueryEngine {
473
486
  return next;
474
487
  };
475
488
  const fallbackOrgId = opts.organizationId ?? (Array.isArray(opts.organizationIds) && opts.organizationIds.length === 1 ? opts.organizationIds[0] : null);
476
- const encryptionService = this.getEncryptionService();
489
+ const decryptEnabled = resolveDecryptEnabled(opts);
490
+ const encryptionService = decryptEnabled ? this.getEncryptionService() : null;
477
491
  const resolvedSorts = [];
478
492
  for (const s of opts.sort || []) {
479
493
  if (s.field.startsWith("cf:")) {
@@ -483,14 +497,32 @@ class BasicQueryEngine {
483
497
  if (column) resolvedSorts.push({ ...s, field: column });
484
498
  }
485
499
  }
486
- const encryptedSortFields = await resolveEncryptedSortFields(
500
+ const plainSortFields = resolvedSorts.filter((sort) => !sort.field.startsWith("cf:")).map((sort) => sort.field);
501
+ const encryptedFieldNames = await resolveEncryptedFieldNames(
487
502
  encryptionService,
488
503
  entity,
489
- resolvedSorts.filter((sort) => !sort.field.startsWith("cf:")).map((sort) => sort.field),
490
504
  opts.tenantId ?? null,
491
505
  fallbackOrgId
492
506
  );
507
+ const encryptedSortFields = matchEncryptedSortFields(encryptedFieldNames, plainSortFields);
493
508
  const requiresPlaintextSort = encryptedSortFields.size > 0;
509
+ if (!decryptEnabled && plainSortFields.length > 0) {
510
+ const declinedSortFields = await resolveEncryptedSortFields(
511
+ this.getEncryptionService(),
512
+ entity,
513
+ plainSortFields,
514
+ opts.tenantId ?? null,
515
+ fallbackOrgId
516
+ );
517
+ if (declinedSortFields.size > 0) {
518
+ logger.warn(DECLINED_ENCRYPTED_SORT_LOG_MESSAGE, {
519
+ entity: String(entity),
520
+ sortFields: Array.from(declinedSortFields)
521
+ });
522
+ }
523
+ }
524
+ const requestedFields = opts.fields && opts.fields.length ? opts.fields.map(String) : null;
525
+ const injectsTenantIdForDecrypt = encryptionService != null && requestedFields != null && !requestedFields.includes("tenant_id") && !requestedFields.includes("tenantId") && (encryptedFieldNames === null || encryptedFieldNames.length > 0) && await this.columnExists(table, "tenant_id");
494
526
  const tenantId = opts.tenantId;
495
527
  const sanitize = (s) => s.replace(/[^a-zA-Z0-9_]/g, "_");
496
528
  const buildQuery = async (projection) => {
@@ -586,6 +618,7 @@ class BasicQueryEngine {
586
618
  if (requiresPlaintextSort) {
587
619
  for (const field of encryptedSortFields) cols.add(field);
588
620
  }
621
+ if (injectsTenantIdForDecrypt) cols.add("tenant_id");
589
622
  for (const c of cols) {
590
623
  q = q.select(sql.ref(qualify(c)).as(c));
591
624
  }
@@ -979,21 +1012,31 @@ class BasicQueryEngine {
979
1012
  }
980
1013
  const svc = encryptionService;
981
1014
  const decryptPayload = svc?.decryptEntityPayload?.bind(svc);
1015
+ const refusalTally = new DecryptRefusalTally();
982
1016
  const decryptRow = async (item) => {
983
1017
  if (!decryptPayload) return item;
1018
+ const decision = resolveDecryptScope({
1019
+ rowTenantId: item?.tenant_id ?? item?.tenantId ?? null,
1020
+ rowOrganizationId: item?.organization_id ?? item?.organizationId ?? null,
1021
+ callerTenantId: opts.tenantId ?? null,
1022
+ callerOrganizationId: fallbackOrgId ?? null
1023
+ });
1024
+ if (!decision.decrypt) {
1025
+ refusalTally.record(decision, item?.id == null ? null : String(item.id));
1026
+ return item;
1027
+ }
984
1028
  try {
985
- const decrypted = await decryptPayload(
986
- entity,
987
- item,
988
- item?.tenant_id ?? item?.tenantId ?? opts.tenantId ?? null,
989
- item?.organization_id ?? item?.organizationId ?? fallbackOrgId ?? null
990
- );
1029
+ const decrypted = await decryptPayload(entity, item, decision.tenantId, decision.organizationId);
991
1030
  return { ...item, ...decrypted };
992
1031
  } catch (err) {
993
1032
  logger.error("Error decrypting entity payload", { err });
994
1033
  return item;
995
1034
  }
996
1035
  };
1036
+ const reportDecryptRefusals = () => {
1037
+ if (refusalTally.refused === 0) return;
1038
+ logger.warn(DECRYPT_REFUSAL_LOG_MESSAGE, refusalTally.toLogContext(String(entity)));
1039
+ };
997
1040
  const normalizeCfJsonAliases = (rows) => {
998
1041
  if (cfJsonAliases.size === 0) return;
999
1042
  for (const row of rows) {
@@ -1064,6 +1107,10 @@ class BasicQueryEngine {
1064
1107
  stripCfSortAliases(items);
1065
1108
  pagedItems = decryptPayload ? await mapWithConcurrency(items, DECRYPT_CONCURRENCY, decryptRow) : items;
1066
1109
  }
1110
+ reportDecryptRefusals();
1111
+ if (injectsTenantIdForDecrypt) {
1112
+ for (const row of pagedItems) delete row.tenant_id;
1113
+ }
1067
1114
  let queryResult = { items: pagedItems, page, pageSize, total };
1068
1115
  if (encryptedSortRowCapWarning || listCountCapWarning) {
1069
1116
  const meta = {};