@open-mercato/shared 0.8.1-develop.7228.1.b7ea09d2d3 → 0.8.1-develop.7238.1.3abe669ea5

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 (33) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/dist/lib/availability/catalogOnlyProvider.js +65 -0
  3. package/dist/lib/availability/catalogOnlyProvider.js.map +7 -0
  4. package/dist/lib/availability/index.js +8 -0
  5. package/dist/lib/availability/index.js.map +7 -0
  6. package/dist/lib/availability/registry.js +47 -0
  7. package/dist/lib/availability/registry.js.map +7 -0
  8. package/dist/lib/availability/types.js +7 -0
  9. package/dist/lib/availability/types.js.map +7 -0
  10. package/dist/lib/crud/factory.js +21 -7
  11. package/dist/lib/crud/factory.js.map +2 -2
  12. package/dist/lib/db/pg-errors.js +9 -1
  13. package/dist/lib/db/pg-errors.js.map +2 -2
  14. package/dist/lib/encryption/aes.js +14 -0
  15. package/dist/lib/encryption/aes.js.map +2 -2
  16. package/dist/lib/encryption/tenantDataEncryptionService.js +17 -1
  17. package/dist/lib/encryption/tenantDataEncryptionService.js.map +2 -2
  18. package/dist/lib/version.js +1 -1
  19. package/dist/lib/version.js.map +1 -1
  20. package/package.json +6 -2
  21. package/src/lib/availability/__tests__/catalogOnlyProvider.test.ts +98 -0
  22. package/src/lib/availability/__tests__/registry.test.ts +115 -0
  23. package/src/lib/availability/catalogOnlyProvider.ts +119 -0
  24. package/src/lib/availability/index.ts +18 -0
  25. package/src/lib/availability/registry.ts +120 -0
  26. package/src/lib/availability/types.ts +71 -0
  27. package/src/lib/crud/__tests__/crud-factory.test.ts +91 -4
  28. package/src/lib/crud/factory.ts +37 -6
  29. package/src/lib/db/__tests__/pg-errors.test.ts +31 -1
  30. package/src/lib/db/pg-errors.ts +23 -0
  31. package/src/lib/encryption/__tests__/tenantDataEncryptionService.test.ts +97 -9
  32. package/src/lib/encryption/aes.ts +43 -0
  33. package/src/lib/encryption/tenantDataEncryptionService.ts +44 -1
@@ -41,6 +41,13 @@ function getForeignKeyViolationConstraint(err) {
41
41
  }
42
42
  return null;
43
43
  }
44
+ const SQLSTATE_PATTERN = /^(?=.*[0-9])[0-9A-Z]{5}$/;
45
+ function readPgSqlState(err) {
46
+ for (const candidate of pgErrorCandidates(err)) {
47
+ if (typeof candidate.code === "string" && SQLSTATE_PATTERN.test(candidate.code)) return candidate.code;
48
+ }
49
+ return null;
50
+ }
44
51
  const TRANSIENT_CONNECTION_SQLSTATES = /* @__PURE__ */ new Set([
45
52
  "53300",
46
53
  // too_many_connections
@@ -86,6 +93,7 @@ export {
86
93
  getForeignKeyViolationConstraint,
87
94
  isForeignKeyViolation,
88
95
  isTransientDbError,
89
- isUniqueViolation
96
+ isUniqueViolation,
97
+ readPgSqlState
90
98
  };
91
99
  //# sourceMappingURL=pg-errors.js.map
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../src/lib/db/pg-errors.ts"],
4
- "sourcesContent": ["/**\n * Detect a Postgres unique-constraint violation (SQLSTATE 23505) regardless of\n * the ORM/driver layer that surfaces it. Shared across modules so duplicate-insert\n * handling stays consistent platform-wide.\n */\nexport function isUniqueViolation(err: unknown): boolean {\n if (!err || typeof err !== 'object') return false\n const code = (err as { code?: string }).code\n if (code === '23505') return true // Postgres unique_violation\n const message = (err as { message?: string }).message\n return typeof message === 'string' && /duplicate key value|unique constraint/i.test(message)\n}\n\nconst FOREIGN_KEY_VIOLATION_MESSAGE = /violates foreign key constraint(?: \"([^\"]+)\")?/i\n\nconst MAX_ERROR_CHAIN_DEPTH = 4\n\n/**\n * MikroORM wraps driver errors and copies the pg fields onto the wrapper, but\n * the original error may also sit behind `cause` (Node) or `previous`\n * (MikroORM), possibly re-wrapped by a transaction helper. Walk that chain,\n * breadth-first with a small depth cap, so a check works on any layer.\n */\nfunction pgErrorCandidates(err: unknown): Array<Record<string, unknown>> {\n const found: Array<Record<string, unknown>> = []\n const seen = new Set<unknown>()\n let layer: unknown[] = [err]\n for (let depth = 0; depth < MAX_ERROR_CHAIN_DEPTH && layer.length > 0; depth += 1) {\n const next: unknown[] = []\n for (const candidate of layer) {\n if (!candidate || typeof candidate !== 'object' || seen.has(candidate)) continue\n seen.add(candidate)\n const record = candidate as Record<string, unknown>\n found.push(record)\n next.push(record.cause, record.previous)\n }\n layer = next\n }\n return found\n}\n\n/**\n * Detect a Postgres foreign-key violation (SQLSTATE 23503): the row is still\n * referenced by a dependent table, or the payload references a parent that\n * does not exist. Looks through MikroORM's driver-error wrapping.\n */\nexport function isForeignKeyViolation(err: unknown): boolean {\n return pgErrorCandidates(err).some((candidate) => {\n if (candidate.code === '23503') return true // Postgres foreign_key_violation\n return typeof candidate.message === 'string' && FOREIGN_KEY_VIOLATION_MESSAGE.test(candidate.message)\n })\n}\n\n/**\n * Name of the constraint behind a foreign-key violation, read from the pg\n * `constraint` field on any layer of the wrapper chain, or parsed out of the\n * quoted constraint in the driver message when the field is missing.\n */\nexport function getForeignKeyViolationConstraint(err: unknown): string | null {\n for (const candidate of pgErrorCandidates(err)) {\n if (typeof candidate.constraint === 'string' && candidate.constraint.length > 0) return candidate.constraint\n }\n for (const candidate of pgErrorCandidates(err)) {\n if (typeof candidate.message !== 'string') continue\n const match = FOREIGN_KEY_VIOLATION_MESSAGE.exec(candidate.message)\n if (match?.[1]) return match[1]\n }\n return null\n}\n\n/**\n * Postgres SQLSTATEs for transient connection / availability failures \u2014 the\n * database (or its connection pool) is temporarily unreachable and the request\n * can succeed on retry. Deliberately scoped to connection/availability codes;\n * query-level conflicts (deadlock 40P01, serialization 40001, lock_not_available\n * 55P03) are NOT included because they do not mean \"service unavailable\".\n */\nconst TRANSIENT_CONNECTION_SQLSTATES = new Set([\n '53300', // too_many_connections\n '53400', // configuration_limit_exceeded\n '57P01', // admin_shutdown\n '57P02', // crash_shutdown\n '57P03', // cannot_connect_now (db starting up)\n '08000', // connection_exception\n '08001', // sqlclient_unable_to_establish_sqlconnection\n '08003', // connection_does_not_exist\n '08006', // connection_failure\n])\n\n/**\n * Postgres-driver / connection-pool messages that describe a transient DB\n * connection failure when the SQLSTATE is dropped by the ORM wrapper. These are\n * intentionally DB-specific phrases only. Bare socket codes (ECONNREFUSED,\n * ETIMEDOUT, \u2026) are deliberately NOT matched: they can originate from any\n * outbound socket (HTTP, cache, queue), so keying off them would falsely\n * attribute unrelated failures to the database.\n */\nconst TRANSIENT_DB_MESSAGE_PATTERNS = [\n /too many clients already/i,\n /unable to acquire a connection/i,\n /timeout acquiring a connection/i,\n /connection terminated/i,\n /the database system is (starting up|shutting down|in recovery)/i,\n]\n\n/**\n * Detect a transient Postgres connection / availability failure (pool exhaustion,\n * `max_connections` reached, DB restarting) via its SQLSTATE or a DB-specific\n * driver message. Gates retryable 503 responses so callers do not report a\n * temporary infrastructure blip as an auth failure (401) or an unexpected server\n * error (500). Scoped to unambiguous DB signals so generic socket errors from\n * non-DB calls are never misclassified.\n */\nexport function isTransientDbError(err: unknown): boolean {\n if (!err || typeof err !== 'object') {\n return false\n }\n const code = (err as { code?: string }).code\n if (typeof code === 'string' && TRANSIENT_CONNECTION_SQLSTATES.has(code)) {\n return true\n }\n const message = (err as { message?: string }).message\n if (typeof message === 'string' && TRANSIENT_DB_MESSAGE_PATTERNS.some((pattern) => pattern.test(message))) {\n return true\n }\n return false\n}\n"],
5
- "mappings": "AAKO,SAAS,kBAAkB,KAAuB;AACvD,MAAI,CAAC,OAAO,OAAO,QAAQ,SAAU,QAAO;AAC5C,QAAM,OAAQ,IAA0B;AACxC,MAAI,SAAS,QAAS,QAAO;AAC7B,QAAM,UAAW,IAA6B;AAC9C,SAAO,OAAO,YAAY,YAAY,yCAAyC,KAAK,OAAO;AAC7F;AAEA,MAAM,gCAAgC;AAEtC,MAAM,wBAAwB;AAQ9B,SAAS,kBAAkB,KAA8C;AACvE,QAAM,QAAwC,CAAC;AAC/C,QAAM,OAAO,oBAAI,IAAa;AAC9B,MAAI,QAAmB,CAAC,GAAG;AAC3B,WAAS,QAAQ,GAAG,QAAQ,yBAAyB,MAAM,SAAS,GAAG,SAAS,GAAG;AACjF,UAAM,OAAkB,CAAC;AACzB,eAAW,aAAa,OAAO;AAC7B,UAAI,CAAC,aAAa,OAAO,cAAc,YAAY,KAAK,IAAI,SAAS,EAAG;AACxE,WAAK,IAAI,SAAS;AAClB,YAAM,SAAS;AACf,YAAM,KAAK,MAAM;AACjB,WAAK,KAAK,OAAO,OAAO,OAAO,QAAQ;AAAA,IACzC;AACA,YAAQ;AAAA,EACV;AACA,SAAO;AACT;AAOO,SAAS,sBAAsB,KAAuB;AAC3D,SAAO,kBAAkB,GAAG,EAAE,KAAK,CAAC,cAAc;AAChD,QAAI,UAAU,SAAS,QAAS,QAAO;AACvC,WAAO,OAAO,UAAU,YAAY,YAAY,8BAA8B,KAAK,UAAU,OAAO;AAAA,EACtG,CAAC;AACH;AAOO,SAAS,iCAAiC,KAA6B;AAC5E,aAAW,aAAa,kBAAkB,GAAG,GAAG;AAC9C,QAAI,OAAO,UAAU,eAAe,YAAY,UAAU,WAAW,SAAS,EAAG,QAAO,UAAU;AAAA,EACpG;AACA,aAAW,aAAa,kBAAkB,GAAG,GAAG;AAC9C,QAAI,OAAO,UAAU,YAAY,SAAU;AAC3C,UAAM,QAAQ,8BAA8B,KAAK,UAAU,OAAO;AAClE,QAAI,QAAQ,CAAC,EAAG,QAAO,MAAM,CAAC;AAAA,EAChC;AACA,SAAO;AACT;AASA,MAAM,iCAAiC,oBAAI,IAAI;AAAA,EAC7C;AAAA;AAAA,EACA;AAAA;AAAA,EACA;AAAA;AAAA,EACA;AAAA;AAAA,EACA;AAAA;AAAA,EACA;AAAA;AAAA,EACA;AAAA;AAAA,EACA;AAAA;AAAA,EACA;AAAA;AACF,CAAC;AAUD,MAAM,gCAAgC;AAAA,EACpC;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AACF;AAUO,SAAS,mBAAmB,KAAuB;AACxD,MAAI,CAAC,OAAO,OAAO,QAAQ,UAAU;AACnC,WAAO;AAAA,EACT;AACA,QAAM,OAAQ,IAA0B;AACxC,MAAI,OAAO,SAAS,YAAY,+BAA+B,IAAI,IAAI,GAAG;AACxE,WAAO;AAAA,EACT;AACA,QAAM,UAAW,IAA6B;AAC9C,MAAI,OAAO,YAAY,YAAY,8BAA8B,KAAK,CAAC,YAAY,QAAQ,KAAK,OAAO,CAAC,GAAG;AACzG,WAAO;AAAA,EACT;AACA,SAAO;AACT;",
4
+ "sourcesContent": ["/**\n * Detect a Postgres unique-constraint violation (SQLSTATE 23505) regardless of\n * the ORM/driver layer that surfaces it. Shared across modules so duplicate-insert\n * handling stays consistent platform-wide.\n */\nexport function isUniqueViolation(err: unknown): boolean {\n if (!err || typeof err !== 'object') return false\n const code = (err as { code?: string }).code\n if (code === '23505') return true // Postgres unique_violation\n const message = (err as { message?: string }).message\n return typeof message === 'string' && /duplicate key value|unique constraint/i.test(message)\n}\n\nconst FOREIGN_KEY_VIOLATION_MESSAGE = /violates foreign key constraint(?: \"([^\"]+)\")?/i\n\nconst MAX_ERROR_CHAIN_DEPTH = 4\n\n/**\n * MikroORM wraps driver errors and copies the pg fields onto the wrapper, but\n * the original error may also sit behind `cause` (Node) or `previous`\n * (MikroORM), possibly re-wrapped by a transaction helper. Walk that chain,\n * breadth-first with a small depth cap, so a check works on any layer.\n */\nfunction pgErrorCandidates(err: unknown): Array<Record<string, unknown>> {\n const found: Array<Record<string, unknown>> = []\n const seen = new Set<unknown>()\n let layer: unknown[] = [err]\n for (let depth = 0; depth < MAX_ERROR_CHAIN_DEPTH && layer.length > 0; depth += 1) {\n const next: unknown[] = []\n for (const candidate of layer) {\n if (!candidate || typeof candidate !== 'object' || seen.has(candidate)) continue\n seen.add(candidate)\n const record = candidate as Record<string, unknown>\n found.push(record)\n next.push(record.cause, record.previous)\n }\n layer = next\n }\n return found\n}\n\n/**\n * Detect a Postgres foreign-key violation (SQLSTATE 23503): the row is still\n * referenced by a dependent table, or the payload references a parent that\n * does not exist. Looks through MikroORM's driver-error wrapping.\n */\nexport function isForeignKeyViolation(err: unknown): boolean {\n return pgErrorCandidates(err).some((candidate) => {\n if (candidate.code === '23503') return true // Postgres foreign_key_violation\n return typeof candidate.message === 'string' && FOREIGN_KEY_VIOLATION_MESSAGE.test(candidate.message)\n })\n}\n\n/**\n * Name of the constraint behind a foreign-key violation, read from the pg\n * `constraint` field on any layer of the wrapper chain, or parsed out of the\n * quoted constraint in the driver message when the field is missing.\n */\nexport function getForeignKeyViolationConstraint(err: unknown): string | null {\n for (const candidate of pgErrorCandidates(err)) {\n if (typeof candidate.constraint === 'string' && candidate.constraint.length > 0) return candidate.constraint\n }\n for (const candidate of pgErrorCandidates(err)) {\n if (typeof candidate.message !== 'string') continue\n const match = FOREIGN_KEY_VIOLATION_MESSAGE.exec(candidate.message)\n if (match?.[1]) return match[1]\n }\n return null\n}\n\n/**\n * A Postgres SQLSTATE is always exactly 5 characters from `[0-9A-Z]`, and every\n * class in the standard SQLSTATE table carries at least one digit. Node system\n * errors (`ECONNREFUSED`), Node internal errors (`ERR_INVALID_ARG_TYPE`), and the\n * five-letter Node errno codes (`EPIPE`, `EPERM`, `EBUSY`) would otherwise be\n * misread as a SQLSTATE, since they also populate a string `code` field; the\n * digit requirement rules those out without excluding any real SQLSTATE.\n */\nconst SQLSTATE_PATTERN = /^(?=.*[0-9])[0-9A-Z]{5}$/\n\n/**\n * The Postgres SQLSTATE behind an error, read from the pg `code` field on any\n * layer of the driver-error wrapper chain (see `pgErrorCandidates`). Returns\n * `null` when no layer carries a `code` matching the SQLSTATE shape, which\n * also covers non-Postgres errors whose `code` is a Node error code.\n */\nexport function readPgSqlState(err: unknown): string | null {\n for (const candidate of pgErrorCandidates(err)) {\n if (typeof candidate.code === 'string' && SQLSTATE_PATTERN.test(candidate.code)) return candidate.code\n }\n return null\n}\n\n/**\n * Postgres SQLSTATEs for transient connection / availability failures \u2014 the\n * database (or its connection pool) is temporarily unreachable and the request\n * can succeed on retry. Deliberately scoped to connection/availability codes;\n * query-level conflicts (deadlock 40P01, serialization 40001, lock_not_available\n * 55P03) are NOT included because they do not mean \"service unavailable\".\n */\nconst TRANSIENT_CONNECTION_SQLSTATES = new Set([\n '53300', // too_many_connections\n '53400', // configuration_limit_exceeded\n '57P01', // admin_shutdown\n '57P02', // crash_shutdown\n '57P03', // cannot_connect_now (db starting up)\n '08000', // connection_exception\n '08001', // sqlclient_unable_to_establish_sqlconnection\n '08003', // connection_does_not_exist\n '08006', // connection_failure\n])\n\n/**\n * Postgres-driver / connection-pool messages that describe a transient DB\n * connection failure when the SQLSTATE is dropped by the ORM wrapper. These are\n * intentionally DB-specific phrases only. Bare socket codes (ECONNREFUSED,\n * ETIMEDOUT, \u2026) are deliberately NOT matched: they can originate from any\n * outbound socket (HTTP, cache, queue), so keying off them would falsely\n * attribute unrelated failures to the database.\n */\nconst TRANSIENT_DB_MESSAGE_PATTERNS = [\n /too many clients already/i,\n /unable to acquire a connection/i,\n /timeout acquiring a connection/i,\n /connection terminated/i,\n /the database system is (starting up|shutting down|in recovery)/i,\n]\n\n/**\n * Detect a transient Postgres connection / availability failure (pool exhaustion,\n * `max_connections` reached, DB restarting) via its SQLSTATE or a DB-specific\n * driver message. Gates retryable 503 responses so callers do not report a\n * temporary infrastructure blip as an auth failure (401) or an unexpected server\n * error (500). Scoped to unambiguous DB signals so generic socket errors from\n * non-DB calls are never misclassified.\n */\nexport function isTransientDbError(err: unknown): boolean {\n if (!err || typeof err !== 'object') {\n return false\n }\n const code = (err as { code?: string }).code\n if (typeof code === 'string' && TRANSIENT_CONNECTION_SQLSTATES.has(code)) {\n return true\n }\n const message = (err as { message?: string }).message\n if (typeof message === 'string' && TRANSIENT_DB_MESSAGE_PATTERNS.some((pattern) => pattern.test(message))) {\n return true\n }\n return false\n}\n"],
5
+ "mappings": "AAKO,SAAS,kBAAkB,KAAuB;AACvD,MAAI,CAAC,OAAO,OAAO,QAAQ,SAAU,QAAO;AAC5C,QAAM,OAAQ,IAA0B;AACxC,MAAI,SAAS,QAAS,QAAO;AAC7B,QAAM,UAAW,IAA6B;AAC9C,SAAO,OAAO,YAAY,YAAY,yCAAyC,KAAK,OAAO;AAC7F;AAEA,MAAM,gCAAgC;AAEtC,MAAM,wBAAwB;AAQ9B,SAAS,kBAAkB,KAA8C;AACvE,QAAM,QAAwC,CAAC;AAC/C,QAAM,OAAO,oBAAI,IAAa;AAC9B,MAAI,QAAmB,CAAC,GAAG;AAC3B,WAAS,QAAQ,GAAG,QAAQ,yBAAyB,MAAM,SAAS,GAAG,SAAS,GAAG;AACjF,UAAM,OAAkB,CAAC;AACzB,eAAW,aAAa,OAAO;AAC7B,UAAI,CAAC,aAAa,OAAO,cAAc,YAAY,KAAK,IAAI,SAAS,EAAG;AACxE,WAAK,IAAI,SAAS;AAClB,YAAM,SAAS;AACf,YAAM,KAAK,MAAM;AACjB,WAAK,KAAK,OAAO,OAAO,OAAO,QAAQ;AAAA,IACzC;AACA,YAAQ;AAAA,EACV;AACA,SAAO;AACT;AAOO,SAAS,sBAAsB,KAAuB;AAC3D,SAAO,kBAAkB,GAAG,EAAE,KAAK,CAAC,cAAc;AAChD,QAAI,UAAU,SAAS,QAAS,QAAO;AACvC,WAAO,OAAO,UAAU,YAAY,YAAY,8BAA8B,KAAK,UAAU,OAAO;AAAA,EACtG,CAAC;AACH;AAOO,SAAS,iCAAiC,KAA6B;AAC5E,aAAW,aAAa,kBAAkB,GAAG,GAAG;AAC9C,QAAI,OAAO,UAAU,eAAe,YAAY,UAAU,WAAW,SAAS,EAAG,QAAO,UAAU;AAAA,EACpG;AACA,aAAW,aAAa,kBAAkB,GAAG,GAAG;AAC9C,QAAI,OAAO,UAAU,YAAY,SAAU;AAC3C,UAAM,QAAQ,8BAA8B,KAAK,UAAU,OAAO;AAClE,QAAI,QAAQ,CAAC,EAAG,QAAO,MAAM,CAAC;AAAA,EAChC;AACA,SAAO;AACT;AAUA,MAAM,mBAAmB;AAQlB,SAAS,eAAe,KAA6B;AAC1D,aAAW,aAAa,kBAAkB,GAAG,GAAG;AAC9C,QAAI,OAAO,UAAU,SAAS,YAAY,iBAAiB,KAAK,UAAU,IAAI,EAAG,QAAO,UAAU;AAAA,EACpG;AACA,SAAO;AACT;AASA,MAAM,iCAAiC,oBAAI,IAAI;AAAA,EAC7C;AAAA;AAAA,EACA;AAAA;AAAA,EACA;AAAA;AAAA,EACA;AAAA;AAAA,EACA;AAAA;AAAA,EACA;AAAA;AAAA,EACA;AAAA;AAAA,EACA;AAAA;AAAA,EACA;AAAA;AACF,CAAC;AAUD,MAAM,gCAAgC;AAAA,EACpC;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AACF;AAUO,SAAS,mBAAmB,KAAuB;AACxD,MAAI,CAAC,OAAO,OAAO,QAAQ,UAAU;AACnC,WAAO;AAAA,EACT;AACA,QAAM,OAAQ,IAA0B;AACxC,MAAI,OAAO,SAAS,YAAY,+BAA+B,IAAI,IAAI,GAAG;AACxE,WAAO;AAAA,EACT;AACA,QAAM,UAAW,IAA6B;AAC9C,MAAI,OAAO,YAAY,YAAY,8BAA8B,KAAK,CAAC,YAAY,QAAQ,KAAK,OAAO,CAAC,GAAG;AACzG,WAAO;AAAA,EACT;AACA,SAAO;AACT;",
6
6
  "names": []
7
7
  }
@@ -50,6 +50,19 @@ function encryptWithAesGcm(value, dekBase64) {
50
50
  logDebug("encrypt", { length: ciphertext.length });
51
51
  return { value: payload, raw: payload, version: "v1" };
52
52
  }
53
+ const AES_GCM_IV_BYTES = 12;
54
+ const AES_GCM_TAG_BYTES = 16;
55
+ function isEncryptedPayloadShape(value) {
56
+ if (typeof value !== "string") return false;
57
+ const parts = value.split(":");
58
+ if (parts.length !== 4 || parts[3] !== "v1") return false;
59
+ const [ivB64, ciphertextB64, tagB64] = parts;
60
+ try {
61
+ return Buffer.from(ivB64, "base64").length === AES_GCM_IV_BYTES && Buffer.from(tagB64, "base64").length === AES_GCM_TAG_BYTES && Buffer.from(ciphertextB64, "base64").length > 0;
62
+ } catch {
63
+ return false;
64
+ }
65
+ }
53
66
  function runAesGcmDecrypt(dek, iv, ciphertext, tag) {
54
67
  const decipher = crypto.createDecipheriv("aes-256-gcm", dek, iv);
55
68
  decipher.setAuthTag(tag);
@@ -153,6 +166,7 @@ export {
153
166
  encryptWithAesGcm,
154
167
  generateDek,
155
168
  hashForLookup,
169
+ isEncryptedPayloadShape,
156
170
  legacyHashForLookup,
157
171
  looksLikeEncryptedPayload,
158
172
  lookupHashCandidates
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../src/lib/encryption/aes.ts"],
4
- "sourcesContent": ["import crypto from 'node:crypto'\nimport { isEncryptionDebugEnabled } from './toggles'\nimport { createLogger } from '../logger'\n\nconst logger = createLogger('shared').child({ component: 'encryption' })\n\nexport type EncryptionPayload = {\n value: string | null\n raw: string\n version: string\n}\n\nexport enum TenantDataEncryptionErrorCode {\n AUTH_FAILED = 'AUTH_FAILED',\n MALFORMED_PAYLOAD = 'MALFORMED_PAYLOAD',\n KMS_UNAVAILABLE = 'KMS_UNAVAILABLE',\n WRONG_KEY = 'WRONG_KEY',\n DECRYPT_INTERNAL = 'DECRYPT_INTERNAL',\n}\n\nexport class TenantDataEncryptionError extends Error {\n code: TenantDataEncryptionErrorCode\n constructor(code: TenantDataEncryptionErrorCode, message: string) {\n super(message)\n this.name = 'TenantDataEncryptionError'\n this.code = code\n }\n}\n\nconst BASE64_PART = /^[A-Za-z0-9+/]+={0,2}$/\n\n/**\n * Keyless structural check for the `base64(iv):base64(ciphertext):base64(tag):v1` envelope\n * {@link encryptWithAesGcm} emits.\n *\n * Answers \"is this column holding ciphertext?\" without a DEK, which is the only question\n * available once encryption has been switched off \u2014 the KMS is a noop by then, so\n * {@link decryptWithAesGcm} cannot distinguish ciphertext from plaintext. A 12-byte IV and a\n * 16-byte tag encode to exactly 16 and 24 base64 characters, so the shape is specific enough\n * that plaintext colliding with it by accident is not a practical concern.\n *\n * Deliberate collision is, though: writing `<16 b64>:<b64>:<24 b64>:v1` is trivial, and\n * `TenantDataEncryptionService` dropped a structural check of exactly this shape for that\n * reason (#2720). So this is only safe on values the SERVER wrote \u2014 never as a test applied to\n * attacker-supplied input while a DEK is reachable, where `isEncryptedWithDek` is the test to use.\n * Callers that must run it over user-controlled data are responsible for confirming first that no\n * DEK is reachable, which is what makes forgery pointless: there is nothing to impersonate.\n */\nexport function looksLikeEncryptedPayload(value: unknown): boolean {\n if (typeof value !== 'string') return false\n const parts = value.split(':')\n if (parts.length !== 4 || parts[3] !== 'v1') return false\n const [iv, ciphertext, tag] = parts as [string, string, string, string]\n return iv.length === 16\n && tag.length === 24\n && ciphertext.length > 0\n && BASE64_PART.test(iv)\n && BASE64_PART.test(ciphertext)\n && BASE64_PART.test(tag)\n}\n\nexport function generateDek(): string {\n return crypto.randomBytes(32).toString('base64')\n}\n\nfunction logDebug(event: string, payload: Record<string, unknown>) {\n if (!isEncryptionDebugEnabled()) return\n try {\n logger.debug(event, payload)\n } catch {\n // ignore\n }\n}\n\nexport function encryptWithAesGcm(value: string, dekBase64: string): EncryptionPayload {\n const dek = Buffer.from(dekBase64, 'base64')\n const iv = crypto.randomBytes(12)\n const cipher = crypto.createCipheriv('aes-256-gcm', dek, iv)\n const ciphertext = Buffer.concat([cipher.update(value, 'utf8'), cipher.final()])\n const tag = cipher.getAuthTag()\n const payload = [\n iv.toString('base64'),\n ciphertext.toString('base64'),\n tag.toString('base64'),\n 'v1',\n ].join(':')\n logDebug('encrypt', { length: ciphertext.length })\n return { value: payload, raw: payload, version: 'v1' }\n}\n\nfunction runAesGcmDecrypt(dek: Buffer, iv: Buffer, ciphertext: Buffer, tag: Buffer): string {\n const decipher = crypto.createDecipheriv('aes-256-gcm', dek, iv)\n decipher.setAuthTag(tag)\n return Buffer.concat([decipher.update(ciphertext), decipher.final()]).toString('utf8')\n}\n\nexport function decryptWithAesGcm(payload: string, dekBase64: string): string | null {\n if (!payload) return null\n const parts = payload.split(':')\n if (parts.length !== 4) return null\n const [ivB64, ciphertextB64, tagB64, version] = parts\n if (version !== 'v1') return null\n const dek = Buffer.from(dekBase64, 'base64')\n const iv = Buffer.from(ivB64, 'base64')\n const ciphertext = Buffer.from(ciphertextB64, 'base64')\n const tag = Buffer.from(tagB64, 'base64')\n try {\n const result = runAesGcmDecrypt(dek, iv, ciphertext, tag)\n logDebug('decrypt', { iv: ivB64, tag: tagB64 })\n return result\n } catch (err) {\n logDebug('decrypt_error', { message: (err as Error)?.message || String(err) })\n return null\n }\n}\n\nconst LOOKUP_HASH_V2_PREFIX = 'v2:'\n\nfunction normalizeLookupValue(value: string): string {\n return value.toLowerCase().trim()\n}\n\n/**\n * Legacy, unkeyed lookup digest (`sha256(lower(trim(value)))`).\n *\n * @deprecated Unkeyed digests are vulnerable to offline rainbow-table attacks and\n * cross-installation correlation (issue #2718). New writes use {@link hashForLookup},\n * which emits a keyed `v2:` HMAC when a lookup pepper is configured. This helper is\n * retained only so existing `*_hash` columns written before the keyed format can still\n * be matched (see {@link lookupHashCandidates}) until a backfill migration recomputes them.\n */\nexport function legacyHashForLookup(value: string): string {\n return crypto.createHash('sha256').update(normalizeLookupValue(value)).digest('hex')\n}\n\n/**\n * Resolve the installation-wide lookup pepper used to key lookup hashes.\n *\n * Order of precedence (never `AUTH_SECRET`, per issue #2718):\n * 1. `LOOKUP_HASH_PEPPER` \u2014 dedicated secret for lookup hashing\n * 2. `TENANT_DATA_ENCRYPTION_FALLBACK_KEY` \u2014 existing encryption fallback secret\n * 3. `TENANT_DATA_ENCRYPTION_KEY` \u2014 existing encryption secret\n *\n * Returns `null` when no secret is configured, in which case {@link hashForLookup}\n * falls back to the legacy unkeyed digest so deployments without any configured key\n * keep working unchanged.\n */\nfunction resolveLookupPepper(): string | null {\n const candidates = [\n process.env.LOOKUP_HASH_PEPPER,\n process.env.TENANT_DATA_ENCRYPTION_FALLBACK_KEY,\n process.env.TENANT_DATA_ENCRYPTION_KEY,\n ]\n for (const candidate of candidates) {\n if (typeof candidate !== 'string') continue\n const normalized = candidate.trim().replace(/(?:^['\"]|['\"]$)/g, '')\n if (normalized) return normalized\n }\n return null\n}\n\n/**\n * Compute a deterministic lookup hash for a low-entropy PII value (email, phone, \u2026).\n *\n * When a lookup pepper is configured the result is a keyed HMAC-SHA-256 prefixed with\n * `v2:` and bound to the optional `context` (entity/field) so digests are not portable\n * across columns, installations, or tenants without the secret. When no pepper is\n * configured it falls back to the legacy unkeyed digest for backward compatibility.\n *\n * The `context` MUST be supplied identically on both the write and the read side for a\n * given column; callers that do not pass one stay mutually consistent.\n */\nexport function hashForLookup(value: string, context?: string): string {\n const pepper = resolveLookupPepper()\n const normalized = normalizeLookupValue(value)\n if (!pepper) {\n return legacyHashForLookup(value)\n }\n const message = context ? `${context}:${normalized}` : normalized\n const digest = crypto.createHmac('sha256', pepper).update(message).digest('hex')\n return `${LOOKUP_HASH_V2_PREFIX}${digest}`\n}\n\n/**\n * Candidate lookup hashes for matching a value against `*_hash` columns that may hold\n * either the new keyed (`v2:`) digest or a legacy unkeyed digest. Use this in `$in` /\n * `IN (...)` filters during the migration window so reads keep matching rows written\n * before the keyed format. Once a backfill has recomputed all columns this can collapse\n * back to a single {@link hashForLookup} value.\n */\nexport function lookupHashCandidates(value: string, context?: string): string[] {\n const primary = hashForLookup(value, context)\n const legacy = legacyHashForLookup(value)\n return primary === legacy ? [primary] : [primary, legacy]\n}\n\n/**\n * Strict variant of decryptWithAesGcm that throws typed TenantDataEncryptionError.\n * - Format mismatch (not iv:ct:tag:v1): throws AUTH_FAILED (treat as plaintext).\n * - Valid format but invalid buffer sizes (bad base64): throws MALFORMED_PAYLOAD.\n * - AES-GCM auth tag failure: throws AUTH_FAILED.\n * - Unexpected crypto error: throws DECRYPT_INTERNAL.\n */\nexport function decryptWithAesGcmStrict(payload: string, dekBase64: string): string {\n const parts = payload.split(':')\n if (parts.length !== 4 || parts[3] !== 'v1') {\n throw new TenantDataEncryptionError(\n TenantDataEncryptionErrorCode.AUTH_FAILED,\n 'Value is not an encrypted payload (format mismatch)',\n )\n }\n const [ivB64, ciphertextB64, tagB64] = parts as [string, string, string, string]\n let dek: Buffer, iv: Buffer, ciphertext: Buffer, tag: Buffer\n try {\n dek = Buffer.from(dekBase64, 'base64')\n iv = Buffer.from(ivB64, 'base64')\n ciphertext = Buffer.from(ciphertextB64, 'base64')\n tag = Buffer.from(tagB64, 'base64')\n } catch {\n throw new TenantDataEncryptionError(\n TenantDataEncryptionErrorCode.MALFORMED_PAYLOAD,\n 'Failed to decode base64 components',\n )\n }\n if (iv.length !== 12 || tag.length !== 16 || ciphertext.length === 0) {\n throw new TenantDataEncryptionError(\n TenantDataEncryptionErrorCode.MALFORMED_PAYLOAD,\n 'Invalid AES-GCM payload: unexpected IV, tag, or ciphertext size',\n )\n }\n try {\n return runAesGcmDecrypt(dek, iv, ciphertext, tag)\n } catch {\n throw new TenantDataEncryptionError(\n TenantDataEncryptionErrorCode.AUTH_FAILED,\n 'AES-GCM authentication tag verification failed',\n )\n }\n}\n"],
5
- "mappings": "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",
4
+ "sourcesContent": ["import crypto from 'node:crypto'\nimport { isEncryptionDebugEnabled } from './toggles'\nimport { createLogger } from '../logger'\n\nconst logger = createLogger('shared').child({ component: 'encryption' })\n\nexport type EncryptionPayload = {\n value: string | null\n raw: string\n version: string\n}\n\nexport enum TenantDataEncryptionErrorCode {\n AUTH_FAILED = 'AUTH_FAILED',\n MALFORMED_PAYLOAD = 'MALFORMED_PAYLOAD',\n KMS_UNAVAILABLE = 'KMS_UNAVAILABLE',\n WRONG_KEY = 'WRONG_KEY',\n DECRYPT_INTERNAL = 'DECRYPT_INTERNAL',\n}\n\nexport class TenantDataEncryptionError extends Error {\n code: TenantDataEncryptionErrorCode\n constructor(code: TenantDataEncryptionErrorCode, message: string) {\n super(message)\n this.name = 'TenantDataEncryptionError'\n this.code = code\n }\n}\n\nconst BASE64_PART = /^[A-Za-z0-9+/]+={0,2}$/\n\n/**\n * Keyless structural check for the `base64(iv):base64(ciphertext):base64(tag):v1` envelope\n * {@link encryptWithAesGcm} emits.\n *\n * Answers \"is this column holding ciphertext?\" without a DEK, which is the only question\n * available once encryption has been switched off \u2014 the KMS is a noop by then, so\n * {@link decryptWithAesGcm} cannot distinguish ciphertext from plaintext. A 12-byte IV and a\n * 16-byte tag encode to exactly 16 and 24 base64 characters, so the shape is specific enough\n * that plaintext colliding with it by accident is not a practical concern.\n *\n * Deliberate collision is, though: writing `<16 b64>:<b64>:<24 b64>:v1` is trivial, and\n * `TenantDataEncryptionService` dropped a structural check of exactly this shape for that\n * reason (#2720). So this is only safe on values the SERVER wrote \u2014 never as a test applied to\n * attacker-supplied input while a DEK is reachable, where `isEncryptedWithDek` is the test to use.\n * Callers that must run it over user-controlled data are responsible for confirming first that no\n * DEK is reachable, which is what makes forgery pointless: there is nothing to impersonate.\n *\n * See also {@link isEncryptedPayloadShape}, which answers the stricter \"would the decrypt path\n * accept this?\" question by decoded byte length instead of encoded character length. The two\n * agree on anything the server actually wrote; use this one for offline detection with no DEK\n * reachable, and that one to predict whether a decrypt attempt will succeed.\n */\nexport function looksLikeEncryptedPayload(value: unknown): boolean {\n if (typeof value !== 'string') return false\n const parts = value.split(':')\n if (parts.length !== 4 || parts[3] !== 'v1') return false\n const [iv, ciphertext, tag] = parts as [string, string, string, string]\n return iv.length === 16\n && tag.length === 24\n && ciphertext.length > 0\n && BASE64_PART.test(iv)\n && BASE64_PART.test(ciphertext)\n && BASE64_PART.test(tag)\n}\n\nexport function generateDek(): string {\n return crypto.randomBytes(32).toString('base64')\n}\n\nfunction logDebug(event: string, payload: Record<string, unknown>) {\n if (!isEncryptionDebugEnabled()) return\n try {\n logger.debug(event, payload)\n } catch {\n // ignore\n }\n}\n\nexport function encryptWithAesGcm(value: string, dekBase64: string): EncryptionPayload {\n const dek = Buffer.from(dekBase64, 'base64')\n const iv = crypto.randomBytes(12)\n const cipher = crypto.createCipheriv('aes-256-gcm', dek, iv)\n const ciphertext = Buffer.concat([cipher.update(value, 'utf8'), cipher.final()])\n const tag = cipher.getAuthTag()\n const payload = [\n iv.toString('base64'),\n ciphertext.toString('base64'),\n tag.toString('base64'),\n 'v1',\n ].join(':')\n logDebug('encrypt', { length: ciphertext.length })\n return { value: payload, raw: payload, version: 'v1' }\n}\n\nconst AES_GCM_IV_BYTES = 12\nconst AES_GCM_TAG_BYTES = 16\n\n/**\n * Reports whether a value is a **structurally** well-formed `<iv>:<ct>:<tag>:v1` envelope \u2014\n * the same shape validation {@link decryptWithAesGcmStrict} performs before it attempts a\n * decrypt, without needing a key.\n *\n * This is deliberately NOT an \"is this encrypted\" oracle: the shape is forgeable, so a caller\n * deciding whether a value is already sealed MUST still bind that decision to a successful\n * authenticated decrypt (issue #2720). Its purpose is to separate \"ciphertext we cannot open\n * with the key we hold\" from \"plaintext that happens to contain colons\", so a write path can\n * fail loudly on the former instead of silently wrapping it a second time (issue #5951).\n *\n * The byte-length checks matter: `Buffer.from(value, 'base64')` is lenient, so a loose\n * four-segment check matches strings like `aaaa:bbbb:cccc:v1` that no AES-GCM payload could be.\n *\n * See also {@link looksLikeEncryptedPayload}, the pre-existing looser check by encoded character\n * length. Use that one when no DEK is reachable at all (it needs none); use this one whenever the\n * question is \"will the encrypt/decrypt path accept this?\", since it matches the same byte-length\n * validation {@link decryptWithAesGcmStrict} performs.\n */\nexport function isEncryptedPayloadShape(value: unknown): value is string {\n if (typeof value !== 'string') return false\n const parts = value.split(':')\n if (parts.length !== 4 || parts[3] !== 'v1') return false\n const [ivB64, ciphertextB64, tagB64] = parts as [string, string, string, string]\n try {\n return (\n Buffer.from(ivB64, 'base64').length === AES_GCM_IV_BYTES\n && Buffer.from(tagB64, 'base64').length === AES_GCM_TAG_BYTES\n && Buffer.from(ciphertextB64, 'base64').length > 0\n )\n } catch {\n return false\n }\n}\n\nfunction runAesGcmDecrypt(dek: Buffer, iv: Buffer, ciphertext: Buffer, tag: Buffer): string {\n const decipher = crypto.createDecipheriv('aes-256-gcm', dek, iv)\n decipher.setAuthTag(tag)\n return Buffer.concat([decipher.update(ciphertext), decipher.final()]).toString('utf8')\n}\n\nexport function decryptWithAesGcm(payload: string, dekBase64: string): string | null {\n if (!payload) return null\n const parts = payload.split(':')\n if (parts.length !== 4) return null\n const [ivB64, ciphertextB64, tagB64, version] = parts\n if (version !== 'v1') return null\n const dek = Buffer.from(dekBase64, 'base64')\n const iv = Buffer.from(ivB64, 'base64')\n const ciphertext = Buffer.from(ciphertextB64, 'base64')\n const tag = Buffer.from(tagB64, 'base64')\n try {\n const result = runAesGcmDecrypt(dek, iv, ciphertext, tag)\n logDebug('decrypt', { iv: ivB64, tag: tagB64 })\n return result\n } catch (err) {\n logDebug('decrypt_error', { message: (err as Error)?.message || String(err) })\n return null\n }\n}\n\nconst LOOKUP_HASH_V2_PREFIX = 'v2:'\n\nfunction normalizeLookupValue(value: string): string {\n return value.toLowerCase().trim()\n}\n\n/**\n * Legacy, unkeyed lookup digest (`sha256(lower(trim(value)))`).\n *\n * @deprecated Unkeyed digests are vulnerable to offline rainbow-table attacks and\n * cross-installation correlation (issue #2718). New writes use {@link hashForLookup},\n * which emits a keyed `v2:` HMAC when a lookup pepper is configured. This helper is\n * retained only so existing `*_hash` columns written before the keyed format can still\n * be matched (see {@link lookupHashCandidates}) until a backfill migration recomputes them.\n */\nexport function legacyHashForLookup(value: string): string {\n return crypto.createHash('sha256').update(normalizeLookupValue(value)).digest('hex')\n}\n\n/**\n * Resolve the installation-wide lookup pepper used to key lookup hashes.\n *\n * Order of precedence (never `AUTH_SECRET`, per issue #2718):\n * 1. `LOOKUP_HASH_PEPPER` \u2014 dedicated secret for lookup hashing\n * 2. `TENANT_DATA_ENCRYPTION_FALLBACK_KEY` \u2014 existing encryption fallback secret\n * 3. `TENANT_DATA_ENCRYPTION_KEY` \u2014 existing encryption secret\n *\n * Returns `null` when no secret is configured, in which case {@link hashForLookup}\n * falls back to the legacy unkeyed digest so deployments without any configured key\n * keep working unchanged.\n */\nfunction resolveLookupPepper(): string | null {\n const candidates = [\n process.env.LOOKUP_HASH_PEPPER,\n process.env.TENANT_DATA_ENCRYPTION_FALLBACK_KEY,\n process.env.TENANT_DATA_ENCRYPTION_KEY,\n ]\n for (const candidate of candidates) {\n if (typeof candidate !== 'string') continue\n const normalized = candidate.trim().replace(/(?:^['\"]|['\"]$)/g, '')\n if (normalized) return normalized\n }\n return null\n}\n\n/**\n * Compute a deterministic lookup hash for a low-entropy PII value (email, phone, \u2026).\n *\n * When a lookup pepper is configured the result is a keyed HMAC-SHA-256 prefixed with\n * `v2:` and bound to the optional `context` (entity/field) so digests are not portable\n * across columns, installations, or tenants without the secret. When no pepper is\n * configured it falls back to the legacy unkeyed digest for backward compatibility.\n *\n * The `context` MUST be supplied identically on both the write and the read side for a\n * given column; callers that do not pass one stay mutually consistent.\n */\nexport function hashForLookup(value: string, context?: string): string {\n const pepper = resolveLookupPepper()\n const normalized = normalizeLookupValue(value)\n if (!pepper) {\n return legacyHashForLookup(value)\n }\n const message = context ? `${context}:${normalized}` : normalized\n const digest = crypto.createHmac('sha256', pepper).update(message).digest('hex')\n return `${LOOKUP_HASH_V2_PREFIX}${digest}`\n}\n\n/**\n * Candidate lookup hashes for matching a value against `*_hash` columns that may hold\n * either the new keyed (`v2:`) digest or a legacy unkeyed digest. Use this in `$in` /\n * `IN (...)` filters during the migration window so reads keep matching rows written\n * before the keyed format. Once a backfill has recomputed all columns this can collapse\n * back to a single {@link hashForLookup} value.\n */\nexport function lookupHashCandidates(value: string, context?: string): string[] {\n const primary = hashForLookup(value, context)\n const legacy = legacyHashForLookup(value)\n return primary === legacy ? [primary] : [primary, legacy]\n}\n\n/**\n * Strict variant of decryptWithAesGcm that throws typed TenantDataEncryptionError.\n * - Format mismatch (not iv:ct:tag:v1): throws AUTH_FAILED (treat as plaintext).\n * - Valid format but invalid buffer sizes (bad base64): throws MALFORMED_PAYLOAD.\n * - AES-GCM auth tag failure: throws AUTH_FAILED.\n * - Unexpected crypto error: throws DECRYPT_INTERNAL.\n */\nexport function decryptWithAesGcmStrict(payload: string, dekBase64: string): string {\n const parts = payload.split(':')\n if (parts.length !== 4 || parts[3] !== 'v1') {\n throw new TenantDataEncryptionError(\n TenantDataEncryptionErrorCode.AUTH_FAILED,\n 'Value is not an encrypted payload (format mismatch)',\n )\n }\n const [ivB64, ciphertextB64, tagB64] = parts as [string, string, string, string]\n let dek: Buffer, iv: Buffer, ciphertext: Buffer, tag: Buffer\n try {\n dek = Buffer.from(dekBase64, 'base64')\n iv = Buffer.from(ivB64, 'base64')\n ciphertext = Buffer.from(ciphertextB64, 'base64')\n tag = Buffer.from(tagB64, 'base64')\n } catch {\n throw new TenantDataEncryptionError(\n TenantDataEncryptionErrorCode.MALFORMED_PAYLOAD,\n 'Failed to decode base64 components',\n )\n }\n if (iv.length !== 12 || tag.length !== 16 || ciphertext.length === 0) {\n throw new TenantDataEncryptionError(\n TenantDataEncryptionErrorCode.MALFORMED_PAYLOAD,\n 'Invalid AES-GCM payload: unexpected IV, tag, or ciphertext size',\n )\n }\n try {\n return runAesGcmDecrypt(dek, iv, ciphertext, tag)\n } catch {\n throw new TenantDataEncryptionError(\n TenantDataEncryptionErrorCode.AUTH_FAILED,\n 'AES-GCM authentication tag verification failed',\n )\n }\n}\n"],
5
+ "mappings": "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",
6
6
  "names": ["TenantDataEncryptionErrorCode"]
7
7
  }
@@ -1,4 +1,11 @@
1
- import { decryptWithAesGcm, encryptWithAesGcm, hashForLookup } from "./aes.js";
1
+ import {
2
+ TenantDataEncryptionError,
3
+ TenantDataEncryptionErrorCode,
4
+ decryptWithAesGcm,
5
+ encryptWithAesGcm,
6
+ hashForLookup,
7
+ isEncryptedPayloadShape
8
+ } from "./aes.js";
2
9
  import { createKmsService } from "./kms.js";
3
10
  import { isTenantDataEncryptionEnabled, isEncryptionDebugEnabled } from "./toggles.js";
4
11
  import { createLogger } from "../logger/index.js";
@@ -49,6 +56,14 @@ function isEncryptedWithDek(value, dek) {
49
56
  if (parts.length !== 4 || parts[3] !== "v1") return false;
50
57
  return decryptWithAesGcm(value, dek.key) !== null;
51
58
  }
59
+ function assertNotSealedUnderAnotherKey(value, field) {
60
+ if (!isEncryptedPayloadShape(value)) return;
61
+ logger.error("Refusing to re-encrypt a value sealed under a different key", { field });
62
+ throw new TenantDataEncryptionError(
63
+ TenantDataEncryptionErrorCode.WRONG_KEY,
64
+ `[internal] Field "${field}" already holds an encrypted payload that does not decrypt under the current tenant DEK. Encrypting it again would produce an unreadable nested envelope. Complete the key rotation for this tenant (mercato entities rotate-encryption-key --old-key \u2026) or restore the DEK that sealed it before writing this record again.`
65
+ );
66
+ }
52
67
  function normalizeEncryptedFieldRules(fields) {
53
68
  if (!Array.isArray(fields)) return [];
54
69
  const rules = [];
@@ -417,6 +432,7 @@ class TenantDataEncryptionService {
417
432
  const value = clone[key];
418
433
  if (value === null || value === void 0) continue;
419
434
  if (isEncryptedWithDek(value, dek)) continue;
435
+ assertNotSealedUnderAnotherKey(value, rule.field);
420
436
  const serialized = typeof value === "string" ? value : JSON.stringify(value);
421
437
  const payload = encryptWithAesGcm(serialized, dek.key);
422
438
  clone[key] = payload.value;
@@ -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 { createLogger } from '../logger'\nimport type { EncryptionKeyScope, ModuleEncryptionMap } from '../../modules/encryption'\n\nconst logger = createLogger('shared').child({ component: 'tenant-encryption' })\n\nexport type EncryptedFieldRule = {\n field: string\n hashField?: string | null\n}\n\nexport type EncryptionMapRecord = {\n entityId: string\n keyScope?: EncryptionKeyScope\n fields: EncryptedFieldRule[]\n}\n\ntype MapCacheKey = {\n entityId: string\n tenantId: string | null\n organizationId: string | null\n}\n\ntype SqlConnection = {\n execute(sql: string, params?: readonly unknown[]): Promise<unknown>\n}\n\nconst MAP_MISS_TTL_MS = 5 * 60 * 1000\n// Mirror the Vault KMS default DEK TTL so a rotated/revoked tenant key is picked\n// up by long-lived processes without a restart (#2746). The service-level cache\n// previously had no TTL and shadowed the KMS's own 15-minute expiry.\nconst DEK_CACHE_TTL_MS = 15 * 60 * 1000\n// Matches the `{ ttl: 300 }` passed to the `CacheStrategy` layer for the all-organizations\n// aggregate, so a process that only ever hits its own memory entry (no shared cache configured,\n// or already warm) still picks up a runtime map edit within the same bound (#6066).\nconst AGGREGATE_CACHE_TTL_MS = 300 * 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\n// Tag for the aggregate of every organization-scoped map of an entity/tenant. The prefix differs\n// from `cacheKey`'s in its first segment rather than by an extra one, so no entity id can spell a\n// tag that collides with an aggregate (`encmap:all-orgs:x:y` would be reachable both ways).\nfunction allOrganizationsCacheKey(entityId: string, tenantId: string | null): string {\n return ['encmap-all-orgs', entityId.toLowerCase(), tenantId ?? 'null'].join(':')\n}\n\nfunction debug(event: string, payload: Record<string, unknown>) {\n if (!isEncryptionDebugEnabled()) return\n try {\n logger.debug(event, 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\n/**\n * A value is only treated as \"already encrypted\" when it actually decrypts\n * under the tenant DEK \u2014 i.e. the AES-GCM authentication tag verifies. A purely\n * structural `<iv>:<ct>:<tag>:v1` shape check is forgeable: attacker-controlled\n * field values (e.g. their own profile email/phone) could impersonate ciphertext\n * to skip encryption-at-rest and the lookup hash entirely (issue #2720). Binding\n * the check to a successful authenticated decrypt makes forgery infeasible, so a\n * fake payload simply gets encrypted like any other plaintext.\n */\nfunction isEncryptedWithDek(value: unknown, dek: TenantDek): boolean {\n if (typeof value !== 'string') return false\n const parts = value.split(':')\n if (parts.length !== 4 || parts[3] !== 'v1') return false\n return decryptWithAesGcm(value, dek.key) !== null\n}\n\nfunction normalizeEncryptedFieldRules(\n fields: readonly { field?: unknown; hashField?: unknown }[] | null | undefined,\n): EncryptedFieldRule[] {\n if (!Array.isArray(fields)) return []\n const rules: EncryptedFieldRule[] = []\n for (const rule of fields) {\n if (!rule || typeof rule !== 'object') continue\n const field = rule.field\n if (typeof field !== 'string' || field.trim().length === 0) continue\n rules.push({ field, hashField: typeof rule.hashField === 'string' ? rule.hashField : null })\n }\n return rules\n}\n\nfunction normalizeEncryptedFieldNames(fields: readonly { field?: unknown }[] | null | undefined): string[] {\n return normalizeEncryptedFieldRules(fields).map((rule) => rule.field)\n}\n\n/**\n * Union of field rules, first declaration wins on the field name. A later duplicate only\n * contributes its `hashField` when the winning rule declares none, so merging an organization-scoped\n * map into a tenant-wide one never silently retargets an existing lookup-hash column.\n */\nfunction mergeEncryptedFieldRules(groups: readonly EncryptedFieldRule[][]): EncryptedFieldRule[] {\n const merged: EncryptedFieldRule[] = []\n const byField = new Map<string, EncryptedFieldRule>()\n for (const group of groups) {\n for (const rule of group) {\n const existing = byField.get(rule.field)\n if (!existing) {\n const copy: EncryptedFieldRule = { field: rule.field, hashField: rule.hashField ?? null }\n byField.set(rule.field, copy)\n merged.push(copy)\n continue\n }\n if (!existing.hashField && rule.hashField) existing.hashField = rule.hashField\n }\n }\n return merged\n}\n\nfunction readEncryptedFieldsJson(row: Record<string, unknown>): EncryptedFieldRule[] {\n const raw = row.fields_json ?? row.fieldsJson\n if (Array.isArray(raw)) return raw as EncryptedFieldRule[]\n if (typeof raw === 'string') {\n try {\n const parsed = JSON.parse(raw)\n return Array.isArray(parsed) ? parsed as EncryptedFieldRule[] : []\n } catch {\n return []\n }\n }\n return []\n}\n\n/**\n * The KMS key id an encryption map's payloads are sealed under: a system-scoped map\n * uses a per-entity key that exists before any tenant does, everything else uses the\n * tenant's own key. Exported so callers that need to probe key availability without\n * encrypting (the encryption CLIs) derive the same id instead of re-spelling the\n * `system:` convention (#5950).\n */\nexport function resolveEncryptionKeyId(\n entityId: string,\n keyScope: EncryptionKeyScope | undefined,\n tenantId: string | null | undefined\n): string | null {\n return keyScope === 'system' ? `system:${entityId}` : tenantId ?? null\n}\n\nfunction getSqlConnection(em: EntityManager): SqlConnection | null {\n const source = em as { getConnection?: () => unknown }\n const conn = source.getConnection?.()\n if (!conn || typeof conn !== 'object') return null\n const candidate = conn as { execute?: unknown }\n if (typeof candidate.execute !== 'function') return null\n return candidate as SqlConnection\n}\n\nexport class TenantDataEncryptionService {\n private static globalMemoryCache = new Map<string, EncryptionMapRecord>()\n private static globalAggregateMemoryCache = new Map<string, { at: number; record: EncryptionMapRecord }>()\n private static globalInflightMaps = new Map<string, Promise<EncryptionMapRecord | null>>()\n private static globalDekCache = new Map<string, TenantDek>()\n private static globalInflightDeks = new Map<string, Promise<TenantDek | null>>()\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 aggregateMemoryCache = TenantDataEncryptionService.globalAggregateMemoryCache\n private readonly dekCache = TenantDataEncryptionService.globalDekCache\n private readonly inflightDeks = TenantDataEncryptionService.globalInflightDeks\n private readonly inflightMaps = TenantDataEncryptionService.globalInflightMaps\n private readonly missCache = TenantDataEncryptionService.globalMissCache\n private readonly systemDefaultMaps: Map<string, ModuleEncryptionMap>\n\n constructor(\n private em: EntityManager,\n opts?: {\n cache?: CacheStrategy\n kms?: KmsService\n defaultEncryptionMaps?: readonly ModuleEncryptionMap[]\n }\n ) {\n this.cache = opts?.cache\n this.kms = opts?.kms ?? createKmsService()\n this.systemDefaultMaps = new Map(\n (opts?.defaultEncryptionMaps ?? [])\n .filter((map) => map.keyScope === 'system')\n .map((map) => [map.entityId, map]),\n )\n }\n\n isEnabled(): boolean {\n return isTenantDataEncryptionEnabled() && this.kms.isHealthy()\n }\n\n private isDekExpired(dek: TenantDek): boolean {\n return Date.now() - dek.fetchedAt > DEK_CACHE_TTL_MS\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 && !this.isDekExpired(cached)) return cached\n if (cached) this.dekCache.delete(tenantId)\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 /**\n * Resolves the DEK an encrypt call should seal under, provisioning one when the\n * tenant has none yet.\n *\n * Provisioning writes real key material to the KMS/Vault backend, so it is a\n * state change \u2014 not a cache fill. Callers whose intent is only to preview or\n * check (\"would this row be encrypted?\") pass `createIfMissing: false` to get a\n * `null` instead, leaving KMS untouched (issue #5950). The default stays `true`\n * so every existing write path keeps provisioning on first use.\n */\n private async resolveDekForEncrypt(\n tenantId: string | null,\n options?: { createIfMissing?: boolean }\n ): Promise<TenantDek | null> {\n const existing = await this.getDek(tenantId)\n if (existing || !tenantId) return existing ?? null\n if (options?.createIfMissing === false) return null\n if (typeof this.kms.createTenantDek !== 'function') return existing ?? null\n // Dedupe concurrent first-time creation within this process so two callers\n // can't each generate a distinct DEK and overwrite one another (#2746).\n // Mirrors the encryption-map inflight dedupe (`globalInflightMaps`).\n const pending = this.inflightDeks.get(tenantId)\n if (pending) return pending\n const creation = (async () => {\n const created = await this.kms.createTenantDek(tenantId)\n if (created) this.dekCache.set(tenantId, created)\n return created ?? null\n })()\n this.inflightDeks.set(tenantId, creation)\n try {\n return await creation\n } finally {\n this.inflightDeks.delete(tenantId)\n }\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 = getSqlConnection(this.em)\n if (!conn) 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] && typeof rows[0] === 'object'\n ? rows[0] as Record<string, unknown>\n : null\n if (!row) return null\n return {\n entityId: String(row.entity_id ?? row.entityId ?? key.entityId),\n fields: readEncryptedFieldsJson(row),\n }\n }\n\n private applySystemDefault(record: EncryptionMapRecord | null, entityId: string): EncryptionMapRecord | null {\n const declared = this.systemDefaultMaps.get(entityId)\n if (!declared) return record\n const fields: EncryptedFieldRule[] = declared.fields.map((field) => ({\n field: field.field,\n hashField: field.hashField ?? null,\n }))\n const declaredFields = new Set(fields.map((field) => field.field))\n for (const field of record?.fields ?? []) {\n if (!declaredFields.has(field.field)) fields.push(field)\n }\n return {\n entityId,\n keyScope: 'system',\n fields,\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 this.applySystemDefault(resolved, key.entityId)\n }\n const mem = this.memoryCache.get(tag)\n if (mem) return this.applySystemDefault(mem, key.entityId)\n if (this.cache && typeof this.cache.get === 'function') {\n const cached = await this.cache.get(tag)\n if (cached) return this.applySystemDefault(cached as EncryptionMapRecord, key.entityId)\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 this.applySystemDefault(loaded, key.entityId)\n }\n return this.applySystemDefault(null, key.entityId)\n }\n\n private async fetchAllOrganizationFieldRules(\n entityId: string,\n tenantId: string | null,\n ): Promise<EncryptedFieldRule[]> {\n const conn = getSqlConnection(this.em)\n if (!conn) return []\n const sql = `\n select fields_json\n from encryption_maps\n where entity_id = ?\n and tenant_id is not distinct from ?\n and organization_id is not null\n and is_active = true\n and deleted_at is null\n `\n const rows = await conn.execute(sql, [entityId, tenantId])\n if (!Array.isArray(rows) || rows.length === 0) return []\n const groups: EncryptedFieldRule[][] = []\n for (const row of rows) {\n if (!row || typeof row !== 'object') continue\n groups.push(normalizeEncryptedFieldRules(readEncryptedFieldsJson(row as Record<string, unknown>)))\n }\n return mergeEncryptedFieldRules(groups)\n }\n\n /**\n * Cached aggregate of every organization-scoped map for an entity/tenant. `encryptEntityPayload`\n * and `decryptEntityPayload` consult it on every row at the tenant-wide scope, so the uncached\n * read this used to be would add a round-trip per flush and per load (#5949).\n */\n private async getAllOrganizationFieldRules(\n entityId: string,\n tenantId: string | null,\n ): Promise<EncryptedFieldRule[]> {\n const tag = allOrganizationsCacheKey(entityId, tenantId)\n const missExpiresAt = this.missCache.get(tag)\n if (missExpiresAt) {\n if (missExpiresAt > Date.now()) return []\n this.missCache.delete(tag)\n }\n const mem = this.aggregateMemoryCache.get(tag)\n if (mem) {\n if (mem.at + AGGREGATE_CACHE_TTL_MS > Date.now()) return mem.record.fields\n this.aggregateMemoryCache.delete(tag)\n }\n if (this.cache && typeof this.cache.get === 'function') {\n const cached = await this.cache.get(tag)\n if (cached) {\n const record = cached as EncryptionMapRecord\n this.aggregateMemoryCache.set(tag, { at: Date.now(), record })\n return record.fields\n }\n }\n const inflight = this.inflightMaps.get(tag)\n if (inflight) return (await inflight)?.fields ?? []\n const pending = (async (): Promise<EncryptionMapRecord | null> => {\n const fields = await this.fetchAllOrganizationFieldRules(entityId, tenantId)\n return fields.length ? { entityId, fields } : null\n })()\n this.inflightMaps.set(tag, pending)\n let loaded: EncryptionMapRecord | null\n try {\n loaded = await pending\n } finally {\n this.inflightMaps.delete(tag)\n }\n if (!loaded) {\n this.missCache.set(tag, Date.now() + MAP_MISS_TTL_MS)\n return []\n }\n this.missCache.delete(tag)\n this.aggregateMemoryCache.set(tag, { at: Date.now(), record: loaded })\n if (this.cache && typeof this.cache.set === 'function') {\n await this.cache.set(tag, loaded, { ttl: 300 })\n }\n return loaded.fields\n }\n\n /**\n * The field rules that are actually encrypted at rest for a scope.\n *\n * At the tenant-wide scope (`organizationId == null`) `getMap` resolves only the base map, but\n * rows of the same entity may carry fields declared solely by an organization-scoped map. Reading\n * or writing such a row with the base map alone stores those fields as plaintext and hands\n * callers back undecrypted ciphertext, so every scope-aware path unions the organization maps in\n * (#5949) \u2014 the same set `getEncryptedFieldNames` has reported since #2282.\n */\n private async resolveFieldRulesForScope(\n entityId: string,\n tenantId: string | null,\n organizationId: string | null,\n map: EncryptionMapRecord | null,\n ): Promise<EncryptedFieldRule[]> {\n const mapRules = normalizeEncryptedFieldRules(map?.fields)\n if (organizationId != null) return mapRules\n return mergeEncryptedFieldRules([mapRules, await this.getAllOrganizationFieldRules(entityId, tenantId)])\n }\n\n async invalidateMap(entityId: string, tenantId: string | null, organizationId: string | null): Promise<void> {\n const tags = [cacheKey({ entityId, tenantId, organizationId }), allOrganizationsCacheKey(entityId, tenantId)]\n for (const tag of tags) {\n this.memoryCache.delete(tag)\n this.aggregateMemoryCache.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\n // Force a flush of a tenant's cached DEK across the service-level cache and the\n // underlying KMS cache so an operator can pick up a rotated/revoked key without\n // a process restart (#2746).\n invalidateDek(tenantId: string): void {\n this.dekCache.delete(tenantId)\n this.inflightDeks.delete(tenantId)\n this.kms.invalidateDek?.(tenantId)\n }\n\n /**\n * Lists the fields an encryption map marks as encrypted at rest.\n *\n * `isEnabled()` folds the environment toggle together with KMS health, so by default an\n * unhealthy KMS reports \"nothing is encrypted\" \u2014 which is safe for write paths but wrong for\n * readers that must decide whether a stored column holds ciphertext. `ignoreRuntimeHealth`\n * answers the on-disk question instead: it consults the map even when the KMS cannot currently\n * resolve a DEK, so callers can fail closed rather than treat ciphertext as plaintext (#4622).\n */\n async getEncryptedFieldNames(\n entityId: string,\n tenantId: string | null | undefined,\n organizationId?: string | null,\n options?: { ignoreRuntimeHealth?: boolean }\n ): Promise<string[]> {\n if (options?.ignoreRuntimeHealth) {\n if (!isTenantDataEncryptionEnabled()) return []\n } else if (!this.isEnabled()) {\n return []\n }\n const map = await this.getMap({ entityId, tenantId: tenantId ?? null, organizationId: organizationId ?? null })\n const fields = await this.resolveFieldRulesForScope(\n entityId,\n tenantId ?? null,\n organizationId ?? null,\n map,\n )\n return fields.map((rule) => rule.field)\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 payloads that genuinely decrypt under this DEK.\n // A forged ciphertext-shaped string fails this check and is encrypted as\n // plaintext, closing the encryption-at-rest bypass (issue #2720).\n if (isEncryptedWithDek(value, dek)) 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 /**\n * Encrypts the fields an entity's encryption map covers.\n *\n * `options.createMissingDek` (default `true`) controls whether a tenant without\n * a DEK gets one provisioned as a side effect. Preview/check callers \u2014 most\n * notably `mercato entities rotate-encryption-key --dry-run` \u2014 pass `false` so a\n * read-only invocation cannot write key material to KMS (issue #5950). With\n * `false` and no existing DEK the payload is returned unchanged, exactly as it\n * is when the KMS declines to issue a key.\n */\n async encryptEntityPayload(\n entityId: string,\n payload: Record<string, unknown>,\n tenantId: string | null | undefined,\n organizationId?: string | null,\n options?: { createMissingDek?: boolean }\n ): Promise<Record<string, unknown>> {\n if (!this.isEnabled()) {\n debug('\u26AA\uFE0F encrypt.skip.disabled', { entityId, tenantId })\n return payload\n }\n const map = await this.getMap({ entityId, tenantId: tenantId ?? null, organizationId: organizationId ?? null })\n const fields = await this.resolveFieldRulesForScope(\n entityId,\n tenantId ?? null,\n organizationId ?? null,\n map,\n )\n if (!fields.length) {\n debug('\u26AA\uFE0F encrypt.skip.no-map', { entityId, tenantId })\n return payload\n }\n const keyId = resolveEncryptionKeyId(entityId, map?.keyScope, tenantId)\n const dek = await this.resolveDekForEncrypt(keyId, { createIfMissing: options?.createMissingDek !== false })\n if (!dek) {\n debug('\u26A0\uFE0F encrypt.skip.no-dek', { entityId, tenantId, keyScope: map?.keyScope ?? 'tenant' })\n return payload\n }\n debug('\uD83D\uDD12 encrypt_entity', { entityId, tenantId, organizationId, fields: fields.length })\n return this.encryptFields(payload, 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 map = await this.getMap({ entityId, tenantId: tenantId ?? null, organizationId: organizationId ?? null })\n const fields = await this.resolveFieldRulesForScope(\n entityId,\n tenantId ?? null,\n organizationId ?? null,\n map,\n )\n if (!fields.length) {\n debug('\u26AA\uFE0F decrypt.skip.no-map', { entityId, tenantId })\n return payload\n }\n const keyId = resolveEncryptionKeyId(entityId, map?.keyScope, tenantId)\n const dek = await this.getDek(keyId)\n if (!dek) {\n debug('\u26A0\uFE0F decrypt.skip.no-dek', { entityId, tenantId, keyScope: map?.keyScope ?? 'tenant' })\n return payload\n }\n debug('\uD83D\uDD13 decrypt_entity', { entityId, tenantId, organizationId, fields: fields.length })\n return this.decryptFields(payload, fields, dek)\n }\n}\n"],
5
- "mappings": "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",
4
+ "sourcesContent": ["import type { EntityManager } from '@mikro-orm/postgresql'\nimport type { CacheStrategy } from '@open-mercato/cache'\nimport {\n TenantDataEncryptionError,\n TenantDataEncryptionErrorCode,\n decryptWithAesGcm,\n encryptWithAesGcm,\n hashForLookup,\n isEncryptedPayloadShape,\n} from './aes'\nimport { createKmsService, type KmsService, type TenantDek } from './kms'\nimport { isTenantDataEncryptionEnabled, isEncryptionDebugEnabled } from './toggles'\nimport { createLogger } from '../logger'\nimport type { EncryptionKeyScope, ModuleEncryptionMap } from '../../modules/encryption'\n\nconst logger = createLogger('shared').child({ component: 'tenant-encryption' })\n\nexport type EncryptedFieldRule = {\n field: string\n hashField?: string | null\n}\n\nexport type EncryptionMapRecord = {\n entityId: string\n keyScope?: EncryptionKeyScope\n fields: EncryptedFieldRule[]\n}\n\ntype MapCacheKey = {\n entityId: string\n tenantId: string | null\n organizationId: string | null\n}\n\ntype SqlConnection = {\n execute(sql: string, params?: readonly unknown[]): Promise<unknown>\n}\n\nconst MAP_MISS_TTL_MS = 5 * 60 * 1000\n// Mirror the Vault KMS default DEK TTL so a rotated/revoked tenant key is picked\n// up by long-lived processes without a restart (#2746). The service-level cache\n// previously had no TTL and shadowed the KMS's own 15-minute expiry.\nconst DEK_CACHE_TTL_MS = 15 * 60 * 1000\n// Matches the `{ ttl: 300 }` passed to the `CacheStrategy` layer for the all-organizations\n// aggregate, so a process that only ever hits its own memory entry (no shared cache configured,\n// or already warm) still picks up a runtime map edit within the same bound (#6066).\nconst AGGREGATE_CACHE_TTL_MS = 300 * 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\n// Tag for the aggregate of every organization-scoped map of an entity/tenant. The prefix differs\n// from `cacheKey`'s in its first segment rather than by an extra one, so no entity id can spell a\n// tag that collides with an aggregate (`encmap:all-orgs:x:y` would be reachable both ways).\nfunction allOrganizationsCacheKey(entityId: string, tenantId: string | null): string {\n return ['encmap-all-orgs', entityId.toLowerCase(), tenantId ?? 'null'].join(':')\n}\n\nfunction debug(event: string, payload: Record<string, unknown>) {\n if (!isEncryptionDebugEnabled()) return\n try {\n logger.debug(event, 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\n/**\n * A value is only treated as \"already encrypted\" when it actually decrypts\n * under the tenant DEK \u2014 i.e. the AES-GCM authentication tag verifies. A purely\n * structural `<iv>:<ct>:<tag>:v1` shape check is forgeable: attacker-controlled\n * field values (e.g. their own profile email/phone) could impersonate ciphertext\n * to skip encryption-at-rest and the lookup hash entirely (issue #2720). Binding\n * the check to a successful authenticated decrypt makes forgery infeasible, so a\n * fake payload simply gets encrypted like any other plaintext.\n */\nfunction isEncryptedWithDek(value: unknown, dek: TenantDek): boolean {\n if (typeof value !== 'string') return false\n const parts = value.split(':')\n if (parts.length !== 4 || parts[3] !== 'v1') return false\n return decryptWithAesGcm(value, dek.key) !== null\n}\n\n/**\n * Guard the encrypt path against re-wrapping ciphertext this process cannot open.\n *\n * Called only after {@link isEncryptedWithDek} has already said \"not sealed under the\n * current DEK\". At that point a structurally well-formed envelope means one of two things:\n * genuine ciphertext under some other key, or a length-valid forgery \u2014 the shape check is\n * length-based, not content-based, so any length-correct string qualifies, not just a\n * byte-exact match against something real. Both must stop the write \u2014 the first because\n * nesting envelopes silently destroys recoverable data, the second because rejecting it is\n * strictly safer than persisting attacker-chosen bytes.\n *\n * The field name is safe to report (it comes from the encryption map, not user input); the\n * value never is, so it stays out of both the error message and the log.\n */\nfunction assertNotSealedUnderAnotherKey(value: unknown, field: string): void {\n if (!isEncryptedPayloadShape(value)) return\n logger.error('Refusing to re-encrypt a value sealed under a different key', { field })\n throw new TenantDataEncryptionError(\n TenantDataEncryptionErrorCode.WRONG_KEY,\n `[internal] Field \"${field}\" already holds an encrypted payload that does not decrypt under the current tenant DEK. `\n + 'Encrypting it again would produce an unreadable nested envelope. '\n + 'Complete the key rotation for this tenant (mercato entities rotate-encryption-key --old-key \u2026) '\n + 'or restore the DEK that sealed it before writing this record again.',\n )\n}\n\nfunction normalizeEncryptedFieldRules(\n fields: readonly { field?: unknown; hashField?: unknown }[] | null | undefined,\n): EncryptedFieldRule[] {\n if (!Array.isArray(fields)) return []\n const rules: EncryptedFieldRule[] = []\n for (const rule of fields) {\n if (!rule || typeof rule !== 'object') continue\n const field = rule.field\n if (typeof field !== 'string' || field.trim().length === 0) continue\n rules.push({ field, hashField: typeof rule.hashField === 'string' ? rule.hashField : null })\n }\n return rules\n}\n\nfunction normalizeEncryptedFieldNames(fields: readonly { field?: unknown }[] | null | undefined): string[] {\n return normalizeEncryptedFieldRules(fields).map((rule) => rule.field)\n}\n\n/**\n * Union of field rules, first declaration wins on the field name. A later duplicate only\n * contributes its `hashField` when the winning rule declares none, so merging an organization-scoped\n * map into a tenant-wide one never silently retargets an existing lookup-hash column.\n */\nfunction mergeEncryptedFieldRules(groups: readonly EncryptedFieldRule[][]): EncryptedFieldRule[] {\n const merged: EncryptedFieldRule[] = []\n const byField = new Map<string, EncryptedFieldRule>()\n for (const group of groups) {\n for (const rule of group) {\n const existing = byField.get(rule.field)\n if (!existing) {\n const copy: EncryptedFieldRule = { field: rule.field, hashField: rule.hashField ?? null }\n byField.set(rule.field, copy)\n merged.push(copy)\n continue\n }\n if (!existing.hashField && rule.hashField) existing.hashField = rule.hashField\n }\n }\n return merged\n}\n\nfunction readEncryptedFieldsJson(row: Record<string, unknown>): EncryptedFieldRule[] {\n const raw = row.fields_json ?? row.fieldsJson\n if (Array.isArray(raw)) return raw as EncryptedFieldRule[]\n if (typeof raw === 'string') {\n try {\n const parsed = JSON.parse(raw)\n return Array.isArray(parsed) ? parsed as EncryptedFieldRule[] : []\n } catch {\n return []\n }\n }\n return []\n}\n\n/**\n * The KMS key id an encryption map's payloads are sealed under: a system-scoped map\n * uses a per-entity key that exists before any tenant does, everything else uses the\n * tenant's own key. Exported so callers that need to probe key availability without\n * encrypting (the encryption CLIs) derive the same id instead of re-spelling the\n * `system:` convention (#5950).\n */\nexport function resolveEncryptionKeyId(\n entityId: string,\n keyScope: EncryptionKeyScope | undefined,\n tenantId: string | null | undefined\n): string | null {\n return keyScope === 'system' ? `system:${entityId}` : tenantId ?? null\n}\n\nfunction getSqlConnection(em: EntityManager): SqlConnection | null {\n const source = em as { getConnection?: () => unknown }\n const conn = source.getConnection?.()\n if (!conn || typeof conn !== 'object') return null\n const candidate = conn as { execute?: unknown }\n if (typeof candidate.execute !== 'function') return null\n return candidate as SqlConnection\n}\n\nexport class TenantDataEncryptionService {\n private static globalMemoryCache = new Map<string, EncryptionMapRecord>()\n private static globalAggregateMemoryCache = new Map<string, { at: number; record: EncryptionMapRecord }>()\n private static globalInflightMaps = new Map<string, Promise<EncryptionMapRecord | null>>()\n private static globalDekCache = new Map<string, TenantDek>()\n private static globalInflightDeks = new Map<string, Promise<TenantDek | null>>()\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 aggregateMemoryCache = TenantDataEncryptionService.globalAggregateMemoryCache\n private readonly dekCache = TenantDataEncryptionService.globalDekCache\n private readonly inflightDeks = TenantDataEncryptionService.globalInflightDeks\n private readonly inflightMaps = TenantDataEncryptionService.globalInflightMaps\n private readonly missCache = TenantDataEncryptionService.globalMissCache\n private readonly systemDefaultMaps: Map<string, ModuleEncryptionMap>\n\n constructor(\n private em: EntityManager,\n opts?: {\n cache?: CacheStrategy\n kms?: KmsService\n defaultEncryptionMaps?: readonly ModuleEncryptionMap[]\n }\n ) {\n this.cache = opts?.cache\n this.kms = opts?.kms ?? createKmsService()\n this.systemDefaultMaps = new Map(\n (opts?.defaultEncryptionMaps ?? [])\n .filter((map) => map.keyScope === 'system')\n .map((map) => [map.entityId, map]),\n )\n }\n\n isEnabled(): boolean {\n return isTenantDataEncryptionEnabled() && this.kms.isHealthy()\n }\n\n private isDekExpired(dek: TenantDek): boolean {\n return Date.now() - dek.fetchedAt > DEK_CACHE_TTL_MS\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 && !this.isDekExpired(cached)) return cached\n if (cached) this.dekCache.delete(tenantId)\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 /**\n * Resolves the DEK an encrypt call should seal under, provisioning one when the\n * tenant has none yet.\n *\n * Provisioning writes real key material to the KMS/Vault backend, so it is a\n * state change \u2014 not a cache fill. Callers whose intent is only to preview or\n * check (\"would this row be encrypted?\") pass `createIfMissing: false` to get a\n * `null` instead, leaving KMS untouched (issue #5950). The default stays `true`\n * so every existing write path keeps provisioning on first use.\n */\n private async resolveDekForEncrypt(\n tenantId: string | null,\n options?: { createIfMissing?: boolean }\n ): Promise<TenantDek | null> {\n const existing = await this.getDek(tenantId)\n if (existing || !tenantId) return existing ?? null\n if (options?.createIfMissing === false) return null\n if (typeof this.kms.createTenantDek !== 'function') return existing ?? null\n // Dedupe concurrent first-time creation within this process so two callers\n // can't each generate a distinct DEK and overwrite one another (#2746).\n // Mirrors the encryption-map inflight dedupe (`globalInflightMaps`).\n const pending = this.inflightDeks.get(tenantId)\n if (pending) return pending\n const creation = (async () => {\n const created = await this.kms.createTenantDek(tenantId)\n if (created) this.dekCache.set(tenantId, created)\n return created ?? null\n })()\n this.inflightDeks.set(tenantId, creation)\n try {\n return await creation\n } finally {\n this.inflightDeks.delete(tenantId)\n }\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 = getSqlConnection(this.em)\n if (!conn) 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] && typeof rows[0] === 'object'\n ? rows[0] as Record<string, unknown>\n : null\n if (!row) return null\n return {\n entityId: String(row.entity_id ?? row.entityId ?? key.entityId),\n fields: readEncryptedFieldsJson(row),\n }\n }\n\n private applySystemDefault(record: EncryptionMapRecord | null, entityId: string): EncryptionMapRecord | null {\n const declared = this.systemDefaultMaps.get(entityId)\n if (!declared) return record\n const fields: EncryptedFieldRule[] = declared.fields.map((field) => ({\n field: field.field,\n hashField: field.hashField ?? null,\n }))\n const declaredFields = new Set(fields.map((field) => field.field))\n for (const field of record?.fields ?? []) {\n if (!declaredFields.has(field.field)) fields.push(field)\n }\n return {\n entityId,\n keyScope: 'system',\n fields,\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 this.applySystemDefault(resolved, key.entityId)\n }\n const mem = this.memoryCache.get(tag)\n if (mem) return this.applySystemDefault(mem, key.entityId)\n if (this.cache && typeof this.cache.get === 'function') {\n const cached = await this.cache.get(tag)\n if (cached) return this.applySystemDefault(cached as EncryptionMapRecord, key.entityId)\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 this.applySystemDefault(loaded, key.entityId)\n }\n return this.applySystemDefault(null, key.entityId)\n }\n\n private async fetchAllOrganizationFieldRules(\n entityId: string,\n tenantId: string | null,\n ): Promise<EncryptedFieldRule[]> {\n const conn = getSqlConnection(this.em)\n if (!conn) return []\n const sql = `\n select fields_json\n from encryption_maps\n where entity_id = ?\n and tenant_id is not distinct from ?\n and organization_id is not null\n and is_active = true\n and deleted_at is null\n `\n const rows = await conn.execute(sql, [entityId, tenantId])\n if (!Array.isArray(rows) || rows.length === 0) return []\n const groups: EncryptedFieldRule[][] = []\n for (const row of rows) {\n if (!row || typeof row !== 'object') continue\n groups.push(normalizeEncryptedFieldRules(readEncryptedFieldsJson(row as Record<string, unknown>)))\n }\n return mergeEncryptedFieldRules(groups)\n }\n\n /**\n * Cached aggregate of every organization-scoped map for an entity/tenant. `encryptEntityPayload`\n * and `decryptEntityPayload` consult it on every row at the tenant-wide scope, so the uncached\n * read this used to be would add a round-trip per flush and per load (#5949).\n */\n private async getAllOrganizationFieldRules(\n entityId: string,\n tenantId: string | null,\n ): Promise<EncryptedFieldRule[]> {\n const tag = allOrganizationsCacheKey(entityId, tenantId)\n const missExpiresAt = this.missCache.get(tag)\n if (missExpiresAt) {\n if (missExpiresAt > Date.now()) return []\n this.missCache.delete(tag)\n }\n const mem = this.aggregateMemoryCache.get(tag)\n if (mem) {\n if (mem.at + AGGREGATE_CACHE_TTL_MS > Date.now()) return mem.record.fields\n this.aggregateMemoryCache.delete(tag)\n }\n if (this.cache && typeof this.cache.get === 'function') {\n const cached = await this.cache.get(tag)\n if (cached) {\n const record = cached as EncryptionMapRecord\n this.aggregateMemoryCache.set(tag, { at: Date.now(), record })\n return record.fields\n }\n }\n const inflight = this.inflightMaps.get(tag)\n if (inflight) return (await inflight)?.fields ?? []\n const pending = (async (): Promise<EncryptionMapRecord | null> => {\n const fields = await this.fetchAllOrganizationFieldRules(entityId, tenantId)\n return fields.length ? { entityId, fields } : null\n })()\n this.inflightMaps.set(tag, pending)\n let loaded: EncryptionMapRecord | null\n try {\n loaded = await pending\n } finally {\n this.inflightMaps.delete(tag)\n }\n if (!loaded) {\n this.missCache.set(tag, Date.now() + MAP_MISS_TTL_MS)\n return []\n }\n this.missCache.delete(tag)\n this.aggregateMemoryCache.set(tag, { at: Date.now(), record: loaded })\n if (this.cache && typeof this.cache.set === 'function') {\n await this.cache.set(tag, loaded, { ttl: 300 })\n }\n return loaded.fields\n }\n\n /**\n * The field rules that are actually encrypted at rest for a scope.\n *\n * At the tenant-wide scope (`organizationId == null`) `getMap` resolves only the base map, but\n * rows of the same entity may carry fields declared solely by an organization-scoped map. Reading\n * or writing such a row with the base map alone stores those fields as plaintext and hands\n * callers back undecrypted ciphertext, so every scope-aware path unions the organization maps in\n * (#5949) \u2014 the same set `getEncryptedFieldNames` has reported since #2282.\n */\n private async resolveFieldRulesForScope(\n entityId: string,\n tenantId: string | null,\n organizationId: string | null,\n map: EncryptionMapRecord | null,\n ): Promise<EncryptedFieldRule[]> {\n const mapRules = normalizeEncryptedFieldRules(map?.fields)\n if (organizationId != null) return mapRules\n return mergeEncryptedFieldRules([mapRules, await this.getAllOrganizationFieldRules(entityId, tenantId)])\n }\n\n async invalidateMap(entityId: string, tenantId: string | null, organizationId: string | null): Promise<void> {\n const tags = [cacheKey({ entityId, tenantId, organizationId }), allOrganizationsCacheKey(entityId, tenantId)]\n for (const tag of tags) {\n this.memoryCache.delete(tag)\n this.aggregateMemoryCache.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\n // Force a flush of a tenant's cached DEK across the service-level cache and the\n // underlying KMS cache so an operator can pick up a rotated/revoked key without\n // a process restart (#2746).\n invalidateDek(tenantId: string): void {\n this.dekCache.delete(tenantId)\n this.inflightDeks.delete(tenantId)\n this.kms.invalidateDek?.(tenantId)\n }\n\n /**\n * Lists the fields an encryption map marks as encrypted at rest.\n *\n * `isEnabled()` folds the environment toggle together with KMS health, so by default an\n * unhealthy KMS reports \"nothing is encrypted\" \u2014 which is safe for write paths but wrong for\n * readers that must decide whether a stored column holds ciphertext. `ignoreRuntimeHealth`\n * answers the on-disk question instead: it consults the map even when the KMS cannot currently\n * resolve a DEK, so callers can fail closed rather than treat ciphertext as plaintext (#4622).\n */\n async getEncryptedFieldNames(\n entityId: string,\n tenantId: string | null | undefined,\n organizationId?: string | null,\n options?: { ignoreRuntimeHealth?: boolean }\n ): Promise<string[]> {\n if (options?.ignoreRuntimeHealth) {\n if (!isTenantDataEncryptionEnabled()) return []\n } else if (!this.isEnabled()) {\n return []\n }\n const map = await this.getMap({ entityId, tenantId: tenantId ?? null, organizationId: organizationId ?? null })\n const fields = await this.resolveFieldRulesForScope(\n entityId,\n tenantId ?? null,\n organizationId ?? null,\n map,\n )\n return fields.map((rule) => rule.field)\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 payloads that genuinely decrypt under this DEK.\n // A forged ciphertext-shaped string fails this check and is encrypted as\n // plaintext, closing the encryption-at-rest bypass (issue #2720).\n if (isEncryptedWithDek(value, dek)) continue\n // Failing that check does not prove the value is plaintext. A well-formed\n // envelope sealed under a *different* key \u2014 the previous DEK mid-rotation, or\n // the derived key the KMS falls back to during a Vault outage \u2014 lands here too,\n // and encrypting it again would nest one envelope inside another: unreadable by\n // any normal decrypt, indistinguishable from correct ciphertext by inspection,\n // and it would overwrite the lookup hash with a hash of ciphertext (issue #5951).\n // Fail the write closed instead. Nothing is ever stored verbatim, so #2720 stays\n // shut: a forgery whose shape is not length-valid still gets encrypted as plaintext\n // above, and a length-valid one is rejected rather than persisted.\n assertNotSealedUnderAnotherKey(value, rule.field)\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 /**\n * Encrypts the fields an entity's encryption map covers.\n *\n * `options.createMissingDek` (default `true`) controls whether a tenant without\n * a DEK gets one provisioned as a side effect. Preview/check callers \u2014 most\n * notably `mercato entities rotate-encryption-key --dry-run` \u2014 pass `false` so a\n * read-only invocation cannot write key material to KMS (issue #5950). With\n * `false` and no existing DEK the payload is returned unchanged, exactly as it\n * is when the KMS declines to issue a key.\n */\n async encryptEntityPayload(\n entityId: string,\n payload: Record<string, unknown>,\n tenantId: string | null | undefined,\n organizationId?: string | null,\n options?: { createMissingDek?: boolean }\n ): Promise<Record<string, unknown>> {\n if (!this.isEnabled()) {\n debug('\u26AA\uFE0F encrypt.skip.disabled', { entityId, tenantId })\n return payload\n }\n const map = await this.getMap({ entityId, tenantId: tenantId ?? null, organizationId: organizationId ?? null })\n const fields = await this.resolveFieldRulesForScope(\n entityId,\n tenantId ?? null,\n organizationId ?? null,\n map,\n )\n if (!fields.length) {\n debug('\u26AA\uFE0F encrypt.skip.no-map', { entityId, tenantId })\n return payload\n }\n const keyId = resolveEncryptionKeyId(entityId, map?.keyScope, tenantId)\n const dek = await this.resolveDekForEncrypt(keyId, { createIfMissing: options?.createMissingDek !== false })\n if (!dek) {\n debug('\u26A0\uFE0F encrypt.skip.no-dek', { entityId, tenantId, keyScope: map?.keyScope ?? 'tenant' })\n return payload\n }\n debug('\uD83D\uDD12 encrypt_entity', { entityId, tenantId, organizationId, fields: fields.length })\n return this.encryptFields(payload, 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 map = await this.getMap({ entityId, tenantId: tenantId ?? null, organizationId: organizationId ?? null })\n const fields = await this.resolveFieldRulesForScope(\n entityId,\n tenantId ?? null,\n organizationId ?? null,\n map,\n )\n if (!fields.length) {\n debug('\u26AA\uFE0F decrypt.skip.no-map', { entityId, tenantId })\n return payload\n }\n const keyId = resolveEncryptionKeyId(entityId, map?.keyScope, tenantId)\n const dek = await this.getDek(keyId)\n if (!dek) {\n debug('\u26A0\uFE0F decrypt.skip.no-dek', { entityId, tenantId, keyScope: map?.keyScope ?? 'tenant' })\n return payload\n }\n debug('\uD83D\uDD13 decrypt_entity', { entityId, tenantId, organizationId, fields: fields.length })\n return this.decryptFields(payload, fields, dek)\n }\n}\n"],
5
+ "mappings": "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",
6
6
  "names": ["pending"]
7
7
  }
@@ -1,4 +1,4 @@
1
- const APP_VERSION = "0.8.1-develop.7228.1.b7ea09d2d3";
1
+ const APP_VERSION = "0.8.1-develop.7238.1.3abe669ea5";
2
2
  const appVersion = APP_VERSION;
3
3
  export {
4
4
  APP_VERSION,
@@ -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.8.1-develop.7228.1.b7ea09d2d3';\nexport const appVersion = APP_VERSION;\n"],
4
+ "sourcesContent": ["// Build-time generated version\nexport const APP_VERSION = '0.8.1-develop.7238.1.3abe669ea5';\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.8.1-develop.7228.1.b7ea09d2d3",
3
+ "version": "0.8.1-develop.7238.1.3abe669ea5",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -12,6 +12,10 @@
12
12
  },
13
13
  "exports": {
14
14
  ".": "./dist/index.js",
15
+ "./lib/availability": {
16
+ "types": "./src/lib/availability/index.ts",
17
+ "default": "./dist/lib/availability/index.js"
18
+ },
15
19
  "./lib/openapi": {
16
20
  "types": "./src/lib/openapi/index.ts",
17
21
  "default": "./dist/lib/openapi/index.js"
@@ -113,7 +117,7 @@
113
117
  "@mikro-orm/core": "^7.1.14",
114
118
  "@mikro-orm/decorators": "^7.1.14",
115
119
  "@mikro-orm/postgresql": "^7.1.14",
116
- "@open-mercato/cache": "0.8.1-develop.7228.1.b7ea09d2d3",
120
+ "@open-mercato/cache": "0.8.1-develop.7238.1.3abe669ea5",
117
121
  "@types/html-to-text": "^9.0.4",
118
122
  "@types/sanitize-html": "^2.16.1",
119
123
  "dotenv": "^17.4.2",
@@ -0,0 +1,98 @@
1
+ import '../catalogOnlyProvider'
2
+ import { setCatalogOnlyPolicyLookup } from '../catalogOnlyProvider'
3
+ import { availabilityProviderRegistry, resolveAvailability, AVAILABILITY_CATALOG_ONLY_PROVIDER_ID } from '../registry'
4
+ import type { AvailabilityQuery } from '../types'
5
+
6
+ function makeQuery(overrides: Partial<AvailabilityQuery> = {}): AvailabilityQuery {
7
+ return {
8
+ tenantId: 'tenant-1',
9
+ organizationId: 'org-1',
10
+ items: [{ catalogProductId: 'product-1', catalogVariantId: 'variant-1', quantity: 1 }],
11
+ ...overrides,
12
+ }
13
+ }
14
+
15
+ describe('catalog-only fallback provider', () => {
16
+ beforeEach(() => {
17
+ setCatalogOnlyPolicyLookup(null)
18
+ })
19
+
20
+ afterAll(() => {
21
+ setCatalogOnlyPolicyLookup(null)
22
+ })
23
+
24
+ it('is always registered under the catalog-only id', () => {
25
+ expect(availabilityProviderRegistry.get(AVAILABILITY_CATALOG_ONLY_PROVIDER_ID)).not.toBeNull()
26
+ })
27
+
28
+ it('returns not_tracked with canFulfil true and isAuthoritative true for every item with no policy hook (R5)', async () => {
29
+ const provider = availabilityProviderRegistry.get(AVAILABILITY_CATALOG_ONLY_PROVIDER_ID)!
30
+ const result = await provider.getAvailability(makeQuery())
31
+ const item = result.byItem['product-1:variant-1']
32
+ expect(item.state).toBe('not_tracked')
33
+ expect(item.canFulfil).toBe(true)
34
+ expect(item.isAuthoritative).toBe(true)
35
+ expect(item.availableQuantity).toBeNull()
36
+ // not_tracked must never collapse into in_stock for a naive consumer.
37
+ expect(item.state).not.toBe('in_stock')
38
+ })
39
+
40
+ it('reflects a future preorderReleaseAt from the policy hook as preorder', async () => {
41
+ const future = new Date(Date.now() + 86_400_000).toISOString()
42
+ setCatalogOnlyPolicyLookup(async () => ({
43
+ 'product-1:variant-1': { preorderReleaseAt: future, policySourceId: 'policy-1' },
44
+ }))
45
+ const provider = availabilityProviderRegistry.get(AVAILABILITY_CATALOG_ONLY_PROVIDER_ID)!
46
+ const result = await provider.getAvailability(makeQuery())
47
+ const item = result.byItem['product-1:variant-1']
48
+ expect(item.state).toBe('preorder')
49
+ expect(item.releaseAt).toBe(future)
50
+ expect(item.policySourceId).toBe('policy-1')
51
+ })
52
+
53
+ it('treats a past preorderReleaseAt as no longer preorder', async () => {
54
+ const past = new Date(Date.now() - 86_400_000).toISOString()
55
+ setCatalogOnlyPolicyLookup(async () => ({
56
+ 'product-1:variant-1': { preorderReleaseAt: past, policySourceId: 'policy-1' },
57
+ }))
58
+ const provider = availabilityProviderRegistry.get(AVAILABILITY_CATALOG_ONLY_PROVIDER_ID)!
59
+ const result = await provider.getAvailability(makeQuery())
60
+ expect(result.byItem['product-1:variant-1'].state).not.toBe('preorder')
61
+ })
62
+
63
+ it('treats an inactive policy row as out_of_stock', async () => {
64
+ setCatalogOnlyPolicyLookup(async () => ({
65
+ 'product-1:variant-1': { isActive: false, policySourceId: 'policy-2' },
66
+ }))
67
+ const provider = availabilityProviderRegistry.get(AVAILABILITY_CATALOG_ONLY_PROVIDER_ID)!
68
+ const result = await provider.getAvailability(makeQuery())
69
+ const item = result.byItem['product-1:variant-1']
70
+ expect(item.state).toBe('out_of_stock')
71
+ expect(item.canFulfil).toBe(false)
72
+ })
73
+
74
+ it('treats is_stock_managed true (no real data source) as out_of_stock rather than in_stock', async () => {
75
+ setCatalogOnlyPolicyLookup(async () => ({
76
+ 'product-1:variant-1': { isStockManaged: true, policySourceId: 'policy-3' },
77
+ }))
78
+ const provider = availabilityProviderRegistry.get(AVAILABILITY_CATALOG_ONLY_PROVIDER_ID)!
79
+ const result = await provider.getAvailability(makeQuery())
80
+ const item = result.byItem['product-1:variant-1']
81
+ expect(item.state).toBe('out_of_stock')
82
+ expect(item.canFulfil).toBe(false)
83
+ })
84
+
85
+ it('degrades to the pure fallback when the policy hook throws', async () => {
86
+ setCatalogOnlyPolicyLookup(async () => {
87
+ throw new Error('boom')
88
+ })
89
+ const provider = availabilityProviderRegistry.get(AVAILABILITY_CATALOG_ONLY_PROVIDER_ID)!
90
+ const result = await provider.getAvailability(makeQuery())
91
+ expect(result.byItem['product-1:variant-1'].state).toBe('not_tracked')
92
+ })
93
+
94
+ it('is reachable end-to-end via resolveAvailability', async () => {
95
+ const result = await resolveAvailability(makeQuery())
96
+ expect(result.byItem['product-1:variant-1'].state).toBe('not_tracked')
97
+ })
98
+ })