@open-mercato/shared 0.6.5-develop.5033.1.c970204a3f → 0.6.5-develop.5099.1.85c0aff78c

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.
@@ -1,7 +1,13 @@
1
+ import { createHmac, randomBytes } from "node:crypto";
1
2
  const DEFAULT_SUCCESS_TTL_MS = 3e4;
2
3
  const DEFAULT_NEGATIVE_TTL_MS = 5e3;
3
4
  const DEFAULT_LAST_USED_WRITE_INTERVAL_MS = 6e4;
4
5
  const DEFAULT_MAX_ENTRIES = 1e3;
6
+ const FINGERPRINT_BYTES = 16;
7
+ function createSecretFingerprinter() {
8
+ const key = randomBytes(32);
9
+ return (secret) => createHmac("sha256", key).update(secret, "utf8").digest("hex").slice(0, FINGERPRINT_BYTES * 2);
10
+ }
5
11
  function resolveTtlEnv(name, fallback) {
6
12
  const raw = process.env?.[name];
7
13
  if (!raw) return fallback;
@@ -18,6 +24,7 @@ function createApiKeyAuthCache(options = {}) {
18
24
  );
19
25
  const maxEntries = options.maxEntries ?? DEFAULT_MAX_ENTRIES;
20
26
  const now = options.now ?? (() => Date.now());
27
+ const fingerprint = createSecretFingerprinter();
21
28
  const entries = /* @__PURE__ */ new Map();
22
29
  const lastUsedWrites = /* @__PURE__ */ new Map();
23
30
  function touch(key, entry) {
@@ -36,12 +43,13 @@ function createApiKeyAuthCache(options = {}) {
36
43
  return {
37
44
  get(secret) {
38
45
  if (!secret) return void 0;
39
- const entry = entries.get(secret);
46
+ const key = fingerprint(secret);
47
+ const entry = entries.get(key);
40
48
  if (!entry) return void 0;
41
49
  const currentMs = now();
42
- if (purgeStale(secret, entry, currentMs)) return void 0;
43
- entries.delete(secret);
44
- entries.set(secret, entry);
50
+ if (purgeStale(key, entry, currentMs)) return void 0;
51
+ entries.delete(key);
52
+ entries.set(key, entry);
45
53
  return entry.auth;
46
54
  },
47
55
  setSuccess(secret, auth, expiresAtMs) {
@@ -51,13 +59,13 @@ function createApiKeyAuthCache(options = {}) {
51
59
  const ttlEnd = currentMs + successTtlMs;
52
60
  const effectiveExpiry = expiresAtMs != null ? Math.min(ttlEnd, expiresAtMs) : ttlEnd;
53
61
  if (effectiveExpiry <= currentMs) return;
54
- touch(secret, { auth, cachedAtMs: currentMs, expiresAtMs: effectiveExpiry });
62
+ touch(fingerprint(secret), { auth, cachedAtMs: currentMs, expiresAtMs: effectiveExpiry });
55
63
  },
56
64
  setMiss(secret) {
57
65
  if (!secret) return;
58
66
  if (negativeTtlMs <= 0) return;
59
67
  const currentMs = now();
60
- touch(secret, { auth: null, cachedAtMs: currentMs, expiresAtMs: currentMs + negativeTtlMs });
68
+ touch(fingerprint(secret), { auth: null, cachedAtMs: currentMs, expiresAtMs: currentMs + negativeTtlMs });
61
69
  },
62
70
  invalidateByKeyId(keyId) {
63
71
  if (!keyId) return;
@@ -84,6 +92,9 @@ function createApiKeyAuthCache(options = {}) {
84
92
  },
85
93
  size() {
86
94
  return entries.size;
95
+ },
96
+ inspectEntryKeysForTests() {
97
+ return Array.from(entries.keys());
87
98
  }
88
99
  };
89
100
  }
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../src/lib/auth/apiKeyAuthCache.ts"],
4
- "sourcesContent": ["const DEFAULT_SUCCESS_TTL_MS = 30_000\nconst DEFAULT_NEGATIVE_TTL_MS = 5_000\nconst DEFAULT_LAST_USED_WRITE_INTERVAL_MS = 60_000\nconst DEFAULT_MAX_ENTRIES = 1_000\n\nexport type CachedApiKeyAuth = Record<string, unknown> | null\n\ntype CachedEntry = {\n auth: CachedApiKeyAuth\n cachedAtMs: number\n expiresAtMs: number\n}\n\nexport type ApiKeyAuthCacheOptions = {\n successTtlMs?: number\n negativeTtlMs?: number\n lastUsedWriteIntervalMs?: number\n maxEntries?: number\n now?: () => number\n}\n\nexport type ApiKeyAuthCache = {\n get(secret: string): CachedApiKeyAuth | undefined\n setSuccess(secret: string, auth: Exclude<CachedApiKeyAuth, null>, expiresAtMs: number | null): void\n setMiss(secret: string): void\n invalidateByKeyId(keyId: string): void\n shouldWriteLastUsed(keyId: string): boolean\n clear(): void\n size(): number\n}\n\nfunction resolveTtlEnv(name: string, fallback: number): number {\n const raw = process.env?.[name]\n if (!raw) return fallback\n const parsed = Number(raw)\n if (!Number.isFinite(parsed) || parsed < 0) return fallback\n return parsed\n}\n\nexport function createApiKeyAuthCache(options: ApiKeyAuthCacheOptions = {}): ApiKeyAuthCache {\n const successTtlMs = options.successTtlMs ?? resolveTtlEnv('OM_API_KEY_AUTH_TTL_MS', DEFAULT_SUCCESS_TTL_MS)\n const negativeTtlMs = options.negativeTtlMs ?? resolveTtlEnv('OM_API_KEY_AUTH_NEGATIVE_TTL_MS', DEFAULT_NEGATIVE_TTL_MS)\n const lastUsedWriteIntervalMs = options.lastUsedWriteIntervalMs ?? resolveTtlEnv(\n 'OM_API_KEY_LAST_USED_WRITE_INTERVAL_MS',\n DEFAULT_LAST_USED_WRITE_INTERVAL_MS,\n )\n const maxEntries = options.maxEntries ?? DEFAULT_MAX_ENTRIES\n const now = options.now ?? (() => Date.now())\n\n const entries = new Map<string, CachedEntry>()\n const lastUsedWrites = new Map<string, number>()\n\n function touch(key: string, entry: CachedEntry) {\n entries.delete(key)\n entries.set(key, entry)\n if (entries.size > maxEntries) {\n const oldest = entries.keys().next().value\n if (typeof oldest === 'string') entries.delete(oldest)\n }\n }\n\n function purgeStale(key: string, entry: CachedEntry, currentMs: number): boolean {\n if (entry.expiresAtMs > currentMs) return false\n entries.delete(key)\n return true\n }\n\n return {\n get(secret) {\n if (!secret) return undefined\n const entry = entries.get(secret)\n if (!entry) return undefined\n const currentMs = now()\n if (purgeStale(secret, entry, currentMs)) return undefined\n entries.delete(secret)\n entries.set(secret, entry)\n return entry.auth\n },\n setSuccess(secret, auth, expiresAtMs) {\n if (!secret) return\n if (successTtlMs <= 0) return\n const currentMs = now()\n const ttlEnd = currentMs + successTtlMs\n const effectiveExpiry = expiresAtMs != null ? Math.min(ttlEnd, expiresAtMs) : ttlEnd\n if (effectiveExpiry <= currentMs) return\n touch(secret, { auth, cachedAtMs: currentMs, expiresAtMs: effectiveExpiry })\n },\n setMiss(secret) {\n if (!secret) return\n if (negativeTtlMs <= 0) return\n const currentMs = now()\n touch(secret, { auth: null, cachedAtMs: currentMs, expiresAtMs: currentMs + negativeTtlMs })\n },\n invalidateByKeyId(keyId) {\n if (!keyId) return\n for (const [key, entry] of entries) {\n const auth = entry.auth\n if (auth && typeof auth === 'object' && (auth as { keyId?: string }).keyId === keyId) {\n entries.delete(key)\n }\n }\n lastUsedWrites.delete(keyId)\n },\n shouldWriteLastUsed(keyId) {\n if (!keyId) return true\n if (lastUsedWriteIntervalMs <= 0) return true\n const currentMs = now()\n const previous = lastUsedWrites.get(keyId)\n if (previous != null && currentMs - previous < lastUsedWriteIntervalMs) return false\n lastUsedWrites.set(keyId, currentMs)\n return true\n },\n clear() {\n entries.clear()\n lastUsedWrites.clear()\n },\n size() {\n return entries.size\n },\n }\n}\n\nlet sharedCache: ApiKeyAuthCache | null = null\n\nexport function getSharedApiKeyAuthCache(): ApiKeyAuthCache {\n if (!sharedCache) sharedCache = createApiKeyAuthCache()\n return sharedCache\n}\n\nexport function resetSharedApiKeyAuthCacheForTests(): void {\n sharedCache = null\n}\n"],
5
- "mappings": "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",
4
+ "sourcesContent": ["import { createHmac, randomBytes } from 'node:crypto'\n\nconst DEFAULT_SUCCESS_TTL_MS = 30_000\nconst DEFAULT_NEGATIVE_TTL_MS = 5_000\nconst DEFAULT_LAST_USED_WRITE_INTERVAL_MS = 60_000\nconst DEFAULT_MAX_ENTRIES = 1_000\nconst FINGERPRINT_BYTES = 16\n\nfunction createSecretFingerprinter(): (secret: string) => string {\n const key = randomBytes(32)\n return (secret) => createHmac('sha256', key).update(secret, 'utf8').digest('hex').slice(0, FINGERPRINT_BYTES * 2)\n}\n\nexport type CachedApiKeyAuth = Record<string, unknown> | null\n\ntype CachedEntry = {\n auth: CachedApiKeyAuth\n cachedAtMs: number\n expiresAtMs: number\n}\n\nexport type ApiKeyAuthCacheOptions = {\n successTtlMs?: number\n negativeTtlMs?: number\n lastUsedWriteIntervalMs?: number\n maxEntries?: number\n now?: () => number\n}\n\nexport type ApiKeyAuthCache = {\n get(secret: string): CachedApiKeyAuth | undefined\n setSuccess(secret: string, auth: Exclude<CachedApiKeyAuth, null>, expiresAtMs: number | null): void\n setMiss(secret: string): void\n invalidateByKeyId(keyId: string): void\n shouldWriteLastUsed(keyId: string): boolean\n clear(): void\n size(): number\n inspectEntryKeysForTests(): string[]\n}\n\nfunction resolveTtlEnv(name: string, fallback: number): number {\n const raw = process.env?.[name]\n if (!raw) return fallback\n const parsed = Number(raw)\n if (!Number.isFinite(parsed) || parsed < 0) return fallback\n return parsed\n}\n\nexport function createApiKeyAuthCache(options: ApiKeyAuthCacheOptions = {}): ApiKeyAuthCache {\n const successTtlMs = options.successTtlMs ?? resolveTtlEnv('OM_API_KEY_AUTH_TTL_MS', DEFAULT_SUCCESS_TTL_MS)\n const negativeTtlMs = options.negativeTtlMs ?? resolveTtlEnv('OM_API_KEY_AUTH_NEGATIVE_TTL_MS', DEFAULT_NEGATIVE_TTL_MS)\n const lastUsedWriteIntervalMs = options.lastUsedWriteIntervalMs ?? resolveTtlEnv(\n 'OM_API_KEY_LAST_USED_WRITE_INTERVAL_MS',\n DEFAULT_LAST_USED_WRITE_INTERVAL_MS,\n )\n const maxEntries = options.maxEntries ?? DEFAULT_MAX_ENTRIES\n const now = options.now ?? (() => Date.now())\n const fingerprint = createSecretFingerprinter()\n\n const entries = new Map<string, CachedEntry>()\n const lastUsedWrites = new Map<string, number>()\n\n function touch(key: string, entry: CachedEntry) {\n entries.delete(key)\n entries.set(key, entry)\n if (entries.size > maxEntries) {\n const oldest = entries.keys().next().value\n if (typeof oldest === 'string') entries.delete(oldest)\n }\n }\n\n function purgeStale(key: string, entry: CachedEntry, currentMs: number): boolean {\n if (entry.expiresAtMs > currentMs) return false\n entries.delete(key)\n return true\n }\n\n return {\n get(secret) {\n if (!secret) return undefined\n const key = fingerprint(secret)\n const entry = entries.get(key)\n if (!entry) return undefined\n const currentMs = now()\n if (purgeStale(key, entry, currentMs)) return undefined\n entries.delete(key)\n entries.set(key, entry)\n return entry.auth\n },\n setSuccess(secret, auth, expiresAtMs) {\n if (!secret) return\n if (successTtlMs <= 0) return\n const currentMs = now()\n const ttlEnd = currentMs + successTtlMs\n const effectiveExpiry = expiresAtMs != null ? Math.min(ttlEnd, expiresAtMs) : ttlEnd\n if (effectiveExpiry <= currentMs) return\n touch(fingerprint(secret), { auth, cachedAtMs: currentMs, expiresAtMs: effectiveExpiry })\n },\n setMiss(secret) {\n if (!secret) return\n if (negativeTtlMs <= 0) return\n const currentMs = now()\n touch(fingerprint(secret), { auth: null, cachedAtMs: currentMs, expiresAtMs: currentMs + negativeTtlMs })\n },\n invalidateByKeyId(keyId) {\n if (!keyId) return\n for (const [key, entry] of entries) {\n const auth = entry.auth\n if (auth && typeof auth === 'object' && (auth as { keyId?: string }).keyId === keyId) {\n entries.delete(key)\n }\n }\n lastUsedWrites.delete(keyId)\n },\n shouldWriteLastUsed(keyId) {\n if (!keyId) return true\n if (lastUsedWriteIntervalMs <= 0) return true\n const currentMs = now()\n const previous = lastUsedWrites.get(keyId)\n if (previous != null && currentMs - previous < lastUsedWriteIntervalMs) return false\n lastUsedWrites.set(keyId, currentMs)\n return true\n },\n clear() {\n entries.clear()\n lastUsedWrites.clear()\n },\n size() {\n return entries.size\n },\n inspectEntryKeysForTests() {\n return Array.from(entries.keys())\n },\n }\n}\n\nlet sharedCache: ApiKeyAuthCache | null = null\n\nexport function getSharedApiKeyAuthCache(): ApiKeyAuthCache {\n if (!sharedCache) sharedCache = createApiKeyAuthCache()\n return sharedCache\n}\n\nexport function resetSharedApiKeyAuthCacheForTests(): void {\n sharedCache = null\n}\n"],
5
+ "mappings": "AAAA,SAAS,YAAY,mBAAmB;AAExC,MAAM,yBAAyB;AAC/B,MAAM,0BAA0B;AAChC,MAAM,sCAAsC;AAC5C,MAAM,sBAAsB;AAC5B,MAAM,oBAAoB;AAE1B,SAAS,4BAAwD;AAC/D,QAAM,MAAM,YAAY,EAAE;AAC1B,SAAO,CAAC,WAAW,WAAW,UAAU,GAAG,EAAE,OAAO,QAAQ,MAAM,EAAE,OAAO,KAAK,EAAE,MAAM,GAAG,oBAAoB,CAAC;AAClH;AA6BA,SAAS,cAAc,MAAc,UAA0B;AAC7D,QAAM,MAAM,QAAQ,MAAM,IAAI;AAC9B,MAAI,CAAC,IAAK,QAAO;AACjB,QAAM,SAAS,OAAO,GAAG;AACzB,MAAI,CAAC,OAAO,SAAS,MAAM,KAAK,SAAS,EAAG,QAAO;AACnD,SAAO;AACT;AAEO,SAAS,sBAAsB,UAAkC,CAAC,GAAoB;AAC3F,QAAM,eAAe,QAAQ,gBAAgB,cAAc,0BAA0B,sBAAsB;AAC3G,QAAM,gBAAgB,QAAQ,iBAAiB,cAAc,mCAAmC,uBAAuB;AACvH,QAAM,0BAA0B,QAAQ,2BAA2B;AAAA,IACjE;AAAA,IACA;AAAA,EACF;AACA,QAAM,aAAa,QAAQ,cAAc;AACzC,QAAM,MAAM,QAAQ,QAAQ,MAAM,KAAK,IAAI;AAC3C,QAAM,cAAc,0BAA0B;AAE9C,QAAM,UAAU,oBAAI,IAAyB;AAC7C,QAAM,iBAAiB,oBAAI,IAAoB;AAE/C,WAAS,MAAM,KAAa,OAAoB;AAC9C,YAAQ,OAAO,GAAG;AAClB,YAAQ,IAAI,KAAK,KAAK;AACtB,QAAI,QAAQ,OAAO,YAAY;AAC7B,YAAM,SAAS,QAAQ,KAAK,EAAE,KAAK,EAAE;AACrC,UAAI,OAAO,WAAW,SAAU,SAAQ,OAAO,MAAM;AAAA,IACvD;AAAA,EACF;AAEA,WAAS,WAAW,KAAa,OAAoB,WAA4B;AAC/E,QAAI,MAAM,cAAc,UAAW,QAAO;AAC1C,YAAQ,OAAO,GAAG;AAClB,WAAO;AAAA,EACT;AAEA,SAAO;AAAA,IACL,IAAI,QAAQ;AACV,UAAI,CAAC,OAAQ,QAAO;AACpB,YAAM,MAAM,YAAY,MAAM;AAC9B,YAAM,QAAQ,QAAQ,IAAI,GAAG;AAC7B,UAAI,CAAC,MAAO,QAAO;AACnB,YAAM,YAAY,IAAI;AACtB,UAAI,WAAW,KAAK,OAAO,SAAS,EAAG,QAAO;AAC9C,cAAQ,OAAO,GAAG;AAClB,cAAQ,IAAI,KAAK,KAAK;AACtB,aAAO,MAAM;AAAA,IACf;AAAA,IACA,WAAW,QAAQ,MAAM,aAAa;AACpC,UAAI,CAAC,OAAQ;AACb,UAAI,gBAAgB,EAAG;AACvB,YAAM,YAAY,IAAI;AACtB,YAAM,SAAS,YAAY;AAC3B,YAAM,kBAAkB,eAAe,OAAO,KAAK,IAAI,QAAQ,WAAW,IAAI;AAC9E,UAAI,mBAAmB,UAAW;AAClC,YAAM,YAAY,MAAM,GAAG,EAAE,MAAM,YAAY,WAAW,aAAa,gBAAgB,CAAC;AAAA,IAC1F;AAAA,IACA,QAAQ,QAAQ;AACd,UAAI,CAAC,OAAQ;AACb,UAAI,iBAAiB,EAAG;AACxB,YAAM,YAAY,IAAI;AACtB,YAAM,YAAY,MAAM,GAAG,EAAE,MAAM,MAAM,YAAY,WAAW,aAAa,YAAY,cAAc,CAAC;AAAA,IAC1G;AAAA,IACA,kBAAkB,OAAO;AACvB,UAAI,CAAC,MAAO;AACZ,iBAAW,CAAC,KAAK,KAAK,KAAK,SAAS;AAClC,cAAM,OAAO,MAAM;AACnB,YAAI,QAAQ,OAAO,SAAS,YAAa,KAA4B,UAAU,OAAO;AACpF,kBAAQ,OAAO,GAAG;AAAA,QACpB;AAAA,MACF;AACA,qBAAe,OAAO,KAAK;AAAA,IAC7B;AAAA,IACA,oBAAoB,OAAO;AACzB,UAAI,CAAC,MAAO,QAAO;AACnB,UAAI,2BAA2B,EAAG,QAAO;AACzC,YAAM,YAAY,IAAI;AACtB,YAAM,WAAW,eAAe,IAAI,KAAK;AACzC,UAAI,YAAY,QAAQ,YAAY,WAAW,wBAAyB,QAAO;AAC/E,qBAAe,IAAI,OAAO,SAAS;AACnC,aAAO;AAAA,IACT;AAAA,IACA,QAAQ;AACN,cAAQ,MAAM;AACd,qBAAe,MAAM;AAAA,IACvB;AAAA,IACA,OAAO;AACL,aAAO,QAAQ;AAAA,IACjB;AAAA,IACA,2BAA2B;AACzB,aAAO,MAAM,KAAK,QAAQ,KAAK,CAAC;AAAA,IAClC;AAAA,EACF;AACF;AAEA,IAAI,cAAsC;AAEnC,SAAS,2BAA4C;AAC1D,MAAI,CAAC,YAAa,eAAc,sBAAsB;AACtD,SAAO;AACT;AAEO,SAAS,qCAA2C;AACzD,gBAAc;AAChB;",
6
6
  "names": []
7
7
  }
@@ -1,29 +1,50 @@
1
1
  import { CrudHttpError } from "@open-mercato/shared/lib/crud/errors";
2
2
  import { isOrganizationAccessAllowed } from "@open-mercato/shared/lib/auth/organizationAccess";
3
+ import { parseBooleanWithDefault } from "@open-mercato/shared/lib/boolean";
3
4
  import { env } from "process";
5
+ function buildScopeLogContext(ctx) {
6
+ const requestInfo = ctx.request && typeof ctx.request === "object" ? {
7
+ method: ctx.request.method ?? void 0,
8
+ url: ctx.request.url ?? void 0
9
+ } : null;
10
+ const scope = ctx.organizationScope ? {
11
+ selectedId: ctx.organizationScope.selectedId ?? null,
12
+ tenantId: ctx.organizationScope.tenantId ?? null,
13
+ allowedIdsCount: Array.isArray(ctx.organizationScope.allowedIds) ? ctx.organizationScope.allowedIds.length : null,
14
+ filterIdsCount: Array.isArray(ctx.organizationScope.filterIds) ? ctx.organizationScope.filterIds.length : null
15
+ } : null;
16
+ return {
17
+ userId: ctx.auth?.sub ?? null,
18
+ actorTenantId: ctx.auth?.tenantId ?? null,
19
+ actorOrganizationId: ctx.auth?.orgId ?? null,
20
+ selectedOrganizationId: ctx.selectedOrganizationId ?? null,
21
+ organizationIdsCount: Array.isArray(ctx.organizationIds) ? ctx.organizationIds.length : null,
22
+ scope,
23
+ request: requestInfo
24
+ };
25
+ }
26
+ function isStrictOrganizationScopeEnforced() {
27
+ return parseBooleanWithDefault(env.OM_ENFORCE_ORG_SCOPE_STRICT, false);
28
+ }
4
29
  function logScopeViolation(ctx, expected, actual) {
5
30
  try {
6
- const requestInfo = ctx.request && typeof ctx.request === "object" ? {
7
- method: ctx.request.method ?? void 0,
8
- url: ctx.request.url ?? void 0
9
- } : null;
10
- const scope = ctx.organizationScope ? {
11
- selectedId: ctx.organizationScope.selectedId ?? null,
12
- tenantId: ctx.organizationScope.tenantId ?? null,
13
- allowedIdsCount: Array.isArray(ctx.organizationScope.allowedIds) ? ctx.organizationScope.allowedIds.length : null,
14
- filterIdsCount: Array.isArray(ctx.organizationScope.filterIds) ? ctx.organizationScope.filterIds.length : null
15
- } : null;
16
31
  if (env.NODE_ENV !== "test") {
17
32
  console.warn("[scope] Forbidden organization scope mismatch detected", {
18
33
  expectedId: expected,
19
34
  actualId: actual,
20
- userId: ctx.auth?.sub ?? null,
21
- actorTenantId: ctx.auth?.tenantId ?? null,
22
- actorOrganizationId: ctx.auth?.orgId ?? null,
23
- selectedOrganizationId: ctx.selectedOrganizationId ?? null,
24
- organizationIdsCount: Array.isArray(ctx.organizationIds) ? ctx.organizationIds.length : null,
25
- scope,
26
- request: requestInfo
35
+ ...buildScopeLogContext(ctx)
36
+ });
37
+ }
38
+ } catch {
39
+ }
40
+ }
41
+ function logUnscopedOrganizationAccess(ctx, organizationId) {
42
+ try {
43
+ if (env.NODE_ENV !== "test") {
44
+ console.warn("[scope] Unscoped organization command executed without organization context", {
45
+ targetOrganizationId: organizationId,
46
+ strictEnforcement: isStrictOrganizationScopeEnforced(),
47
+ ...buildScopeLogContext(ctx)
27
48
  });
28
49
  }
29
50
  } catch {
@@ -31,27 +52,11 @@ function logScopeViolation(ctx, expected, actual) {
31
52
  }
32
53
  function logTenantScopeViolation(ctx, expectedTenantId, actualTenantId) {
33
54
  try {
34
- const requestInfo = ctx.request && typeof ctx.request === "object" ? {
35
- method: ctx.request.method ?? void 0,
36
- url: ctx.request.url ?? void 0
37
- } : null;
38
- const scope = ctx.organizationScope ? {
39
- selectedId: ctx.organizationScope.selectedId ?? null,
40
- tenantId: ctx.organizationScope.tenantId ?? null,
41
- allowedIdsCount: Array.isArray(ctx.organizationScope.allowedIds) ? ctx.organizationScope.allowedIds.length : null,
42
- filterIdsCount: Array.isArray(ctx.organizationScope.filterIds) ? ctx.organizationScope.filterIds.length : null
43
- } : null;
44
55
  if (env.NODE_ENV !== "test") {
45
56
  console.warn("[scope] Forbidden tenant scope mismatch detected", {
46
57
  expectedTenantId,
47
58
  actualTenantId,
48
- userId: ctx.auth?.sub ?? null,
49
- actorTenantId: ctx.auth?.tenantId ?? null,
50
- actorOrganizationId: ctx.auth?.orgId ?? null,
51
- selectedOrganizationId: ctx.selectedOrganizationId ?? null,
52
- organizationIdsCount: Array.isArray(ctx.organizationIds) ? ctx.organizationIds.length : null,
53
- scope,
54
- request: requestInfo
59
+ ...buildScopeLogContext(ctx)
55
60
  });
56
61
  }
57
62
  } catch {
@@ -63,9 +68,18 @@ function ensureOrganizationScope(ctx, organizationId) {
63
68
  if (!scope) {
64
69
  if (isSuperAdmin) return;
65
70
  const currentOrg2 = ctx.selectedOrganizationId ?? ctx.auth?.orgId ?? null;
66
- if (currentOrg2 && currentOrg2 !== organizationId) {
67
- logScopeViolation(ctx, organizationId, currentOrg2);
68
- throw new CrudHttpError(403, { error: "Forbidden" });
71
+ if (currentOrg2) {
72
+ if (currentOrg2 !== organizationId) {
73
+ logScopeViolation(ctx, organizationId, currentOrg2);
74
+ throw new CrudHttpError(403, { error: "Forbidden" });
75
+ }
76
+ return;
77
+ }
78
+ if (organizationId) {
79
+ logUnscopedOrganizationAccess(ctx, organizationId);
80
+ if (isStrictOrganizationScopeEnforced()) {
81
+ throw new CrudHttpError(403, { error: "Forbidden" });
82
+ }
69
83
  }
70
84
  return;
71
85
  }
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../src/lib/commands/scope.ts"],
4
- "sourcesContent": ["import { CrudHttpError } from '@open-mercato/shared/lib/crud/errors'\nimport type { CommandRuntimeContext } from '@open-mercato/shared/lib/commands'\nimport { isOrganizationAccessAllowed } from '@open-mercato/shared/lib/auth/organizationAccess'\nimport { env } from 'process'\n\nfunction logScopeViolation(\n ctx: CommandRuntimeContext,\n expected: string,\n actual: string | null\n): void {\n try {\n const requestInfo =\n ctx.request && typeof ctx.request === 'object'\n ? {\n method: (ctx.request as Request).method ?? undefined,\n url: (ctx.request as Request).url ?? undefined,\n }\n : null\n const scope = ctx.organizationScope\n ? {\n selectedId: ctx.organizationScope.selectedId ?? null,\n tenantId: ctx.organizationScope.tenantId ?? null,\n allowedIdsCount: Array.isArray(ctx.organizationScope.allowedIds)\n ? ctx.organizationScope.allowedIds.length\n : null,\n filterIdsCount: Array.isArray(ctx.organizationScope.filterIds)\n ? ctx.organizationScope.filterIds.length\n : null,\n }\n : null\n if (env.NODE_ENV !== 'test') {\n console.warn('[scope] Forbidden organization scope mismatch detected', {\n expectedId: expected,\n actualId: actual,\n userId: ctx.auth?.sub ?? null,\n actorTenantId: ctx.auth?.tenantId ?? null,\n actorOrganizationId: ctx.auth?.orgId ?? null,\n selectedOrganizationId: ctx.selectedOrganizationId ?? null,\n organizationIdsCount: Array.isArray(ctx.organizationIds) ? ctx.organizationIds.length : null,\n scope,\n request: requestInfo,\n })\n }\n } catch {\n // best-effort logging\n }\n}\n\nfunction logTenantScopeViolation(\n ctx: CommandRuntimeContext,\n expectedTenantId: string,\n actualTenantId: string | null\n): void {\n try {\n const requestInfo =\n ctx.request && typeof ctx.request === 'object'\n ? {\n method: (ctx.request as Request).method ?? undefined,\n url: (ctx.request as Request).url ?? undefined,\n }\n : null\n const scope = ctx.organizationScope\n ? {\n selectedId: ctx.organizationScope.selectedId ?? null,\n tenantId: ctx.organizationScope.tenantId ?? null,\n allowedIdsCount: Array.isArray(ctx.organizationScope.allowedIds)\n ? ctx.organizationScope.allowedIds.length\n : null,\n filterIdsCount: Array.isArray(ctx.organizationScope.filterIds)\n ? ctx.organizationScope.filterIds.length\n : null,\n }\n : null\n if (env.NODE_ENV !== 'test') {\n console.warn('[scope] Forbidden tenant scope mismatch detected', {\n expectedTenantId,\n actualTenantId,\n userId: ctx.auth?.sub ?? null,\n actorTenantId: ctx.auth?.tenantId ?? null,\n actorOrganizationId: ctx.auth?.orgId ?? null,\n selectedOrganizationId: ctx.selectedOrganizationId ?? null,\n organizationIdsCount: Array.isArray(ctx.organizationIds) ? ctx.organizationIds.length : null,\n scope,\n request: requestInfo,\n })\n }\n } catch {\n // best-effort logging\n }\n}\n\nexport function ensureOrganizationScope(ctx: CommandRuntimeContext, organizationId: string): void {\n const isSuperAdmin = ctx.auth?.isSuperAdmin === true\n const scope = ctx.organizationScope\n\n // Pattern C: when no organization scope was resolved (system/worker/non-user\n // command contexts that build ctx with `organizationScope: null`), preserve\n // the legacy currentOrg fallback. This branch is load-bearing \u2014 switching it\n // to deny would break payment, scheduled-command, and other scope-less flows.\n if (!scope) {\n if (isSuperAdmin) return\n const currentOrg = ctx.selectedOrganizationId ?? ctx.auth?.orgId ?? null\n if (currentOrg && currentOrg !== organizationId) {\n logScopeViolation(ctx, organizationId, currentOrg)\n throw new CrudHttpError(403, { error: 'Forbidden' })\n }\n return\n }\n\n if (\n isOrganizationAccessAllowed({\n isSuperAdmin,\n allowedOrganizationIds: scope.allowedIds,\n targetOrganizationId: organizationId,\n })\n ) {\n return\n }\n\n const currentOrg = ctx.selectedOrganizationId ?? ctx.auth?.orgId ?? null\n logScopeViolation(ctx, organizationId, currentOrg)\n throw new CrudHttpError(403, { error: 'Forbidden' })\n}\n\nexport function ensureTenantScope(ctx: CommandRuntimeContext, tenantId: string): void {\n const currentTenant = ctx.auth?.tenantId ?? null\n if (currentTenant && currentTenant !== tenantId) {\n logTenantScopeViolation(ctx, tenantId, currentTenant)\n throw new CrudHttpError(403, { error: 'Forbidden' })\n }\n}\n\nexport function ensureSameScope(\n entity: Pick<{ organizationId: string; tenantId: string }, 'organizationId' | 'tenantId'>,\n organizationId: string,\n tenantId: string\n): void {\n if (entity.organizationId !== organizationId || entity.tenantId !== tenantId) {\n throw new CrudHttpError(403, { error: 'Cross-tenant relation forbidden' })\n }\n}\n"],
5
- "mappings": "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",
4
+ "sourcesContent": ["import { CrudHttpError } from '@open-mercato/shared/lib/crud/errors'\nimport type { CommandRuntimeContext } from '@open-mercato/shared/lib/commands'\nimport { isOrganizationAccessAllowed } from '@open-mercato/shared/lib/auth/organizationAccess'\nimport { parseBooleanWithDefault } from '@open-mercato/shared/lib/boolean'\nimport { env } from 'process'\n\nfunction buildScopeLogContext(ctx: CommandRuntimeContext) {\n const requestInfo =\n ctx.request && typeof ctx.request === 'object'\n ? {\n method: (ctx.request as Request).method ?? undefined,\n url: (ctx.request as Request).url ?? undefined,\n }\n : null\n const scope = ctx.organizationScope\n ? {\n selectedId: ctx.organizationScope.selectedId ?? null,\n tenantId: ctx.organizationScope.tenantId ?? null,\n allowedIdsCount: Array.isArray(ctx.organizationScope.allowedIds)\n ? ctx.organizationScope.allowedIds.length\n : null,\n filterIdsCount: Array.isArray(ctx.organizationScope.filterIds)\n ? ctx.organizationScope.filterIds.length\n : null,\n }\n : null\n return {\n userId: ctx.auth?.sub ?? null,\n actorTenantId: ctx.auth?.tenantId ?? null,\n actorOrganizationId: ctx.auth?.orgId ?? null,\n selectedOrganizationId: ctx.selectedOrganizationId ?? null,\n organizationIdsCount: Array.isArray(ctx.organizationIds) ? ctx.organizationIds.length : null,\n scope,\n request: requestInfo,\n }\n}\n\nfunction isStrictOrganizationScopeEnforced(): boolean {\n return parseBooleanWithDefault(env.OM_ENFORCE_ORG_SCOPE_STRICT, false)\n}\n\nfunction logScopeViolation(\n ctx: CommandRuntimeContext,\n expected: string,\n actual: string | null\n): void {\n try {\n if (env.NODE_ENV !== 'test') {\n console.warn('[scope] Forbidden organization scope mismatch detected', {\n expectedId: expected,\n actualId: actual,\n ...buildScopeLogContext(ctx),\n })\n }\n } catch {\n // best-effort logging\n }\n}\n\nfunction logUnscopedOrganizationAccess(ctx: CommandRuntimeContext, organizationId: string): void {\n try {\n if (env.NODE_ENV !== 'test') {\n console.warn('[scope] Unscoped organization command executed without organization context', {\n targetOrganizationId: organizationId,\n strictEnforcement: isStrictOrganizationScopeEnforced(),\n ...buildScopeLogContext(ctx),\n })\n }\n } catch {\n // best-effort logging\n }\n}\n\nfunction logTenantScopeViolation(\n ctx: CommandRuntimeContext,\n expectedTenantId: string,\n actualTenantId: string | null\n): void {\n try {\n if (env.NODE_ENV !== 'test') {\n console.warn('[scope] Forbidden tenant scope mismatch detected', {\n expectedTenantId,\n actualTenantId,\n ...buildScopeLogContext(ctx),\n })\n }\n } catch {\n // best-effort logging\n }\n}\n\nexport function ensureOrganizationScope(ctx: CommandRuntimeContext, organizationId: string): void {\n const isSuperAdmin = ctx.auth?.isSuperAdmin === true\n const scope = ctx.organizationScope\n\n // Pattern C: when no organization scope was resolved (system/worker/non-user\n // command contexts that build ctx with `organizationScope: null`), preserve\n // the legacy currentOrg fallback. This branch is load-bearing \u2014 switching it\n // to deny would break payment, scheduled-command, and other scope-less flows.\n if (!scope) {\n if (isSuperAdmin) return\n const currentOrg = ctx.selectedOrganizationId ?? ctx.auth?.orgId ?? null\n if (currentOrg) {\n if (currentOrg !== organizationId) {\n logScopeViolation(ctx, organizationId, currentOrg)\n throw new CrudHttpError(403, { error: 'Forbidden' })\n }\n return\n }\n // No current org could be resolved either. This branch previously returned\n // with no validation and no signal \u2014 a fail-open-by-omission shape (#2441):\n // a new command path reaching here with `organizationScope: null` would act\n // on an arbitrary target org silently. Preserve the legacy allow behavior by\n // default (the path is load-bearing) but make the unscoped access observable,\n // and let operators harden it into a deny via OM_ENFORCE_ORG_SCOPE_STRICT.\n if (organizationId) {\n logUnscopedOrganizationAccess(ctx, organizationId)\n if (isStrictOrganizationScopeEnforced()) {\n throw new CrudHttpError(403, { error: 'Forbidden' })\n }\n }\n return\n }\n\n if (\n isOrganizationAccessAllowed({\n isSuperAdmin,\n allowedOrganizationIds: scope.allowedIds,\n targetOrganizationId: organizationId,\n })\n ) {\n return\n }\n\n const currentOrg = ctx.selectedOrganizationId ?? ctx.auth?.orgId ?? null\n logScopeViolation(ctx, organizationId, currentOrg)\n throw new CrudHttpError(403, { error: 'Forbidden' })\n}\n\nexport function ensureTenantScope(ctx: CommandRuntimeContext, tenantId: string): void {\n const currentTenant = ctx.auth?.tenantId ?? null\n if (currentTenant && currentTenant !== tenantId) {\n logTenantScopeViolation(ctx, tenantId, currentTenant)\n throw new CrudHttpError(403, { error: 'Forbidden' })\n }\n}\n\nexport function ensureSameScope(\n entity: Pick<{ organizationId: string; tenantId: string }, 'organizationId' | 'tenantId'>,\n organizationId: string,\n tenantId: string\n): void {\n if (entity.organizationId !== organizationId || entity.tenantId !== tenantId) {\n throw new CrudHttpError(403, { error: 'Cross-tenant relation forbidden' })\n }\n}\n"],
5
+ "mappings": "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",
6
6
  "names": ["currentOrg"]
7
7
  }
@@ -56,6 +56,10 @@ class FallbackKmsService {
56
56
  }
57
57
  return null;
58
58
  }
59
+ invalidateDek(tenantId) {
60
+ this.primary.invalidateDek?.(tenantId);
61
+ this.fallback?.invalidateDek?.(tenantId);
62
+ }
59
63
  }
60
64
  function normalizeEnv(value) {
61
65
  if (!value) return "";
@@ -178,11 +182,13 @@ class HashicorpVaultKmsService {
178
182
  return null;
179
183
  }
180
184
  }
181
- async writeVault(path, key) {
185
+ async writeVault(path, key, opts) {
182
186
  if (!this.vaultAddr || !this.vaultToken) {
183
187
  this.healthy = false;
184
- return false;
188
+ return "error";
185
189
  }
190
+ const body = { data: { key } };
191
+ if (typeof opts?.cas === "number") body.options = { cas: opts.cas };
186
192
  try {
187
193
  const res = await fetchWithTimeout(`${this.vaultAddr}/v1/${path}`, {
188
194
  method: "POST",
@@ -190,14 +196,20 @@ class HashicorpVaultKmsService {
190
196
  "X-Vault-Token": this.vaultToken,
191
197
  "Content-Type": "application/json"
192
198
  },
193
- body: JSON.stringify({ data: { key } }),
199
+ body: JSON.stringify(body),
194
200
  timeoutMs: this.requestTimeoutMs
195
201
  });
196
- this.healthy = res.ok;
197
- if (!res.ok) {
198
- console.warn("\u26A0\uFE0F [encryption][kms] Vault write failed", { path, status: res.status });
202
+ if (res.ok) {
203
+ this.healthy = true;
204
+ return "ok";
205
+ }
206
+ if (typeof opts?.cas === "number" && res.status === 400) {
207
+ console.warn("\u26A0\uFE0F [encryption][kms] Vault write CAS conflict (concurrent DEK create)", { path, status: res.status });
208
+ return "conflict";
199
209
  }
200
- return res.ok;
210
+ this.healthy = false;
211
+ console.warn("\u26A0\uFE0F [encryption][kms] Vault write failed", { path, status: res.status });
212
+ return "error";
201
213
  } catch (err) {
202
214
  this.healthy = false;
203
215
  console.warn("\u26A0\uFE0F [encryption][kms] Vault write error", {
@@ -205,7 +217,7 @@ class HashicorpVaultKmsService {
205
217
  error: err?.message || String(err),
206
218
  timeoutMs: this.requestTimeoutMs
207
219
  });
208
- return false;
220
+ return "error";
209
221
  }
210
222
  }
211
223
  buildKeyPath(tenantId) {
@@ -231,16 +243,32 @@ class HashicorpVaultKmsService {
231
243
  return this.remember(dek);
232
244
  }
233
245
  async createTenantDek(tenantId) {
234
- const key = generateDek();
235
246
  const path = this.buildKeyPath(tenantId);
236
- const ok = await this.writeVault(path, key);
237
- if (ok) {
247
+ const existing = await this.readVault(path);
248
+ const existingKey = existing?.data?.data?.key;
249
+ if (existingKey) {
250
+ return this.remember({ tenantId, key: existingKey, fetchedAt: this.now() });
251
+ }
252
+ if (!this.healthy) return null;
253
+ const key = generateDek();
254
+ const outcome = await this.writeVault(path, key, { cas: 0 });
255
+ if (outcome === "ok") {
238
256
  console.info("\u{1F511} [encryption][kms] Stored tenant DEK in Vault", { tenantId, path });
239
- } else {
240
- console.warn("\u26A0\uFE0F [encryption][kms] Failed to store tenant DEK in Vault", { tenantId, path });
257
+ return this.remember({ tenantId, key, fetchedAt: this.now() });
258
+ }
259
+ if (outcome === "conflict") {
260
+ const winner = await this.readVault(path);
261
+ const winnerKey = winner?.data?.data?.key;
262
+ if (winnerKey) {
263
+ console.info("\u{1F511} [encryption][kms] Adopted concurrently-created tenant DEK", { tenantId, path });
264
+ return this.remember({ tenantId, key: winnerKey, fetchedAt: this.now() });
265
+ }
241
266
  }
242
- if (!ok) return null;
243
- return this.remember({ tenantId, key, fetchedAt: this.now() });
267
+ console.warn("\u26A0\uFE0F [encryption][kms] Failed to store tenant DEK in Vault", { tenantId, path });
268
+ return null;
269
+ }
270
+ invalidateDek(tenantId) {
271
+ this.cache.delete(tenantId);
244
272
  }
245
273
  }
246
274
  let loggedDerivedKeyFallbackBanner = false;
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../src/lib/encryption/kms.ts"],
4
- "sourcesContent": ["import crypto from 'node:crypto'\nimport { generateDek, hashForLookup } from './aes'\nimport { isEncryptionDebugEnabled, isTenantDataEncryptionEnabled } from './toggles'\nimport { parseBooleanToken } from '../boolean'\nimport { fetchWithTimeout, resolveTimeoutMs } from '../http/fetchWithTimeout'\n\nconst DEFAULT_VAULT_REQUEST_TIMEOUT_MS = 1_000\n\nfunction resolveVaultRequestTimeoutMs(): number {\n const raw = process.env.VAULT_REQUEST_TIMEOUT_MS\n const parsed = raw ? Number.parseInt(raw, 10) : undefined\n return resolveTimeoutMs(parsed, DEFAULT_VAULT_REQUEST_TIMEOUT_MS)\n}\n\nexport type TenantDek = {\n tenantId: string\n key: string // base64\n fetchedAt: number\n}\n\nexport interface KmsService {\n getTenantDek(tenantId: string): Promise<TenantDek | null>\n createTenantDek(tenantId: string): Promise<TenantDek | null>\n isHealthy(): boolean\n}\n\nclass FallbackKmsService implements KmsService {\n private notified = false\n constructor(\n private readonly primary: KmsService,\n private readonly fallback: KmsService | null,\n private readonly onFallback?: () => void,\n ) {}\n\n isHealthy(): boolean {\n return this.primary.isHealthy() || Boolean(this.fallback?.isHealthy?.())\n }\n\n private notifyFallback() {\n if (this.notified) return\n this.notified = true\n this.onFallback?.()\n }\n\n private async fromPrimary<T>(op: () => Promise<T | null>): Promise<T | null> {\n try {\n return await op()\n } catch (err) {\n console.warn('\u26A0\uFE0F [encryption][kms] Primary KMS failed, will try fallback', {\n error: (err as Error)?.message || String(err),\n })\n return null\n }\n }\n\n async getTenantDek(tenantId: string): Promise<TenantDek | null> {\n if (this.primary.isHealthy()) {\n const dek = await this.fromPrimary(() => this.primary.getTenantDek(tenantId))\n if (dek) return dek\n }\n if (this.fallback?.isHealthy()) {\n this.notifyFallback()\n return this.fallback.getTenantDek(tenantId)\n }\n return null\n }\n\n async createTenantDek(tenantId: string): Promise<TenantDek | null> {\n if (this.primary.isHealthy()) {\n const dek = await this.fromPrimary(() => this.primary.createTenantDek(tenantId))\n if (dek) return dek\n }\n if (this.fallback?.isHealthy()) {\n this.notifyFallback()\n return this.fallback.createTenantDek(tenantId)\n }\n return null\n }\n}\n\ntype VaultClientOpts = {\n vaultAddr?: string\n vaultToken?: string\n mountPath?: string\n ttlMs?: number\n requestTimeoutMs?: number\n}\n\ntype VaultReadResponse = {\n data?: { data?: { key?: string; version?: number }; metadata?: Record<string, unknown> }\n}\n\nfunction normalizeEnv(value: string | undefined): string {\n if (!value) return ''\n return value.trim().replace(/(?:^['\"]|['\"]$)/g, '')\n}\n\ntype DerivedSecret = { secret: string; source: 'explicit' | 'dev-default'; envName: string }\n\nfunction resolveDerivedKeySecret(): DerivedSecret | null {\n const candidates: Array<{ value: string | null; envName: string }> = [\n { value: process.env.TENANT_DATA_ENCRYPTION_FALLBACK_KEY ?? null, envName: 'TENANT_DATA_ENCRYPTION_FALLBACK_KEY' },\n { value: process.env.TENANT_DATA_ENCRYPTION_KEY ?? null, envName: 'TENANT_DATA_ENCRYPTION_KEY' },\n ]\n for (const raw of candidates) {\n const normalized = normalizeEnv(raw.value ?? undefined)\n if (normalized) return { secret: normalized, source: 'explicit', envName: raw.envName }\n }\n if (\n process.env.NODE_ENV !== 'production'\n && parseBooleanToken(process.env.ALLOW_DERIVED_KMS_FALLBACK) === true\n ) {\n return { secret: 'om-dev-tenant-encryption', source: 'dev-default', envName: 'DEV_DEFAULT' }\n }\n return null\n}\n\nexport class NoopKmsService implements KmsService {\n isHealthy(): boolean { return !isTenantDataEncryptionEnabled() }\n async getTenantDek(): Promise<TenantDek | null> { return null }\n async createTenantDek(): Promise<TenantDek | null> { return null }\n}\n\nclass DerivedKmsService implements KmsService {\n private root: Buffer\n constructor(secret: string) {\n // Derive a stable root key from the provided secret so derived tenant keys are deterministic\n this.root = crypto.createHash('sha256').update(secret).digest()\n }\n\n isHealthy(): boolean {\n return true\n }\n\n private deriveKey(tenantId: string): string {\n const iterations = 310_000\n const keyLength = 32\n const derived = crypto.pbkdf2Sync(this.root, tenantId, iterations, keyLength, 'sha512')\n return derived.toString('base64')\n }\n\n async getTenantDek(tenantId: string): Promise<TenantDek | null> {\n if (!tenantId) return null\n return { tenantId, key: this.deriveKey(tenantId), fetchedAt: Date.now() }\n }\n\n async createTenantDek(tenantId: string): Promise<TenantDek | null> {\n return this.getTenantDek(tenantId)\n }\n}\n\nexport class HashicorpVaultKmsService implements KmsService {\n private cache = new Map<string, TenantDek>()\n private readonly vaultAddr: string\n private readonly vaultToken: string\n private readonly mountPath: string\n private readonly ttlMs: number\n private readonly requestTimeoutMs: number\n private healthy = true\n private readonly debugEnabled: boolean\n private static loggedInit = false\n\n constructor(opts: VaultClientOpts = {}) {\n this.vaultAddr = normalizeEnv(opts.vaultAddr || process.env.VAULT_ADDR || '')\n this.vaultToken = normalizeEnv(opts.vaultToken || process.env.VAULT_TOKEN || '')\n this.mountPath = (opts.mountPath || process.env.VAULT_KV_PATH || 'secret/data').replace(/\\/+$/, '')\n this.ttlMs = opts.ttlMs ?? 15 * 60 * 1000\n this.requestTimeoutMs = resolveTimeoutMs(opts.requestTimeoutMs, resolveVaultRequestTimeoutMs())\n this.debugEnabled = isEncryptionDebugEnabled()\n if (!this.vaultAddr || !this.vaultToken) {\n this.healthy = false\n if (this.debugEnabled) {\n console.warn('\u26A0\uFE0F [encryption][kms] Vault misconfigured (missing VAULT_ADDR or VAULT_TOKEN)')\n }\n }\n if (this.healthy && !HashicorpVaultKmsService.loggedInit && this.debugEnabled) {\n HashicorpVaultKmsService.loggedInit = true\n if(this.debugEnabled) {\n console.info('\uD83D\uDD10 [encryption][kms] Hashicorp Vault KMS enabled')\n }\n }\n }\n\n isHealthy(): boolean {\n return this.healthy\n }\n\n private now(): number {\n return Date.now()\n }\n\n private cacheHit(tenantId: string): TenantDek | null {\n const entry = this.cache.get(tenantId)\n if (!entry) return null\n if (this.now() - entry.fetchedAt > this.ttlMs) {\n this.cache.delete(tenantId)\n return null\n }\n return entry\n }\n\n private async readVault(path: string): Promise<VaultReadResponse | null> {\n if (!this.vaultAddr || !this.vaultToken) {\n this.healthy = false\n return null\n }\n try {\n const res = await fetchWithTimeout(`${this.vaultAddr}/v1/${path}`, {\n method: 'GET',\n headers: { 'X-Vault-Token': this.vaultToken },\n timeoutMs: this.requestTimeoutMs,\n })\n if (!res.ok) {\n this.healthy = res.status < 500\n console.warn('\u26A0\uFE0F [encryption][kms] Vault read failed', { path, status: res.status })\n return null\n }\n if (this.debugEnabled) {\n console.info('\uD83D\uDD0D [encryption][kms] Vault read ok', { path })\n }\n return (await res.json()) as VaultReadResponse\n } catch (err) {\n this.healthy = false\n console.warn('\u26A0\uFE0F [encryption][kms] Vault read error', {\n path,\n error: (err as Error)?.message || String(err),\n timeoutMs: this.requestTimeoutMs,\n })\n return null\n }\n }\n\n private async writeVault(path: string, key: string): Promise<boolean> {\n if (!this.vaultAddr || !this.vaultToken) {\n this.healthy = false\n return false\n }\n try {\n const res = await fetchWithTimeout(`${this.vaultAddr}/v1/${path}`, {\n method: 'POST',\n headers: {\n 'X-Vault-Token': this.vaultToken,\n 'Content-Type': 'application/json',\n },\n body: JSON.stringify({ data: { key } }),\n timeoutMs: this.requestTimeoutMs,\n })\n this.healthy = res.ok\n if (!res.ok) {\n console.warn('\u26A0\uFE0F [encryption][kms] Vault write failed', { path, status: res.status })\n }\n return res.ok\n } catch (err) {\n this.healthy = false\n console.warn('\u26A0\uFE0F [encryption][kms] Vault write error', {\n path,\n error: (err as Error)?.message || String(err),\n timeoutMs: this.requestTimeoutMs,\n })\n return false\n }\n }\n\n private buildKeyPath(tenantId: string): string {\n const suffix = `tenant_key_${tenantId}`\n const normalizedMount = this.mountPath.replace(/^\\/+/, '')\n return `${normalizedMount}/${suffix}`\n }\n\n private remember(entry: TenantDek): TenantDek {\n this.cache.set(entry.tenantId, entry)\n return entry\n }\n\n async getTenantDek(tenantId: string): Promise<TenantDek | null> {\n const cached = this.cacheHit(tenantId)\n if (cached) return cached\n const path = this.buildKeyPath(tenantId)\n const res = await this.readVault(path)\n const key = res?.data?.data?.key\n if (!key) {\n console.warn('\u26A0\uFE0F [encryption][kms] No tenant DEK found in Vault', { tenantId, path })\n return null\n }\n const dek: TenantDek = { tenantId, key, fetchedAt: this.now() }\n return this.remember(dek)\n }\n\n async createTenantDek(tenantId: string): Promise<TenantDek | null> {\n const key = generateDek()\n const path = this.buildKeyPath(tenantId)\n const ok = await this.writeVault(path, key)\n if (ok) {\n console.info('\uD83D\uDD11 [encryption][kms] Stored tenant DEK in Vault', { tenantId, path })\n } else {\n console.warn('\u26A0\uFE0F [encryption][kms] Failed to store tenant DEK in Vault', { tenantId, path })\n }\n if (!ok) return null\n return this.remember({ tenantId, key, fetchedAt: this.now() })\n }\n}\n\nlet loggedDerivedKeyFallbackBanner = false\n\nfunction fingerprintSecret(secret: string): string {\n return crypto.createHash('sha256').update(secret, 'utf8').digest('hex').slice(0, 16)\n}\n\nexport function buildDerivedKeyFallbackBannerLines(opts: DerivedSecret): string[] {\n const sourceLine =\n opts.source === 'explicit' ? `Source: ${opts.envName}` : 'Source: dev default secret (do NOT use in production)'\n return [\n '\uD83D\uDEA8 Using derived tenant encryption keys (Vault unavailable / no DEK)',\n sourceLine,\n `Secret fingerprint (sha256, truncated): ${fingerprintSecret(opts.secret)}`,\n 'Persist this secret securely. Without it, encrypted tenant data cannot be recovered after restart.',\n ]\n}\n\nfunction logDerivedKeyFallbackBanner(opts: DerivedSecret): void {\n if (process.env.NODE_ENV === 'test' || loggedDerivedKeyFallbackBanner) return\n loggedDerivedKeyFallbackBanner = true\n const redBg = '\\x1b[41m'\n const white = '\\x1b[97m'\n const reset = '\\x1b[0m'\n const width = 110\n const border = `${redBg}${white}${'\u2501'.repeat(width)}${reset}`\n const body = buildDerivedKeyFallbackBannerLines(opts)\n console.warn(border)\n for (const line of body) {\n const padded = line.padEnd(width - 2, ' ')\n console.warn(`${redBg}${white} ${padded} ${reset}`)\n }\n console.warn(border)\n}\n\nexport function createKmsService(): KmsService {\n if (!isTenantDataEncryptionEnabled()) return new NoopKmsService()\n const primary = new HashicorpVaultKmsService()\n\n const derived = resolveDerivedKeySecret()\n const fallback = derived ? new DerivedKmsService(derived.secret) : null\n const notifyFallback = derived\n ? () => {\n logDerivedKeyFallbackBanner(derived)\n }\n : undefined\n\n if (!primary.isHealthy()) {\n if (fallback) {\n notifyFallback?.()\n return fallback\n }\n console.warn(\n '\u26A0\uFE0F [encryption][kms] Vault not healthy or misconfigured (missing VAULT_ADDR/VAULT_TOKEN) and no fallback secret provided; falling back to noop KMS',\n )\n return new NoopKmsService()\n }\n\n if (fallback) {\n return new FallbackKmsService(primary, fallback, notifyFallback)\n }\n\n return primary\n}\n\nexport { hashForLookup }\n"],
5
- "mappings": "AAAA,OAAO,YAAY;AACnB,SAAS,aAAa,qBAAqB;AAC3C,SAAS,0BAA0B,qCAAqC;AACxE,SAAS,yBAAyB;AAClC,SAAS,kBAAkB,wBAAwB;AAEnD,MAAM,mCAAmC;AAEzC,SAAS,+BAAuC;AAC9C,QAAM,MAAM,QAAQ,IAAI;AACxB,QAAM,SAAS,MAAM,OAAO,SAAS,KAAK,EAAE,IAAI;AAChD,SAAO,iBAAiB,QAAQ,gCAAgC;AAClE;AAcA,MAAM,mBAAyC;AAAA,EAE7C,YACmB,SACA,UACA,YACjB;AAHiB;AACA;AACA;AAJnB,SAAQ,WAAW;AAAA,EAKhB;AAAA,EAEH,YAAqB;AACnB,WAAO,KAAK,QAAQ,UAAU,KAAK,QAAQ,KAAK,UAAU,YAAY,CAAC;AAAA,EACzE;AAAA,EAEQ,iBAAiB;AACvB,QAAI,KAAK,SAAU;AACnB,SAAK,WAAW;AAChB,SAAK,aAAa;AAAA,EACpB;AAAA,EAEA,MAAc,YAAe,IAAgD;AAC3E,QAAI;AACF,aAAO,MAAM,GAAG;AAAA,IAClB,SAAS,KAAK;AACZ,cAAQ,KAAK,wEAA8D;AAAA,QACzE,OAAQ,KAAe,WAAW,OAAO,GAAG;AAAA,MAC9C,CAAC;AACD,aAAO;AAAA,IACT;AAAA,EACF;AAAA,EAEA,MAAM,aAAa,UAA6C;AAC9D,QAAI,KAAK,QAAQ,UAAU,GAAG;AAC5B,YAAM,MAAM,MAAM,KAAK,YAAY,MAAM,KAAK,QAAQ,aAAa,QAAQ,CAAC;AAC5E,UAAI,IAAK,QAAO;AAAA,IAClB;AACA,QAAI,KAAK,UAAU,UAAU,GAAG;AAC9B,WAAK,eAAe;AACpB,aAAO,KAAK,SAAS,aAAa,QAAQ;AAAA,IAC5C;AACA,WAAO;AAAA,EACT;AAAA,EAEA,MAAM,gBAAgB,UAA6C;AACjE,QAAI,KAAK,QAAQ,UAAU,GAAG;AAC5B,YAAM,MAAM,MAAM,KAAK,YAAY,MAAM,KAAK,QAAQ,gBAAgB,QAAQ,CAAC;AAC/E,UAAI,IAAK,QAAO;AAAA,IAClB;AACA,QAAI,KAAK,UAAU,UAAU,GAAG;AAC9B,WAAK,eAAe;AACpB,aAAO,KAAK,SAAS,gBAAgB,QAAQ;AAAA,IAC/C;AACA,WAAO;AAAA,EACT;AACF;AAcA,SAAS,aAAa,OAAmC;AACvD,MAAI,CAAC,MAAO,QAAO;AACnB,SAAO,MAAM,KAAK,EAAE,QAAQ,oBAAoB,EAAE;AACpD;AAIA,SAAS,0BAAgD;AACvD,QAAM,aAA+D;AAAA,IACnE,EAAE,OAAO,QAAQ,IAAI,uCAAuC,MAAM,SAAS,sCAAsC;AAAA,IACjH,EAAE,OAAO,QAAQ,IAAI,8BAA8B,MAAM,SAAS,6BAA6B;AAAA,EACjG;AACA,aAAW,OAAO,YAAY;AAC5B,UAAM,aAAa,aAAa,IAAI,SAAS,MAAS;AACtD,QAAI,WAAY,QAAO,EAAE,QAAQ,YAAY,QAAQ,YAAY,SAAS,IAAI,QAAQ;AAAA,EACxF;AACA,MACE,QAAQ,IAAI,aAAa,gBACtB,kBAAkB,QAAQ,IAAI,0BAA0B,MAAM,MACjE;AACA,WAAO,EAAE,QAAQ,4BAA4B,QAAQ,eAAe,SAAS,cAAc;AAAA,EAC7F;AACA,SAAO;AACT;AAEO,MAAM,eAAqC;AAAA,EAChD,YAAqB;AAAE,WAAO,CAAC,8BAA8B;AAAA,EAAE;AAAA,EAC/D,MAAM,eAA0C;AAAE,WAAO;AAAA,EAAK;AAAA,EAC9D,MAAM,kBAA6C;AAAE,WAAO;AAAA,EAAK;AACnE;AAEA,MAAM,kBAAwC;AAAA,EAE5C,YAAY,QAAgB;AAE1B,SAAK,OAAO,OAAO,WAAW,QAAQ,EAAE,OAAO,MAAM,EAAE,OAAO;AAAA,EAChE;AAAA,EAEA,YAAqB;AACnB,WAAO;AAAA,EACT;AAAA,EAEQ,UAAU,UAA0B;AAC1C,UAAM,aAAa;AACnB,UAAM,YAAY;AAClB,UAAM,UAAU,OAAO,WAAW,KAAK,MAAM,UAAU,YAAY,WAAW,QAAQ;AACtF,WAAO,QAAQ,SAAS,QAAQ;AAAA,EAClC;AAAA,EAEA,MAAM,aAAa,UAA6C;AAC9D,QAAI,CAAC,SAAU,QAAO;AACtB,WAAO,EAAE,UAAU,KAAK,KAAK,UAAU,QAAQ,GAAG,WAAW,KAAK,IAAI,EAAE;AAAA,EAC1E;AAAA,EAEA,MAAM,gBAAgB,UAA6C;AACjE,WAAO,KAAK,aAAa,QAAQ;AAAA,EACnC;AACF;AAEO,MAAM,yBAA+C;AAAA,EAW1D,YAAY,OAAwB,CAAC,GAAG;AAVxC,SAAQ,QAAQ,oBAAI,IAAuB;AAM3C,SAAQ,UAAU;AAKhB,SAAK,YAAY,aAAa,KAAK,aAAa,QAAQ,IAAI,cAAc,EAAE;AAC5E,SAAK,aAAa,aAAa,KAAK,cAAc,QAAQ,IAAI,eAAe,EAAE;AAC/E,SAAK,aAAa,KAAK,aAAa,QAAQ,IAAI,iBAAiB,eAAe,QAAQ,QAAQ,EAAE;AAClG,SAAK,QAAQ,KAAK,SAAS,KAAK,KAAK;AACrC,SAAK,mBAAmB,iBAAiB,KAAK,kBAAkB,6BAA6B,CAAC;AAC9F,SAAK,eAAe,yBAAyB;AAC7C,QAAI,CAAC,KAAK,aAAa,CAAC,KAAK,YAAY;AACvC,WAAK,UAAU;AACf,UAAI,KAAK,cAAc;AACrB,gBAAQ,KAAK,wFAA8E;AAAA,MAC7F;AAAA,IACF;AACA,QAAI,KAAK,WAAW,CAAC,yBAAyB,cAAc,KAAK,cAAc;AAC7E,+BAAyB,aAAa;AACtC,UAAG,KAAK,cAAc;AACpB,gBAAQ,KAAK,yDAAkD;AAAA,MACjE;AAAA,IACF;AAAA,EACF;AAAA,EArBA;AAAA,SAAe,aAAa;AAAA;AAAA,EAuB5B,YAAqB;AACnB,WAAO,KAAK;AAAA,EACd;AAAA,EAEQ,MAAc;AACpB,WAAO,KAAK,IAAI;AAAA,EAClB;AAAA,EAEQ,SAAS,UAAoC;AACnD,UAAM,QAAQ,KAAK,MAAM,IAAI,QAAQ;AACrC,QAAI,CAAC,MAAO,QAAO;AACnB,QAAI,KAAK,IAAI,IAAI,MAAM,YAAY,KAAK,OAAO;AAC7C,WAAK,MAAM,OAAO,QAAQ;AAC1B,aAAO;AAAA,IACT;AACA,WAAO;AAAA,EACT;AAAA,EAEA,MAAc,UAAU,MAAiD;AACvE,QAAI,CAAC,KAAK,aAAa,CAAC,KAAK,YAAY;AACvC,WAAK,UAAU;AACf,aAAO;AAAA,IACT;AACA,QAAI;AACF,YAAM,MAAM,MAAM,iBAAiB,GAAG,KAAK,SAAS,OAAO,IAAI,IAAI;AAAA,QACjE,QAAQ;AAAA,QACR,SAAS,EAAE,iBAAiB,KAAK,WAAW;AAAA,QAC5C,WAAW,KAAK;AAAA,MAClB,CAAC;AACD,UAAI,CAAC,IAAI,IAAI;AACX,aAAK,UAAU,IAAI,SAAS;AAC5B,gBAAQ,KAAK,oDAA0C,EAAE,MAAM,QAAQ,IAAI,OAAO,CAAC;AACnF,eAAO;AAAA,MACT;AACA,UAAI,KAAK,cAAc;AACrB,gBAAQ,KAAK,6CAAsC,EAAE,KAAK,CAAC;AAAA,MAC7D;AACA,aAAQ,MAAM,IAAI,KAAK;AAAA,IACzB,SAAS,KAAK;AACZ,WAAK,UAAU;AACf,cAAQ,KAAK,mDAAyC;AAAA,QACpD;AAAA,QACA,OAAQ,KAAe,WAAW,OAAO,GAAG;AAAA,QAC5C,WAAW,KAAK;AAAA,MAClB,CAAC;AACD,aAAO;AAAA,IACT;AAAA,EACF;AAAA,EAEA,MAAc,WAAW,MAAc,KAA+B;AACpE,QAAI,CAAC,KAAK,aAAa,CAAC,KAAK,YAAY;AACvC,WAAK,UAAU;AACf,aAAO;AAAA,IACT;AACA,QAAI;AACF,YAAM,MAAM,MAAM,iBAAiB,GAAG,KAAK,SAAS,OAAO,IAAI,IAAI;AAAA,QACjE,QAAQ;AAAA,QACR,SAAS;AAAA,UACP,iBAAiB,KAAK;AAAA,UACtB,gBAAgB;AAAA,QAClB;AAAA,QACA,MAAM,KAAK,UAAU,EAAE,MAAM,EAAE,IAAI,EAAE,CAAC;AAAA,QACtC,WAAW,KAAK;AAAA,MAClB,CAAC;AACD,WAAK,UAAU,IAAI;AACnB,UAAI,CAAC,IAAI,IAAI;AACX,gBAAQ,KAAK,qDAA2C,EAAE,MAAM,QAAQ,IAAI,OAAO,CAAC;AAAA,MACtF;AACA,aAAO,IAAI;AAAA,IACb,SAAS,KAAK;AACZ,WAAK,UAAU;AACf,cAAQ,KAAK,oDAA0C;AAAA,QACrD;AAAA,QACA,OAAQ,KAAe,WAAW,OAAO,GAAG;AAAA,QAC5C,WAAW,KAAK;AAAA,MAClB,CAAC;AACD,aAAO;AAAA,IACT;AAAA,EACF;AAAA,EAEQ,aAAa,UAA0B;AAC7C,UAAM,SAAS,cAAc,QAAQ;AACrC,UAAM,kBAAkB,KAAK,UAAU,QAAQ,QAAQ,EAAE;AACzD,WAAO,GAAG,eAAe,IAAI,MAAM;AAAA,EACrC;AAAA,EAEQ,SAAS,OAA6B;AAC5C,SAAK,MAAM,IAAI,MAAM,UAAU,KAAK;AACpC,WAAO;AAAA,EACT;AAAA,EAEA,MAAM,aAAa,UAA6C;AAC9D,UAAM,SAAS,KAAK,SAAS,QAAQ;AACrC,QAAI,OAAQ,QAAO;AACnB,UAAM,OAAO,KAAK,aAAa,QAAQ;AACvC,UAAM,MAAM,MAAM,KAAK,UAAU,IAAI;AACrC,UAAM,MAAM,KAAK,MAAM,MAAM;AAC7B,QAAI,CAAC,KAAK;AACR,cAAQ,KAAK,+DAAqD,EAAE,UAAU,KAAK,CAAC;AACpF,aAAO;AAAA,IACT;AACA,UAAM,MAAiB,EAAE,UAAU,KAAK,WAAW,KAAK,IAAI,EAAE;AAC9D,WAAO,KAAK,SAAS,GAAG;AAAA,EAC1B;AAAA,EAEA,MAAM,gBAAgB,UAA6C;AACjE,UAAM,MAAM,YAAY;AACxB,UAAM,OAAO,KAAK,aAAa,QAAQ;AACvC,UAAM,KAAK,MAAM,KAAK,WAAW,MAAM,GAAG;AAC1C,QAAI,IAAI;AACN,cAAQ,KAAK,0DAAmD,EAAE,UAAU,KAAK,CAAC;AAAA,IACpF,OAAO;AACL,cAAQ,KAAK,sEAA4D,EAAE,UAAU,KAAK,CAAC;AAAA,IAC7F;AACA,QAAI,CAAC,GAAI,QAAO;AAChB,WAAO,KAAK,SAAS,EAAE,UAAU,KAAK,WAAW,KAAK,IAAI,EAAE,CAAC;AAAA,EAC/D;AACF;AAEA,IAAI,iCAAiC;AAErC,SAAS,kBAAkB,QAAwB;AACjD,SAAO,OAAO,WAAW,QAAQ,EAAE,OAAO,QAAQ,MAAM,EAAE,OAAO,KAAK,EAAE,MAAM,GAAG,EAAE;AACrF;AAEO,SAAS,mCAAmC,MAA+B;AAChF,QAAM,aACJ,KAAK,WAAW,aAAa,WAAW,KAAK,OAAO,KAAK;AAC3D,SAAO;AAAA,IACL;AAAA,IACA;AAAA,IACA,2CAA2C,kBAAkB,KAAK,MAAM,CAAC;AAAA,IACzE;AAAA,EACF;AACF;AAEA,SAAS,4BAA4B,MAA2B;AAC9D,MAAI,QAAQ,IAAI,aAAa,UAAU,+BAAgC;AACvE,mCAAiC;AACjC,QAAM,QAAQ;AACd,QAAM,QAAQ;AACd,QAAM,QAAQ;AACd,QAAM,QAAQ;AACd,QAAM,SAAS,GAAG,KAAK,GAAG,KAAK,GAAG,SAAI,OAAO,KAAK,CAAC,GAAG,KAAK;AAC3D,QAAM,OAAO,mCAAmC,IAAI;AACpD,UAAQ,KAAK,MAAM;AACnB,aAAW,QAAQ,MAAM;AACvB,UAAM,SAAS,KAAK,OAAO,QAAQ,GAAG,GAAG;AACzC,YAAQ,KAAK,GAAG,KAAK,GAAG,KAAK,IAAI,MAAM,IAAI,KAAK,EAAE;AAAA,EACpD;AACA,UAAQ,KAAK,MAAM;AACrB;AAEO,SAAS,mBAA+B;AAC7C,MAAI,CAAC,8BAA8B,EAAG,QAAO,IAAI,eAAe;AAChE,QAAM,UAAU,IAAI,yBAAyB;AAE7C,QAAM,UAAU,wBAAwB;AACxC,QAAM,WAAW,UAAU,IAAI,kBAAkB,QAAQ,MAAM,IAAI;AACnE,QAAM,iBAAiB,UACnB,MAAM;AACJ,gCAA4B,OAAO;AAAA,EACrC,IACA;AAEJ,MAAI,CAAC,QAAQ,UAAU,GAAG;AACxB,QAAI,UAAU;AACZ,uBAAiB;AACjB,aAAO;AAAA,IACT;AACA,YAAQ;AAAA,MACN;AAAA,IACF;AACA,WAAO,IAAI,eAAe;AAAA,EAC5B;AAEA,MAAI,UAAU;AACZ,WAAO,IAAI,mBAAmB,SAAS,UAAU,cAAc;AAAA,EACjE;AAEA,SAAO;AACT;",
4
+ "sourcesContent": ["import crypto from 'node:crypto'\nimport { generateDek, hashForLookup } from './aes'\nimport { isEncryptionDebugEnabled, isTenantDataEncryptionEnabled } from './toggles'\nimport { parseBooleanToken } from '../boolean'\nimport { fetchWithTimeout, resolveTimeoutMs } from '../http/fetchWithTimeout'\n\nconst DEFAULT_VAULT_REQUEST_TIMEOUT_MS = 1_000\n\nfunction resolveVaultRequestTimeoutMs(): number {\n const raw = process.env.VAULT_REQUEST_TIMEOUT_MS\n const parsed = raw ? Number.parseInt(raw, 10) : undefined\n return resolveTimeoutMs(parsed, DEFAULT_VAULT_REQUEST_TIMEOUT_MS)\n}\n\nexport type TenantDek = {\n tenantId: string\n key: string // base64\n fetchedAt: number\n}\n\nexport interface KmsService {\n getTenantDek(tenantId: string): Promise<TenantDek | null>\n createTenantDek(tenantId: string): Promise<TenantDek | null>\n isHealthy(): boolean\n invalidateDek?(tenantId: string): void\n}\n\nclass FallbackKmsService implements KmsService {\n private notified = false\n constructor(\n private readonly primary: KmsService,\n private readonly fallback: KmsService | null,\n private readonly onFallback?: () => void,\n ) {}\n\n isHealthy(): boolean {\n return this.primary.isHealthy() || Boolean(this.fallback?.isHealthy?.())\n }\n\n private notifyFallback() {\n if (this.notified) return\n this.notified = true\n this.onFallback?.()\n }\n\n private async fromPrimary<T>(op: () => Promise<T | null>): Promise<T | null> {\n try {\n return await op()\n } catch (err) {\n console.warn('\u26A0\uFE0F [encryption][kms] Primary KMS failed, will try fallback', {\n error: (err as Error)?.message || String(err),\n })\n return null\n }\n }\n\n async getTenantDek(tenantId: string): Promise<TenantDek | null> {\n if (this.primary.isHealthy()) {\n const dek = await this.fromPrimary(() => this.primary.getTenantDek(tenantId))\n if (dek) return dek\n }\n if (this.fallback?.isHealthy()) {\n this.notifyFallback()\n return this.fallback.getTenantDek(tenantId)\n }\n return null\n }\n\n async createTenantDek(tenantId: string): Promise<TenantDek | null> {\n if (this.primary.isHealthy()) {\n const dek = await this.fromPrimary(() => this.primary.createTenantDek(tenantId))\n if (dek) return dek\n }\n if (this.fallback?.isHealthy()) {\n this.notifyFallback()\n return this.fallback.createTenantDek(tenantId)\n }\n return null\n }\n\n invalidateDek(tenantId: string): void {\n this.primary.invalidateDek?.(tenantId)\n this.fallback?.invalidateDek?.(tenantId)\n }\n}\n\ntype VaultClientOpts = {\n vaultAddr?: string\n vaultToken?: string\n mountPath?: string\n ttlMs?: number\n requestTimeoutMs?: number\n}\n\ntype VaultReadResponse = {\n data?: { data?: { key?: string; version?: number }; metadata?: Record<string, unknown> }\n}\n\n// 'conflict' = a check-and-set write lost to a concurrent writer (normal race\n// outcome, Vault still healthy); 'error' = the write genuinely failed.\ntype VaultWriteOutcome = 'ok' | 'conflict' | 'error'\n\nfunction normalizeEnv(value: string | undefined): string {\n if (!value) return ''\n return value.trim().replace(/(?:^['\"]|['\"]$)/g, '')\n}\n\ntype DerivedSecret = { secret: string; source: 'explicit' | 'dev-default'; envName: string }\n\nfunction resolveDerivedKeySecret(): DerivedSecret | null {\n const candidates: Array<{ value: string | null; envName: string }> = [\n { value: process.env.TENANT_DATA_ENCRYPTION_FALLBACK_KEY ?? null, envName: 'TENANT_DATA_ENCRYPTION_FALLBACK_KEY' },\n { value: process.env.TENANT_DATA_ENCRYPTION_KEY ?? null, envName: 'TENANT_DATA_ENCRYPTION_KEY' },\n ]\n for (const raw of candidates) {\n const normalized = normalizeEnv(raw.value ?? undefined)\n if (normalized) return { secret: normalized, source: 'explicit', envName: raw.envName }\n }\n if (\n process.env.NODE_ENV !== 'production'\n && parseBooleanToken(process.env.ALLOW_DERIVED_KMS_FALLBACK) === true\n ) {\n return { secret: 'om-dev-tenant-encryption', source: 'dev-default', envName: 'DEV_DEFAULT' }\n }\n return null\n}\n\nexport class NoopKmsService implements KmsService {\n isHealthy(): boolean { return !isTenantDataEncryptionEnabled() }\n async getTenantDek(): Promise<TenantDek | null> { return null }\n async createTenantDek(): Promise<TenantDek | null> { return null }\n}\n\nclass DerivedKmsService implements KmsService {\n private root: Buffer\n constructor(secret: string) {\n // Derive a stable root key from the provided secret so derived tenant keys are deterministic\n this.root = crypto.createHash('sha256').update(secret).digest()\n }\n\n isHealthy(): boolean {\n return true\n }\n\n private deriveKey(tenantId: string): string {\n const iterations = 310_000\n const keyLength = 32\n const derived = crypto.pbkdf2Sync(this.root, tenantId, iterations, keyLength, 'sha512')\n return derived.toString('base64')\n }\n\n async getTenantDek(tenantId: string): Promise<TenantDek | null> {\n if (!tenantId) return null\n return { tenantId, key: this.deriveKey(tenantId), fetchedAt: Date.now() }\n }\n\n async createTenantDek(tenantId: string): Promise<TenantDek | null> {\n return this.getTenantDek(tenantId)\n }\n}\n\nexport class HashicorpVaultKmsService implements KmsService {\n private cache = new Map<string, TenantDek>()\n private readonly vaultAddr: string\n private readonly vaultToken: string\n private readonly mountPath: string\n private readonly ttlMs: number\n private readonly requestTimeoutMs: number\n private healthy = true\n private readonly debugEnabled: boolean\n private static loggedInit = false\n\n constructor(opts: VaultClientOpts = {}) {\n this.vaultAddr = normalizeEnv(opts.vaultAddr || process.env.VAULT_ADDR || '')\n this.vaultToken = normalizeEnv(opts.vaultToken || process.env.VAULT_TOKEN || '')\n this.mountPath = (opts.mountPath || process.env.VAULT_KV_PATH || 'secret/data').replace(/\\/+$/, '')\n this.ttlMs = opts.ttlMs ?? 15 * 60 * 1000\n this.requestTimeoutMs = resolveTimeoutMs(opts.requestTimeoutMs, resolveVaultRequestTimeoutMs())\n this.debugEnabled = isEncryptionDebugEnabled()\n if (!this.vaultAddr || !this.vaultToken) {\n this.healthy = false\n if (this.debugEnabled) {\n console.warn('\u26A0\uFE0F [encryption][kms] Vault misconfigured (missing VAULT_ADDR or VAULT_TOKEN)')\n }\n }\n if (this.healthy && !HashicorpVaultKmsService.loggedInit && this.debugEnabled) {\n HashicorpVaultKmsService.loggedInit = true\n if(this.debugEnabled) {\n console.info('\uD83D\uDD10 [encryption][kms] Hashicorp Vault KMS enabled')\n }\n }\n }\n\n isHealthy(): boolean {\n return this.healthy\n }\n\n private now(): number {\n return Date.now()\n }\n\n private cacheHit(tenantId: string): TenantDek | null {\n const entry = this.cache.get(tenantId)\n if (!entry) return null\n if (this.now() - entry.fetchedAt > this.ttlMs) {\n this.cache.delete(tenantId)\n return null\n }\n return entry\n }\n\n private async readVault(path: string): Promise<VaultReadResponse | null> {\n if (!this.vaultAddr || !this.vaultToken) {\n this.healthy = false\n return null\n }\n try {\n const res = await fetchWithTimeout(`${this.vaultAddr}/v1/${path}`, {\n method: 'GET',\n headers: { 'X-Vault-Token': this.vaultToken },\n timeoutMs: this.requestTimeoutMs,\n })\n if (!res.ok) {\n this.healthy = res.status < 500\n console.warn('\u26A0\uFE0F [encryption][kms] Vault read failed', { path, status: res.status })\n return null\n }\n if (this.debugEnabled) {\n console.info('\uD83D\uDD0D [encryption][kms] Vault read ok', { path })\n }\n return (await res.json()) as VaultReadResponse\n } catch (err) {\n this.healthy = false\n console.warn('\u26A0\uFE0F [encryption][kms] Vault read error', {\n path,\n error: (err as Error)?.message || String(err),\n timeoutMs: this.requestTimeoutMs,\n })\n return null\n }\n }\n\n private async writeVault(path: string, key: string, opts?: { cas?: number }): Promise<VaultWriteOutcome> {\n if (!this.vaultAddr || !this.vaultToken) {\n this.healthy = false\n return 'error'\n }\n const body: { data: { key: string }; options?: { cas: number } } = { data: { key } }\n if (typeof opts?.cas === 'number') body.options = { cas: opts.cas }\n try {\n const res = await fetchWithTimeout(`${this.vaultAddr}/v1/${path}`, {\n method: 'POST',\n headers: {\n 'X-Vault-Token': this.vaultToken,\n 'Content-Type': 'application/json',\n },\n body: JSON.stringify(body),\n timeoutMs: this.requestTimeoutMs,\n })\n if (res.ok) {\n this.healthy = true\n return 'ok'\n }\n // KV v2 returns 400 when a check-and-set write loses to a concurrent\n // writer (path already at a newer version). That is a normal race outcome,\n // not an unhealthy Vault \u2014 don't flip `healthy`.\n if (typeof opts?.cas === 'number' && res.status === 400) {\n console.warn('\u26A0\uFE0F [encryption][kms] Vault write CAS conflict (concurrent DEK create)', { path, status: res.status })\n return 'conflict'\n }\n this.healthy = false\n console.warn('\u26A0\uFE0F [encryption][kms] Vault write failed', { path, status: res.status })\n return 'error'\n } catch (err) {\n this.healthy = false\n console.warn('\u26A0\uFE0F [encryption][kms] Vault write error', {\n path,\n error: (err as Error)?.message || String(err),\n timeoutMs: this.requestTimeoutMs,\n })\n return 'error'\n }\n }\n\n private buildKeyPath(tenantId: string): string {\n const suffix = `tenant_key_${tenantId}`\n const normalizedMount = this.mountPath.replace(/^\\/+/, '')\n return `${normalizedMount}/${suffix}`\n }\n\n private remember(entry: TenantDek): TenantDek {\n this.cache.set(entry.tenantId, entry)\n return entry\n }\n\n async getTenantDek(tenantId: string): Promise<TenantDek | null> {\n const cached = this.cacheHit(tenantId)\n if (cached) return cached\n const path = this.buildKeyPath(tenantId)\n const res = await this.readVault(path)\n const key = res?.data?.data?.key\n if (!key) {\n console.warn('\u26A0\uFE0F [encryption][kms] No tenant DEK found in Vault', { tenantId, path })\n return null\n }\n const dek: TenantDek = { tenantId, key, fetchedAt: this.now() }\n return this.remember(dek)\n }\n\n async createTenantDek(tenantId: string): Promise<TenantDek | null> {\n const path = this.buildKeyPath(tenantId)\n // Read-before-write: if a DEK already exists for this tenant (another request\n // or process created it first), adopt it instead of overwriting the active\n // key \u2014 overwriting orphans every row already encrypted under it (#2746).\n const existing = await this.readVault(path)\n const existingKey = existing?.data?.data?.key\n if (existingKey) {\n return this.remember({ tenantId, key: existingKey, fetchedAt: this.now() })\n }\n // A read failure (timeout / 5xx) flips `healthy` off; don't blind-write a new\n // key over a possibly-existing one we just couldn't read \u2014 let the caller fall back.\n if (!this.healthy) return null\n const key = generateDek()\n const outcome = await this.writeVault(path, key, { cas: 0 })\n if (outcome === 'ok') {\n console.info('\uD83D\uDD11 [encryption][kms] Stored tenant DEK in Vault', { tenantId, path })\n return this.remember({ tenantId, key, fetchedAt: this.now() })\n }\n if (outcome === 'conflict') {\n // A concurrent create won the CAS race \u2014 adopt the winner's key so both\n // callers encrypt under the same DEK.\n const winner = await this.readVault(path)\n const winnerKey = winner?.data?.data?.key\n if (winnerKey) {\n console.info('\uD83D\uDD11 [encryption][kms] Adopted concurrently-created tenant DEK', { tenantId, path })\n return this.remember({ tenantId, key: winnerKey, fetchedAt: this.now() })\n }\n }\n console.warn('\u26A0\uFE0F [encryption][kms] Failed to store tenant DEK in Vault', { tenantId, path })\n return null\n }\n\n invalidateDek(tenantId: string): void {\n this.cache.delete(tenantId)\n }\n}\n\nlet loggedDerivedKeyFallbackBanner = false\n\nfunction fingerprintSecret(secret: string): string {\n return crypto.createHash('sha256').update(secret, 'utf8').digest('hex').slice(0, 16)\n}\n\nexport function buildDerivedKeyFallbackBannerLines(opts: DerivedSecret): string[] {\n const sourceLine =\n opts.source === 'explicit' ? `Source: ${opts.envName}` : 'Source: dev default secret (do NOT use in production)'\n return [\n '\uD83D\uDEA8 Using derived tenant encryption keys (Vault unavailable / no DEK)',\n sourceLine,\n `Secret fingerprint (sha256, truncated): ${fingerprintSecret(opts.secret)}`,\n 'Persist this secret securely. Without it, encrypted tenant data cannot be recovered after restart.',\n ]\n}\n\nfunction logDerivedKeyFallbackBanner(opts: DerivedSecret): void {\n if (process.env.NODE_ENV === 'test' || loggedDerivedKeyFallbackBanner) return\n loggedDerivedKeyFallbackBanner = true\n const redBg = '\\x1b[41m'\n const white = '\\x1b[97m'\n const reset = '\\x1b[0m'\n const width = 110\n const border = `${redBg}${white}${'\u2501'.repeat(width)}${reset}`\n const body = buildDerivedKeyFallbackBannerLines(opts)\n console.warn(border)\n for (const line of body) {\n const padded = line.padEnd(width - 2, ' ')\n console.warn(`${redBg}${white} ${padded} ${reset}`)\n }\n console.warn(border)\n}\n\nexport function createKmsService(): KmsService {\n if (!isTenantDataEncryptionEnabled()) return new NoopKmsService()\n const primary = new HashicorpVaultKmsService()\n\n const derived = resolveDerivedKeySecret()\n const fallback = derived ? new DerivedKmsService(derived.secret) : null\n const notifyFallback = derived\n ? () => {\n logDerivedKeyFallbackBanner(derived)\n }\n : undefined\n\n if (!primary.isHealthy()) {\n if (fallback) {\n notifyFallback?.()\n return fallback\n }\n console.warn(\n '\u26A0\uFE0F [encryption][kms] Vault not healthy or misconfigured (missing VAULT_ADDR/VAULT_TOKEN) and no fallback secret provided; falling back to noop KMS',\n )\n return new NoopKmsService()\n }\n\n if (fallback) {\n return new FallbackKmsService(primary, fallback, notifyFallback)\n }\n\n return primary\n}\n\nexport { hashForLookup }\n"],
5
+ "mappings": "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",
6
6
  "names": []
7
7
  }
@@ -2,6 +2,7 @@ import { decryptWithAesGcm, encryptWithAesGcm, hashForLookup } from "./aes.js";
2
2
  import { createKmsService } from "./kms.js";
3
3
  import { isTenantDataEncryptionEnabled, isEncryptionDebugEnabled } from "./toggles.js";
4
4
  const MAP_MISS_TTL_MS = 5 * 60 * 1e3;
5
+ const DEK_CACHE_TTL_MS = 15 * 60 * 1e3;
5
6
  function cacheKey(key) {
6
7
  return [
7
8
  "encmap",
@@ -47,6 +48,7 @@ class TenantDataEncryptionService {
47
48
  this.em = em;
48
49
  this.memoryCache = TenantDataEncryptionService.globalMemoryCache;
49
50
  this.dekCache = TenantDataEncryptionService.globalDekCache;
51
+ this.inflightDeks = TenantDataEncryptionService.globalInflightDeks;
50
52
  this.inflightMaps = TenantDataEncryptionService.globalInflightMaps;
51
53
  this.missCache = TenantDataEncryptionService.globalMissCache;
52
54
  this.cache = opts?.cache;
@@ -61,16 +63,23 @@ class TenantDataEncryptionService {
61
63
  static {
62
64
  this.globalDekCache = /* @__PURE__ */ new Map();
63
65
  }
66
+ static {
67
+ this.globalInflightDeks = /* @__PURE__ */ new Map();
68
+ }
64
69
  static {
65
70
  this.globalMissCache = /* @__PURE__ */ new Map();
66
71
  }
67
72
  isEnabled() {
68
73
  return isTenantDataEncryptionEnabled() && this.kms.isHealthy();
69
74
  }
75
+ isDekExpired(dek) {
76
+ return Date.now() - dek.fetchedAt > DEK_CACHE_TTL_MS;
77
+ }
70
78
  async getDek(tenantId) {
71
79
  if (!tenantId) return null;
72
80
  const cached = this.dekCache.get(tenantId);
73
- if (cached) return cached;
81
+ if (cached && !this.isDekExpired(cached)) return cached;
82
+ if (cached) this.dekCache.delete(tenantId);
74
83
  const dek = await this.kms.getTenantDek(tenantId);
75
84
  if (!dek) {
76
85
  debug("\u{1F50E} dek.miss", { tenantId });
@@ -84,9 +93,19 @@ class TenantDataEncryptionService {
84
93
  const existing = await this.getDek(tenantId);
85
94
  if (existing || !tenantId) return existing ?? null;
86
95
  if (typeof this.kms.createTenantDek !== "function") return existing ?? null;
87
- const created = await this.kms.createTenantDek(tenantId);
88
- if (created) this.dekCache.set(tenantId, created);
89
- return created ?? null;
96
+ const pending = this.inflightDeks.get(tenantId);
97
+ if (pending) return pending;
98
+ const creation = (async () => {
99
+ const created = await this.kms.createTenantDek(tenantId);
100
+ if (created) this.dekCache.set(tenantId, created);
101
+ return created ?? null;
102
+ })();
103
+ this.inflightDeks.set(tenantId, creation);
104
+ try {
105
+ return await creation;
106
+ } finally {
107
+ this.inflightDeks.delete(tenantId);
108
+ }
90
109
  }
91
110
  async createDek(tenantId) {
92
111
  const dek = await this.kms.createTenantDek(tenantId);
@@ -175,6 +194,14 @@ class TenantDataEncryptionService {
175
194
  await this.cache.delete(tag);
176
195
  }
177
196
  }
197
+ // Force a flush of a tenant's cached DEK across the service-level cache and the
198
+ // underlying KMS cache so an operator can pick up a rotated/revoked key without
199
+ // a process restart (#2746).
200
+ invalidateDek(tenantId) {
201
+ this.dekCache.delete(tenantId);
202
+ this.inflightDeks.delete(tenantId);
203
+ this.kms.invalidateDek?.(tenantId);
204
+ }
178
205
  async getEncryptedFieldNames(entityId, tenantId, organizationId) {
179
206
  if (!this.isEnabled()) return [];
180
207
  const map = await this.getMap({ entityId, tenantId: tenantId ?? null, organizationId: organizationId ?? null });