@open-mercato/shared 0.6.6-develop.5509.1.006f4d4f24 → 0.6.6-develop.5523.1.e223ca1915
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/lib/db/mikro.js +38 -9
- package/dist/lib/db/mikro.js.map +2 -2
- package/dist/lib/encryption/kms.js +41 -6
- package/dist/lib/encryption/kms.js.map +2 -2
- package/dist/lib/version.js +1 -1
- package/dist/lib/version.js.map +1 -1
- package/package.json +2 -2
- package/src/lib/db/__tests__/mikro.test.ts +82 -0
- package/src/lib/db/mikro.ts +55 -16
- package/src/lib/encryption/__tests__/kms.test.ts +80 -0
- package/src/lib/encryption/kms.ts +55 -7
package/dist/lib/db/mikro.js
CHANGED
|
@@ -25,6 +25,28 @@ function getOrmEntities() {
|
|
|
25
25
|
}
|
|
26
26
|
return entities;
|
|
27
27
|
}
|
|
28
|
+
function parsePositiveIntEnv(raw) {
|
|
29
|
+
const parsed = parseInt(raw || "");
|
|
30
|
+
return Number.isFinite(parsed) && parsed > 0 ? parsed : void 0;
|
|
31
|
+
}
|
|
32
|
+
function resolvePoolConfig(env = process.env) {
|
|
33
|
+
const idleSessionTimeoutEnv = parseInt(env.DB_IDLE_SESSION_TIMEOUT_MS || "");
|
|
34
|
+
const idleInTxTimeoutEnv = parseInt(env.DB_IDLE_IN_TRANSACTION_TIMEOUT_MS || "");
|
|
35
|
+
return {
|
|
36
|
+
poolMin: parseInt(env.DB_POOL_MIN || "2"),
|
|
37
|
+
poolMax: parseInt(env.DB_POOL_MAX || "20"),
|
|
38
|
+
poolIdleTimeout: parseInt(env.DB_POOL_IDLE_TIMEOUT || "3000"),
|
|
39
|
+
poolAcquireTimeout: parseInt(env.DB_POOL_ACQUIRE_TIMEOUT || "6000"),
|
|
40
|
+
idleSessionTimeoutMs: Number.isFinite(idleSessionTimeoutEnv) ? idleSessionTimeoutEnv : env.NODE_ENV === "production" ? void 0 : 6e5,
|
|
41
|
+
// Finite default in every environment (including production) so a leaked or idle
|
|
42
|
+
// open transaction cannot pin a pool connection indefinitely and exhaust the pool.
|
|
43
|
+
// Mirrors the long-standing dev value; override (incl. 0 to disable) via env.
|
|
44
|
+
idleInTransactionTimeoutMs: Number.isFinite(idleInTxTimeoutEnv) ? idleInTxTimeoutEnv : 12e4,
|
|
45
|
+
// Opt-in guards against runaway statements and lock waits. No timeout when unset.
|
|
46
|
+
statementTimeoutMs: parsePositiveIntEnv(env.DB_STATEMENT_TIMEOUT_MS),
|
|
47
|
+
lockTimeoutMs: parsePositiveIntEnv(env.DB_LOCK_TIMEOUT_MS)
|
|
48
|
+
};
|
|
49
|
+
}
|
|
28
50
|
async function getOrm() {
|
|
29
51
|
if (ormInstance) {
|
|
30
52
|
return ormInstance;
|
|
@@ -34,14 +56,16 @@ async function getOrm() {
|
|
|
34
56
|
if (!clientUrl) {
|
|
35
57
|
throw new Error("DATABASE_URL is not set");
|
|
36
58
|
}
|
|
37
|
-
const
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
59
|
+
const {
|
|
60
|
+
poolMin,
|
|
61
|
+
poolMax,
|
|
62
|
+
poolIdleTimeout,
|
|
63
|
+
poolAcquireTimeout,
|
|
64
|
+
idleSessionTimeoutMs,
|
|
65
|
+
idleInTransactionTimeoutMs,
|
|
66
|
+
statementTimeoutMs,
|
|
67
|
+
lockTimeoutMs
|
|
68
|
+
} = resolvePoolConfig();
|
|
45
69
|
const connectionOptions = idleSessionTimeoutMs && idleSessionTimeoutMs > 0 ? `-c idle_session_timeout=${idleSessionTimeoutMs}` : void 0;
|
|
46
70
|
const sslConfig = getSslConfig();
|
|
47
71
|
if (process.env.OM_DB_POOL_DEBUG === "1" || process.env.OM_INTEGRATION_TEST === "true") {
|
|
@@ -52,6 +76,8 @@ async function getOrm() {
|
|
|
52
76
|
poolAcquireTimeout,
|
|
53
77
|
idleSessionTimeoutMs,
|
|
54
78
|
idleInTransactionTimeoutMs,
|
|
79
|
+
statementTimeoutMs,
|
|
80
|
+
lockTimeoutMs,
|
|
55
81
|
nodeEnv: process.env.NODE_ENV
|
|
56
82
|
});
|
|
57
83
|
}
|
|
@@ -80,6 +106,8 @@ async function getOrm() {
|
|
|
80
106
|
driverOptions: {
|
|
81
107
|
connectionTimeoutMillis: poolAcquireTimeout,
|
|
82
108
|
idle_in_transaction_session_timeout: idleInTransactionTimeoutMs,
|
|
109
|
+
statement_timeout: statementTimeoutMs,
|
|
110
|
+
lock_timeout: lockTimeoutMs,
|
|
83
111
|
options: connectionOptions,
|
|
84
112
|
ssl: sslConfig,
|
|
85
113
|
onPoolCreated: (pool) => {
|
|
@@ -108,6 +136,7 @@ if (process.env.NODE_ENV !== "production") {
|
|
|
108
136
|
export {
|
|
109
137
|
getOrm,
|
|
110
138
|
getOrmEntities,
|
|
111
|
-
registerOrmEntities
|
|
139
|
+
registerOrmEntities,
|
|
140
|
+
resolvePoolConfig
|
|
112
141
|
};
|
|
113
142
|
//# sourceMappingURL=mikro.js.map
|
package/dist/lib/db/mikro.js.map
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../src/lib/db/mikro.ts"],
|
|
4
|
-
"sourcesContent": ["import 'dotenv/config'\nimport 'reflect-metadata'\nimport { MikroORM } from '@mikro-orm/core'\nimport { ReflectMetadataProvider } from '@mikro-orm/decorators/legacy'\nimport { PostgreSqlDriver, type EntityManager as PostgreSqlEntityManager } from '@mikro-orm/postgresql'\nimport { getSslConfig } from './ssl'\n\nexport type AppMikroORM = MikroORM<PostgreSqlDriver, PostgreSqlEntityManager<PostgreSqlDriver>>\n\nlet ormInstance: AppMikroORM | null = null\n\n// Use globalThis so standalone apps survive duplicated shared package module instances.\nconst GLOBAL_ENTITIES_KEY = '__openMercatoOrmEntities__'\n\nfunction getRegisteredEntities(): any[] | null {\n return (globalThis as Record<string, unknown>)[GLOBAL_ENTITIES_KEY] as any[] | null ?? null\n}\n\nfunction setRegisteredEntities(entities: any[]): void {\n (globalThis as Record<string, unknown>)[GLOBAL_ENTITIES_KEY] = entities\n}\n\nexport function registerOrmEntities(entities: any[]) {\n if (getRegisteredEntities() !== null && process.env.NODE_ENV === 'development') {\n console.debug('[Bootstrap] ORM entities re-registered (this may occur during HMR)')\n }\n setRegisteredEntities(entities)\n}\n\nexport function getOrmEntities(): any[] {\n const entities = getRegisteredEntities()\n if (!entities) {\n throw new Error('[Bootstrap] ORM entities not registered. Call registerOrmEntities() at bootstrap.')\n }\n return entities\n}\n\nexport
|
|
5
|
-
"mappings": "AAAA,OAAO;AACP,OAAO;AACP,SAAS,gBAAgB;AACzB,SAAS,+BAA+B;AACxC,SAAS,wBAAuE;AAChF,SAAS,oBAAoB;AAI7B,IAAI,cAAkC;AAGtC,MAAM,sBAAsB;AAE5B,SAAS,wBAAsC;AAC7C,SAAQ,WAAuC,mBAAmB,KAAqB;AACzF;AAEA,SAAS,sBAAsB,UAAuB;AACpD,EAAC,WAAuC,mBAAmB,IAAI;AACjE;AAEO,SAAS,oBAAoB,UAAiB;AACnD,MAAI,sBAAsB,MAAM,QAAQ,QAAQ,IAAI,aAAa,eAAe;AAC9E,YAAQ,MAAM,oEAAoE;AAAA,EACpF;AACA,wBAAsB,QAAQ;AAChC;AAEO,SAAS,iBAAwB;AACtC,QAAM,WAAW,sBAAsB;AACvC,MAAI,CAAC,UAAU;AACb,UAAM,IAAI,MAAM,mFAAmF;AAAA,EACrG;AACA,SAAO;AACT;
|
|
4
|
+
"sourcesContent": ["import 'dotenv/config'\nimport 'reflect-metadata'\nimport { MikroORM } from '@mikro-orm/core'\nimport { ReflectMetadataProvider } from '@mikro-orm/decorators/legacy'\nimport { PostgreSqlDriver, type EntityManager as PostgreSqlEntityManager } from '@mikro-orm/postgresql'\nimport { getSslConfig } from './ssl'\n\nexport type AppMikroORM = MikroORM<PostgreSqlDriver, PostgreSqlEntityManager<PostgreSqlDriver>>\n\nlet ormInstance: AppMikroORM | null = null\n\n// Use globalThis so standalone apps survive duplicated shared package module instances.\nconst GLOBAL_ENTITIES_KEY = '__openMercatoOrmEntities__'\n\nfunction getRegisteredEntities(): any[] | null {\n return (globalThis as Record<string, unknown>)[GLOBAL_ENTITIES_KEY] as any[] | null ?? null\n}\n\nfunction setRegisteredEntities(entities: any[]): void {\n (globalThis as Record<string, unknown>)[GLOBAL_ENTITIES_KEY] = entities\n}\n\nexport function registerOrmEntities(entities: any[]) {\n if (getRegisteredEntities() !== null && process.env.NODE_ENV === 'development') {\n console.debug('[Bootstrap] ORM entities re-registered (this may occur during HMR)')\n }\n setRegisteredEntities(entities)\n}\n\nexport function getOrmEntities(): any[] {\n const entities = getRegisteredEntities()\n if (!entities) {\n throw new Error('[Bootstrap] ORM entities not registered. Call registerOrmEntities() at bootstrap.')\n }\n return entities\n}\n\nexport type ResolvedPoolConfig = {\n poolMin: number\n poolMax: number\n poolIdleTimeout: number\n poolAcquireTimeout: number\n idleSessionTimeoutMs: number | undefined\n idleInTransactionTimeoutMs: number | undefined\n statementTimeoutMs: number | undefined\n lockTimeoutMs: number | undefined\n}\n\n// Parse an optional positive-millisecond env var. Returns undefined when unset,\n// non-numeric, or non-positive so callers treat \"no value\" as \"no timeout\".\nfunction parsePositiveIntEnv(raw: string | undefined): number | undefined {\n const parsed = parseInt(raw || '')\n return Number.isFinite(parsed) && parsed > 0 ? parsed : undefined\n}\n\nexport function resolvePoolConfig(env: NodeJS.ProcessEnv = process.env): ResolvedPoolConfig {\n const idleSessionTimeoutEnv = parseInt(env.DB_IDLE_SESSION_TIMEOUT_MS || '')\n const idleInTxTimeoutEnv = parseInt(env.DB_IDLE_IN_TRANSACTION_TIMEOUT_MS || '')\n return {\n poolMin: parseInt(env.DB_POOL_MIN || '2'),\n poolMax: parseInt(env.DB_POOL_MAX || '20'),\n poolIdleTimeout: parseInt(env.DB_POOL_IDLE_TIMEOUT || '3000'),\n poolAcquireTimeout: parseInt(env.DB_POOL_ACQUIRE_TIMEOUT || '6000'),\n idleSessionTimeoutMs: Number.isFinite(idleSessionTimeoutEnv)\n ? idleSessionTimeoutEnv\n : env.NODE_ENV === 'production'\n ? undefined\n : 600_000,\n // Finite default in every environment (including production) so a leaked or idle\n // open transaction cannot pin a pool connection indefinitely and exhaust the pool.\n // Mirrors the long-standing dev value; override (incl. 0 to disable) via env.\n idleInTransactionTimeoutMs: Number.isFinite(idleInTxTimeoutEnv) ? idleInTxTimeoutEnv : 120_000,\n // Opt-in guards against runaway statements and lock waits. No timeout when unset.\n statementTimeoutMs: parsePositiveIntEnv(env.DB_STATEMENT_TIMEOUT_MS),\n lockTimeoutMs: parsePositiveIntEnv(env.DB_LOCK_TIMEOUT_MS),\n }\n}\n\nexport async function getOrm() {\n if (ormInstance) {\n return ormInstance\n }\n\n const entities = getOrmEntities()\n const clientUrl = process.env.DATABASE_URL\n if (!clientUrl) {\n throw new Error('DATABASE_URL is not set')\n }\n\n // Parse connection pool settings from environment\n const {\n poolMin,\n poolMax,\n poolIdleTimeout,\n poolAcquireTimeout,\n idleSessionTimeoutMs,\n idleInTransactionTimeoutMs,\n statementTimeoutMs,\n lockTimeoutMs,\n } = resolvePoolConfig()\n const connectionOptions =\n idleSessionTimeoutMs && idleSessionTimeoutMs > 0\n ? `-c idle_session_timeout=${idleSessionTimeoutMs}`\n : undefined\n\n const sslConfig = getSslConfig()\n\n if (process.env.OM_DB_POOL_DEBUG === '1' || process.env.OM_INTEGRATION_TEST === 'true') {\n console.log('[orm] pool config', {\n poolMin,\n poolMax,\n poolIdleTimeout,\n poolAcquireTimeout,\n idleSessionTimeoutMs,\n idleInTransactionTimeoutMs,\n statementTimeoutMs,\n lockTimeoutMs,\n nodeEnv: process.env.NODE_ENV,\n })\n }\n\n ormInstance = await MikroORM.init<PostgreSqlDriver, PostgreSqlEntityManager<PostgreSqlDriver>>({\n driver: PostgreSqlDriver,\n clientUrl,\n entities,\n debug: false,\n // v7 no longer defaults to ReflectMetadataProvider. Entities in this repo use\n // `@mikro-orm/decorators/legacy`, which relies on TypeScript `emitDecoratorMetadata`\n // + reflect-metadata for type inference (nullability, column types). Without this,\n // inferred types are silently wrong at runtime.\n metadataProvider: ReflectMetadataProvider,\n // MikroORM v7 pool shape (min/max/idleTimeoutMillis). Knex-era `acquireTimeoutMillis` /\n // `destroyTimeoutMillis` were removed; acquire wait maps to pg `connectionTimeoutMillis`\n // below under `driverOptions`. Mirror `connectionTimeoutMillis` here too \u2014 older Mikro\n // versions read it from `pool`; v7 reads from `driverOptions` but accepting both\n // costs nothing and protects us from upstream config-merge regressions.\n pool: {\n min: poolMin,\n max: poolMax,\n idleTimeoutMillis: poolIdleTimeout,\n acquireTimeoutMillis: poolAcquireTimeout,\n } as any,\n // Driver options are merged into pg.PoolConfig (ClientConfig + pg-pool).\n driverOptions: {\n connectionTimeoutMillis: poolAcquireTimeout,\n idle_in_transaction_session_timeout: idleInTransactionTimeoutMs,\n statement_timeout: statementTimeoutMs,\n lock_timeout: lockTimeoutMs,\n options: connectionOptions,\n ssl: sslConfig,\n onPoolCreated: (pool: any) => {\n if (process.env.OM_DB_POOL_DEBUG === '1' || process.env.OM_INTEGRATION_TEST === 'true') {\n console.log('[orm] pg pool created with options', {\n max: pool.options?.max,\n min: pool.options?.min,\n idleTimeoutMillis: pool.options?.idleTimeoutMillis,\n connectionTimeoutMillis: pool.options?.connectionTimeoutMillis,\n })\n }\n },\n },\n })\n\n return ormInstance\n}\n\n\nasync function closeOrmIfLoaded(): Promise<void> {\n if (ormInstance) {\n await ormInstance.close(true)\n ormInstance = null\n }\n}\n\n// In dev mode, handle reloads cleanly without leaving dangling connections.\nif (process.env.NODE_ENV !== 'production') {\n void closeOrmIfLoaded()\n}\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
|
@@ -4,11 +4,17 @@ import { isEncryptionDebugEnabled, isTenantDataEncryptionEnabled } from "./toggl
|
|
|
4
4
|
import { parseBooleanToken } from "../boolean.js";
|
|
5
5
|
import { fetchWithTimeout, resolveTimeoutMs } from "../http/fetchWithTimeout.js";
|
|
6
6
|
const DEFAULT_VAULT_REQUEST_TIMEOUT_MS = 1e3;
|
|
7
|
+
const DEFAULT_VAULT_RECOVERY_COOLDOWN_MS = 3e4;
|
|
7
8
|
function resolveVaultRequestTimeoutMs() {
|
|
8
9
|
const raw = process.env.VAULT_REQUEST_TIMEOUT_MS;
|
|
9
10
|
const parsed = raw ? Number.parseInt(raw, 10) : void 0;
|
|
10
11
|
return resolveTimeoutMs(parsed, DEFAULT_VAULT_REQUEST_TIMEOUT_MS);
|
|
11
12
|
}
|
|
13
|
+
function resolveVaultRecoveryCooldownMs() {
|
|
14
|
+
const raw = process.env.VAULT_RECOVERY_COOLDOWN_MS;
|
|
15
|
+
const parsed = raw ? Number.parseInt(raw, 10) : void 0;
|
|
16
|
+
return resolveTimeoutMs(parsed, DEFAULT_VAULT_RECOVERY_COOLDOWN_MS);
|
|
17
|
+
}
|
|
12
18
|
class FallbackKmsService {
|
|
13
19
|
constructor(primary, fallback, onFallback) {
|
|
14
20
|
this.primary = primary;
|
|
@@ -115,14 +121,24 @@ class HashicorpVaultKmsService {
|
|
|
115
121
|
constructor(opts = {}) {
|
|
116
122
|
this.cache = /* @__PURE__ */ new Map();
|
|
117
123
|
this.healthy = true;
|
|
124
|
+
// Sticky terminal failure (missing VAULT_ADDR/VAULT_TOKEN): no amount of
|
|
125
|
+
// re-probing fixes a misconfiguration, so this never self-heals — only a
|
|
126
|
+
// restart with corrected config does.
|
|
127
|
+
this.misconfigured = false;
|
|
128
|
+
// Timestamp of the last transient failure (timeout / network blip / 5xx).
|
|
129
|
+
// Drives the half-open circuit breaker in isHealthy(): after the cooldown the
|
|
130
|
+
// instance reports healthy again so the next call re-probes Vault.
|
|
131
|
+
this.lastTransientFailureAt = null;
|
|
118
132
|
this.vaultAddr = normalizeEnv(opts.vaultAddr || process.env.VAULT_ADDR || "");
|
|
119
133
|
this.vaultToken = normalizeEnv(opts.vaultToken || process.env.VAULT_TOKEN || "");
|
|
120
134
|
this.mountPath = (opts.mountPath || process.env.VAULT_KV_PATH || "secret/data").replace(/\/+$/, "");
|
|
121
135
|
this.ttlMs = opts.ttlMs ?? 15 * 60 * 1e3;
|
|
122
136
|
this.requestTimeoutMs = resolveTimeoutMs(opts.requestTimeoutMs, resolveVaultRequestTimeoutMs());
|
|
137
|
+
this.recoveryCooldownMs = resolveTimeoutMs(opts.recoveryCooldownMs, resolveVaultRecoveryCooldownMs());
|
|
123
138
|
this.debugEnabled = isEncryptionDebugEnabled();
|
|
124
139
|
if (!this.vaultAddr || !this.vaultToken) {
|
|
125
140
|
this.healthy = false;
|
|
141
|
+
this.misconfigured = true;
|
|
126
142
|
if (this.debugEnabled) {
|
|
127
143
|
console.warn("\u26A0\uFE0F [encryption][kms] Vault misconfigured (missing VAULT_ADDR or VAULT_TOKEN)");
|
|
128
144
|
}
|
|
@@ -138,11 +154,25 @@ class HashicorpVaultKmsService {
|
|
|
138
154
|
this.loggedInit = false;
|
|
139
155
|
}
|
|
140
156
|
isHealthy() {
|
|
141
|
-
|
|
157
|
+
if (this.misconfigured) return false;
|
|
158
|
+
if (this.healthy) return true;
|
|
159
|
+
if (this.lastTransientFailureAt === null) return false;
|
|
160
|
+
return this.now() - this.lastTransientFailureAt >= this.recoveryCooldownMs;
|
|
142
161
|
}
|
|
143
162
|
now() {
|
|
144
163
|
return Date.now();
|
|
145
164
|
}
|
|
165
|
+
// Vault responded successfully (or is provably reachable): close the breaker.
|
|
166
|
+
markHealthy() {
|
|
167
|
+
this.healthy = true;
|
|
168
|
+
this.lastTransientFailureAt = null;
|
|
169
|
+
}
|
|
170
|
+
// Transient infra failure (timeout / network blip / 5xx): open the breaker and
|
|
171
|
+
// start the recovery cooldown so a later call can re-probe and self-heal.
|
|
172
|
+
markTransientFailure() {
|
|
173
|
+
this.healthy = false;
|
|
174
|
+
this.lastTransientFailureAt = this.now();
|
|
175
|
+
}
|
|
146
176
|
cacheHit(tenantId) {
|
|
147
177
|
const entry = this.cache.get(tenantId);
|
|
148
178
|
if (!entry) return null;
|
|
@@ -155,6 +185,7 @@ class HashicorpVaultKmsService {
|
|
|
155
185
|
async readVault(path) {
|
|
156
186
|
if (!this.vaultAddr || !this.vaultToken) {
|
|
157
187
|
this.healthy = false;
|
|
188
|
+
this.misconfigured = true;
|
|
158
189
|
return null;
|
|
159
190
|
}
|
|
160
191
|
try {
|
|
@@ -164,16 +195,18 @@ class HashicorpVaultKmsService {
|
|
|
164
195
|
timeoutMs: this.requestTimeoutMs
|
|
165
196
|
});
|
|
166
197
|
if (!res.ok) {
|
|
167
|
-
|
|
198
|
+
if (res.status >= 500) this.markTransientFailure();
|
|
199
|
+
else this.markHealthy();
|
|
168
200
|
console.warn("\u26A0\uFE0F [encryption][kms] Vault read failed", { path, status: res.status });
|
|
169
201
|
return null;
|
|
170
202
|
}
|
|
203
|
+
this.markHealthy();
|
|
171
204
|
if (this.debugEnabled) {
|
|
172
205
|
console.info("\u{1F50D} [encryption][kms] Vault read ok", { path });
|
|
173
206
|
}
|
|
174
207
|
return await res.json();
|
|
175
208
|
} catch (err) {
|
|
176
|
-
this.
|
|
209
|
+
this.markTransientFailure();
|
|
177
210
|
console.warn("\u26A0\uFE0F [encryption][kms] Vault read error", {
|
|
178
211
|
path,
|
|
179
212
|
error: err?.message || String(err),
|
|
@@ -185,6 +218,7 @@ class HashicorpVaultKmsService {
|
|
|
185
218
|
async writeVault(path, key, opts) {
|
|
186
219
|
if (!this.vaultAddr || !this.vaultToken) {
|
|
187
220
|
this.healthy = false;
|
|
221
|
+
this.misconfigured = true;
|
|
188
222
|
return "error";
|
|
189
223
|
}
|
|
190
224
|
const body = { data: { key } };
|
|
@@ -200,18 +234,19 @@ class HashicorpVaultKmsService {
|
|
|
200
234
|
timeoutMs: this.requestTimeoutMs
|
|
201
235
|
});
|
|
202
236
|
if (res.ok) {
|
|
203
|
-
this.
|
|
237
|
+
this.markHealthy();
|
|
204
238
|
return "ok";
|
|
205
239
|
}
|
|
206
240
|
if (typeof opts?.cas === "number" && res.status === 400) {
|
|
241
|
+
this.markHealthy();
|
|
207
242
|
console.warn("\u26A0\uFE0F [encryption][kms] Vault write CAS conflict (concurrent DEK create)", { path, status: res.status });
|
|
208
243
|
return "conflict";
|
|
209
244
|
}
|
|
210
|
-
this.
|
|
245
|
+
this.markTransientFailure();
|
|
211
246
|
console.warn("\u26A0\uFE0F [encryption][kms] Vault write failed", { path, status: res.status });
|
|
212
247
|
return "error";
|
|
213
248
|
} catch (err) {
|
|
214
|
-
this.
|
|
249
|
+
this.markTransientFailure();
|
|
215
250
|
console.warn("\u26A0\uFE0F [encryption][kms] Vault write error", {
|
|
216
251
|
path,
|
|
217
252
|
error: err?.message || String(err),
|
|
@@ -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 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": "AAAA,OAAO,YAAY;AACnB,SAAS,aAAa,qBAAqB;AAC3C,SAAS,0BAA0B,qCAAqC;AACxE,SAAS,yBAAyB;AAClC,SAAS,kBAAkB,wBAAwB;AAEnD,MAAM,mCAAmC;
|
|
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\nconst DEFAULT_VAULT_RECOVERY_COOLDOWN_MS = 30_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\nfunction resolveVaultRecoveryCooldownMs(): number {\n const raw = process.env.VAULT_RECOVERY_COOLDOWN_MS\n const parsed = raw ? Number.parseInt(raw, 10) : undefined\n return resolveTimeoutMs(parsed, DEFAULT_VAULT_RECOVERY_COOLDOWN_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 recoveryCooldownMs?: 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 readonly recoveryCooldownMs: number\n private healthy = true\n // Sticky terminal failure (missing VAULT_ADDR/VAULT_TOKEN): no amount of\n // re-probing fixes a misconfiguration, so this never self-heals \u2014 only a\n // restart with corrected config does.\n private misconfigured = false\n // Timestamp of the last transient failure (timeout / network blip / 5xx).\n // Drives the half-open circuit breaker in isHealthy(): after the cooldown the\n // instance reports healthy again so the next call re-probes Vault.\n private lastTransientFailureAt: number | null = null\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.recoveryCooldownMs = resolveTimeoutMs(opts.recoveryCooldownMs, resolveVaultRecoveryCooldownMs())\n this.debugEnabled = isEncryptionDebugEnabled()\n if (!this.vaultAddr || !this.vaultToken) {\n this.healthy = false\n this.misconfigured = true\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 // A missing-config failure is terminal \u2014 never report healthy again.\n if (this.misconfigured) return false\n if (this.healthy) return true\n // Half-open circuit breaker: once the cooldown since the last transient\n // failure has elapsed, report healthy so the next read/write re-probes\n // Vault. A successful probe flips `healthy` back on; a failing one records a\n // fresh failure timestamp and re-opens the breaker for another cooldown.\n if (this.lastTransientFailureAt === null) return false\n return this.now() - this.lastTransientFailureAt >= this.recoveryCooldownMs\n }\n\n private now(): number {\n return Date.now()\n }\n\n // Vault responded successfully (or is provably reachable): close the breaker.\n private markHealthy(): void {\n this.healthy = true\n this.lastTransientFailureAt = null\n }\n\n // Transient infra failure (timeout / network blip / 5xx): open the breaker and\n // start the recovery cooldown so a later call can re-probe and self-heal.\n private markTransientFailure(): void {\n this.healthy = false\n this.lastTransientFailureAt = this.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 this.misconfigured = true\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 // 5xx = Vault down/erroring (transient). <500 (auth/not-found/etc.) means\n // Vault is reachable and answered, so keep it healthy \u2014 a 404 for a\n // not-yet-created tenant DEK is the normal read-before-write path.\n if (res.status >= 500) this.markTransientFailure()\n else this.markHealthy()\n console.warn('\u26A0\uFE0F [encryption][kms] Vault read failed', { path, status: res.status })\n return null\n }\n this.markHealthy()\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.markTransientFailure()\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 this.misconfigured = true\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.markHealthy()\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 Vault is reachable, so close the breaker.\n if (typeof opts?.cas === 'number' && res.status === 400) {\n this.markHealthy()\n console.warn('\u26A0\uFE0F [encryption][kms] Vault write CAS conflict (concurrent DEK create)', { path, status: res.status })\n return 'conflict'\n }\n this.markTransientFailure()\n console.warn('\u26A0\uFE0F [encryption][kms] Vault write failed', { path, status: res.status })\n return 'error'\n } catch (err) {\n this.markTransientFailure()\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
|
}
|
package/dist/lib/version.js
CHANGED
package/dist/lib/version.js.map
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../src/lib/version.ts"],
|
|
4
|
-
"sourcesContent": ["// Build-time generated version\nexport const APP_VERSION = '0.6.6-develop.
|
|
4
|
+
"sourcesContent": ["// Build-time generated version\nexport const APP_VERSION = '0.6.6-develop.5523.1.e223ca1915'\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.6.6-develop.
|
|
3
|
+
"version": "0.6.6-develop.5523.1.e223ca1915",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "./dist/index.js",
|
|
6
6
|
"scripts": {
|
|
@@ -92,7 +92,7 @@
|
|
|
92
92
|
"@mikro-orm/core": "^7.1.4",
|
|
93
93
|
"@mikro-orm/decorators": "^7.1.4",
|
|
94
94
|
"@mikro-orm/postgresql": "^7.1.4",
|
|
95
|
-
"@open-mercato/cache": "0.6.6-develop.
|
|
95
|
+
"@open-mercato/cache": "0.6.6-develop.5523.1.e223ca1915",
|
|
96
96
|
"dotenv": "^17.4.2",
|
|
97
97
|
"rate-limiter-flexible": "^11.2.0",
|
|
98
98
|
"re2js": "2.8.3",
|
|
@@ -23,3 +23,85 @@ describe('ORM entity registry', () => {
|
|
|
23
23
|
expect(secondLoad.getOrmEntities()).toBe(entities)
|
|
24
24
|
})
|
|
25
25
|
})
|
|
26
|
+
|
|
27
|
+
describe('resolvePoolConfig', () => {
|
|
28
|
+
const baseEnv = (extra: Record<string, string | undefined> = {}): NodeJS.ProcessEnv =>
|
|
29
|
+
({ ...extra }) as NodeJS.ProcessEnv
|
|
30
|
+
|
|
31
|
+
it('applies pool size defaults when env is empty', async () => {
|
|
32
|
+
const { resolvePoolConfig } = await import('../mikro')
|
|
33
|
+
const config = resolvePoolConfig(baseEnv())
|
|
34
|
+
expect(config.poolMin).toBe(2)
|
|
35
|
+
expect(config.poolMax).toBe(20)
|
|
36
|
+
expect(config.poolIdleTimeout).toBe(3000)
|
|
37
|
+
expect(config.poolAcquireTimeout).toBe(6000)
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
it('reads pool sizes from env overrides', async () => {
|
|
41
|
+
const { resolvePoolConfig } = await import('../mikro')
|
|
42
|
+
const config = resolvePoolConfig(
|
|
43
|
+
baseEnv({ DB_POOL_MIN: '5', DB_POOL_MAX: '50', DB_POOL_ACQUIRE_TIMEOUT: '12000' }),
|
|
44
|
+
)
|
|
45
|
+
expect(config.poolMin).toBe(5)
|
|
46
|
+
expect(config.poolMax).toBe(50)
|
|
47
|
+
expect(config.poolAcquireTimeout).toBe(12000)
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
it('defaults idle_in_transaction to a finite 120s in production', async () => {
|
|
51
|
+
const { resolvePoolConfig } = await import('../mikro')
|
|
52
|
+
const config = resolvePoolConfig(baseEnv({ NODE_ENV: 'production' }))
|
|
53
|
+
expect(config.idleInTransactionTimeoutMs).toBe(120_000)
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
it('defaults idle_in_transaction to a finite 120s in development', async () => {
|
|
57
|
+
const { resolvePoolConfig } = await import('../mikro')
|
|
58
|
+
const config = resolvePoolConfig(baseEnv({ NODE_ENV: 'development' }))
|
|
59
|
+
expect(config.idleInTransactionTimeoutMs).toBe(120_000)
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
it('lets idle_in_transaction be overridden, including 0 to disable', async () => {
|
|
63
|
+
const { resolvePoolConfig } = await import('../mikro')
|
|
64
|
+
expect(
|
|
65
|
+
resolvePoolConfig(baseEnv({ DB_IDLE_IN_TRANSACTION_TIMEOUT_MS: '30000' }))
|
|
66
|
+
.idleInTransactionTimeoutMs,
|
|
67
|
+
).toBe(30000)
|
|
68
|
+
expect(
|
|
69
|
+
resolvePoolConfig(
|
|
70
|
+
baseEnv({ NODE_ENV: 'production', DB_IDLE_IN_TRANSACTION_TIMEOUT_MS: '0' }),
|
|
71
|
+
).idleInTransactionTimeoutMs,
|
|
72
|
+
).toBe(0)
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
it('keeps idle_session production-undefined / dev-600s default', async () => {
|
|
76
|
+
const { resolvePoolConfig } = await import('../mikro')
|
|
77
|
+
expect(resolvePoolConfig(baseEnv({ NODE_ENV: 'production' })).idleSessionTimeoutMs).toBeUndefined()
|
|
78
|
+
expect(resolvePoolConfig(baseEnv({ NODE_ENV: 'development' })).idleSessionTimeoutMs).toBe(600_000)
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
it('leaves statement/lock timeouts unset by default (no timeout)', async () => {
|
|
82
|
+
const { resolvePoolConfig } = await import('../mikro')
|
|
83
|
+
const config = resolvePoolConfig(baseEnv({ NODE_ENV: 'production' }))
|
|
84
|
+
expect(config.statementTimeoutMs).toBeUndefined()
|
|
85
|
+
expect(config.lockTimeoutMs).toBeUndefined()
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
it('passes through positive statement/lock timeouts when set', async () => {
|
|
89
|
+
const { resolvePoolConfig } = await import('../mikro')
|
|
90
|
+
const config = resolvePoolConfig(
|
|
91
|
+
baseEnv({ DB_STATEMENT_TIMEOUT_MS: '30000', DB_LOCK_TIMEOUT_MS: '5000' }),
|
|
92
|
+
)
|
|
93
|
+
expect(config.statementTimeoutMs).toBe(30000)
|
|
94
|
+
expect(config.lockTimeoutMs).toBe(5000)
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
it('ignores non-positive or non-numeric statement/lock timeouts', async () => {
|
|
98
|
+
const { resolvePoolConfig } = await import('../mikro')
|
|
99
|
+
for (const value of ['0', '-1', 'abc', '']) {
|
|
100
|
+
const config = resolvePoolConfig(
|
|
101
|
+
baseEnv({ DB_STATEMENT_TIMEOUT_MS: value, DB_LOCK_TIMEOUT_MS: value }),
|
|
102
|
+
)
|
|
103
|
+
expect(config.statementTimeoutMs).toBeUndefined()
|
|
104
|
+
expect(config.lockTimeoutMs).toBeUndefined()
|
|
105
|
+
}
|
|
106
|
+
})
|
|
107
|
+
})
|
package/src/lib/db/mikro.ts
CHANGED
|
@@ -35,6 +35,47 @@ export function getOrmEntities(): any[] {
|
|
|
35
35
|
return entities
|
|
36
36
|
}
|
|
37
37
|
|
|
38
|
+
export type ResolvedPoolConfig = {
|
|
39
|
+
poolMin: number
|
|
40
|
+
poolMax: number
|
|
41
|
+
poolIdleTimeout: number
|
|
42
|
+
poolAcquireTimeout: number
|
|
43
|
+
idleSessionTimeoutMs: number | undefined
|
|
44
|
+
idleInTransactionTimeoutMs: number | undefined
|
|
45
|
+
statementTimeoutMs: number | undefined
|
|
46
|
+
lockTimeoutMs: number | undefined
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Parse an optional positive-millisecond env var. Returns undefined when unset,
|
|
50
|
+
// non-numeric, or non-positive so callers treat "no value" as "no timeout".
|
|
51
|
+
function parsePositiveIntEnv(raw: string | undefined): number | undefined {
|
|
52
|
+
const parsed = parseInt(raw || '')
|
|
53
|
+
return Number.isFinite(parsed) && parsed > 0 ? parsed : undefined
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function resolvePoolConfig(env: NodeJS.ProcessEnv = process.env): ResolvedPoolConfig {
|
|
57
|
+
const idleSessionTimeoutEnv = parseInt(env.DB_IDLE_SESSION_TIMEOUT_MS || '')
|
|
58
|
+
const idleInTxTimeoutEnv = parseInt(env.DB_IDLE_IN_TRANSACTION_TIMEOUT_MS || '')
|
|
59
|
+
return {
|
|
60
|
+
poolMin: parseInt(env.DB_POOL_MIN || '2'),
|
|
61
|
+
poolMax: parseInt(env.DB_POOL_MAX || '20'),
|
|
62
|
+
poolIdleTimeout: parseInt(env.DB_POOL_IDLE_TIMEOUT || '3000'),
|
|
63
|
+
poolAcquireTimeout: parseInt(env.DB_POOL_ACQUIRE_TIMEOUT || '6000'),
|
|
64
|
+
idleSessionTimeoutMs: Number.isFinite(idleSessionTimeoutEnv)
|
|
65
|
+
? idleSessionTimeoutEnv
|
|
66
|
+
: env.NODE_ENV === 'production'
|
|
67
|
+
? undefined
|
|
68
|
+
: 600_000,
|
|
69
|
+
// Finite default in every environment (including production) so a leaked or idle
|
|
70
|
+
// open transaction cannot pin a pool connection indefinitely and exhaust the pool.
|
|
71
|
+
// Mirrors the long-standing dev value; override (incl. 0 to disable) via env.
|
|
72
|
+
idleInTransactionTimeoutMs: Number.isFinite(idleInTxTimeoutEnv) ? idleInTxTimeoutEnv : 120_000,
|
|
73
|
+
// Opt-in guards against runaway statements and lock waits. No timeout when unset.
|
|
74
|
+
statementTimeoutMs: parsePositiveIntEnv(env.DB_STATEMENT_TIMEOUT_MS),
|
|
75
|
+
lockTimeoutMs: parsePositiveIntEnv(env.DB_LOCK_TIMEOUT_MS),
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
38
79
|
export async function getOrm() {
|
|
39
80
|
if (ormInstance) {
|
|
40
81
|
return ormInstance
|
|
@@ -47,22 +88,16 @@ export async function getOrm() {
|
|
|
47
88
|
}
|
|
48
89
|
|
|
49
90
|
// Parse connection pool settings from environment
|
|
50
|
-
const
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
: 600_000
|
|
61
|
-
const idleInTransactionTimeoutMs = Number.isFinite(idleInTxTimeoutEnv)
|
|
62
|
-
? idleInTxTimeoutEnv
|
|
63
|
-
: process.env.NODE_ENV === 'production'
|
|
64
|
-
? undefined
|
|
65
|
-
: 120_000
|
|
91
|
+
const {
|
|
92
|
+
poolMin,
|
|
93
|
+
poolMax,
|
|
94
|
+
poolIdleTimeout,
|
|
95
|
+
poolAcquireTimeout,
|
|
96
|
+
idleSessionTimeoutMs,
|
|
97
|
+
idleInTransactionTimeoutMs,
|
|
98
|
+
statementTimeoutMs,
|
|
99
|
+
lockTimeoutMs,
|
|
100
|
+
} = resolvePoolConfig()
|
|
66
101
|
const connectionOptions =
|
|
67
102
|
idleSessionTimeoutMs && idleSessionTimeoutMs > 0
|
|
68
103
|
? `-c idle_session_timeout=${idleSessionTimeoutMs}`
|
|
@@ -78,6 +113,8 @@ export async function getOrm() {
|
|
|
78
113
|
poolAcquireTimeout,
|
|
79
114
|
idleSessionTimeoutMs,
|
|
80
115
|
idleInTransactionTimeoutMs,
|
|
116
|
+
statementTimeoutMs,
|
|
117
|
+
lockTimeoutMs,
|
|
81
118
|
nodeEnv: process.env.NODE_ENV,
|
|
82
119
|
})
|
|
83
120
|
}
|
|
@@ -107,6 +144,8 @@ export async function getOrm() {
|
|
|
107
144
|
driverOptions: {
|
|
108
145
|
connectionTimeoutMillis: poolAcquireTimeout,
|
|
109
146
|
idle_in_transaction_session_timeout: idleInTransactionTimeoutMs,
|
|
147
|
+
statement_timeout: statementTimeoutMs,
|
|
148
|
+
lock_timeout: lockTimeoutMs,
|
|
110
149
|
options: connectionOptions,
|
|
111
150
|
ssl: sslConfig,
|
|
112
151
|
onPoolCreated: (pool: any) => {
|
|
@@ -126,3 +126,83 @@ describe('kms timeout handling', () => {
|
|
|
126
126
|
expect(dek?.key).toBeTruthy()
|
|
127
127
|
})
|
|
128
128
|
})
|
|
129
|
+
|
|
130
|
+
describe('kms self-healing circuit breaker (#2661)', () => {
|
|
131
|
+
const vaultAddr = 'http://vault.test'
|
|
132
|
+
const vaultToken = 'token'
|
|
133
|
+
|
|
134
|
+
afterEach(() => {
|
|
135
|
+
process.env = { ...originalEnv }
|
|
136
|
+
jest.restoreAllMocks()
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
it('re-probes Vault and recovers after the cooldown window instead of staying unhealthy forever', async () => {
|
|
140
|
+
let clock = 1_000_000
|
|
141
|
+
jest.spyOn(Date, 'now').mockImplementation(() => clock)
|
|
142
|
+
|
|
143
|
+
let calls = 0
|
|
144
|
+
const fetchMock = jest.fn(() => {
|
|
145
|
+
calls += 1
|
|
146
|
+
if (calls === 1) {
|
|
147
|
+
// First read hits a transient 5xx (Vault hiccup) → breaker opens.
|
|
148
|
+
return Promise.resolve({ ok: false, status: 503, json: async () => ({}) })
|
|
149
|
+
}
|
|
150
|
+
// Vault is back: subsequent reads succeed.
|
|
151
|
+
return Promise.resolve({ ok: true, status: 200, json: async () => ({ data: { data: { key: 'recovered-key' } } }) })
|
|
152
|
+
})
|
|
153
|
+
;(globalThis as { fetch?: typeof fetch }).fetch = fetchMock as typeof fetch
|
|
154
|
+
|
|
155
|
+
const service = new HashicorpVaultKmsService({ vaultAddr, vaultToken, recoveryCooldownMs: 5_000 })
|
|
156
|
+
|
|
157
|
+
// Transient failure flips the instance unhealthy.
|
|
158
|
+
await expect(service.getTenantDek('tenant-1')).resolves.toBeNull()
|
|
159
|
+
expect(service.isHealthy()).toBe(false)
|
|
160
|
+
|
|
161
|
+
// Still within the cooldown: the breaker stays open.
|
|
162
|
+
clock += 4_000
|
|
163
|
+
expect(service.isHealthy()).toBe(false)
|
|
164
|
+
|
|
165
|
+
// Past the cooldown: half-open — report healthy so the next call re-probes.
|
|
166
|
+
clock += 2_000
|
|
167
|
+
expect(service.isHealthy()).toBe(true)
|
|
168
|
+
|
|
169
|
+
// The re-probe succeeds and the breaker fully closes (the never-recover bug).
|
|
170
|
+
const dek = await service.getTenantDek('tenant-1')
|
|
171
|
+
expect(dek?.key).toBe('recovered-key')
|
|
172
|
+
expect(service.isHealthy()).toBe(true)
|
|
173
|
+
})
|
|
174
|
+
|
|
175
|
+
it('treats missing VAULT_ADDR/VAULT_TOKEN as a terminal failure that never self-heals', () => {
|
|
176
|
+
let clock = 2_000_000
|
|
177
|
+
jest.spyOn(Date, 'now').mockImplementation(() => clock)
|
|
178
|
+
|
|
179
|
+
const service = new HashicorpVaultKmsService({ vaultAddr: '', vaultToken: '', recoveryCooldownMs: 5_000 })
|
|
180
|
+
expect(service.isHealthy()).toBe(false)
|
|
181
|
+
|
|
182
|
+
// No cooldown re-probe should ever revive a misconfigured instance.
|
|
183
|
+
clock += 10_000_000
|
|
184
|
+
expect(service.isHealthy()).toBe(false)
|
|
185
|
+
})
|
|
186
|
+
|
|
187
|
+
it('keeps Vault healthy on a 404 read so read-before-write DEK creation can proceed', async () => {
|
|
188
|
+
jest.spyOn(Date, 'now').mockReturnValue(3_000_000)
|
|
189
|
+
|
|
190
|
+
const fetchMock = jest.fn((_url: string, init?: RequestInit) => {
|
|
191
|
+
const method = (init?.method || 'GET').toUpperCase()
|
|
192
|
+
if (method === 'GET') {
|
|
193
|
+
// KV v2 returns 404 for a not-yet-created tenant key — Vault is reachable.
|
|
194
|
+
return Promise.resolve({ ok: false, status: 404, json: async () => ({}) })
|
|
195
|
+
}
|
|
196
|
+
return Promise.resolve({ ok: true, status: 200, json: async () => ({}) })
|
|
197
|
+
})
|
|
198
|
+
;(globalThis as { fetch?: typeof fetch }).fetch = fetchMock as typeof fetch
|
|
199
|
+
|
|
200
|
+
const service = new HashicorpVaultKmsService({ vaultAddr, vaultToken })
|
|
201
|
+
|
|
202
|
+
const dek = await service.createTenantDek('tenant-2')
|
|
203
|
+
expect(typeof dek?.key).toBe('string')
|
|
204
|
+
expect(dek?.key).toBeTruthy()
|
|
205
|
+
expect(service.isHealthy()).toBe(true)
|
|
206
|
+
expect(fetchMock).toHaveBeenCalledTimes(2) // 404 read probe + the write
|
|
207
|
+
})
|
|
208
|
+
})
|
|
@@ -5,6 +5,7 @@ import { parseBooleanToken } from '../boolean'
|
|
|
5
5
|
import { fetchWithTimeout, resolveTimeoutMs } from '../http/fetchWithTimeout'
|
|
6
6
|
|
|
7
7
|
const DEFAULT_VAULT_REQUEST_TIMEOUT_MS = 1_000
|
|
8
|
+
const DEFAULT_VAULT_RECOVERY_COOLDOWN_MS = 30_000
|
|
8
9
|
|
|
9
10
|
function resolveVaultRequestTimeoutMs(): number {
|
|
10
11
|
const raw = process.env.VAULT_REQUEST_TIMEOUT_MS
|
|
@@ -12,6 +13,12 @@ function resolveVaultRequestTimeoutMs(): number {
|
|
|
12
13
|
return resolveTimeoutMs(parsed, DEFAULT_VAULT_REQUEST_TIMEOUT_MS)
|
|
13
14
|
}
|
|
14
15
|
|
|
16
|
+
function resolveVaultRecoveryCooldownMs(): number {
|
|
17
|
+
const raw = process.env.VAULT_RECOVERY_COOLDOWN_MS
|
|
18
|
+
const parsed = raw ? Number.parseInt(raw, 10) : undefined
|
|
19
|
+
return resolveTimeoutMs(parsed, DEFAULT_VAULT_RECOVERY_COOLDOWN_MS)
|
|
20
|
+
}
|
|
21
|
+
|
|
15
22
|
export type TenantDek = {
|
|
16
23
|
tenantId: string
|
|
17
24
|
key: string // base64
|
|
@@ -90,6 +97,7 @@ type VaultClientOpts = {
|
|
|
90
97
|
mountPath?: string
|
|
91
98
|
ttlMs?: number
|
|
92
99
|
requestTimeoutMs?: number
|
|
100
|
+
recoveryCooldownMs?: number
|
|
93
101
|
}
|
|
94
102
|
|
|
95
103
|
type VaultReadResponse = {
|
|
@@ -166,7 +174,16 @@ export class HashicorpVaultKmsService implements KmsService {
|
|
|
166
174
|
private readonly mountPath: string
|
|
167
175
|
private readonly ttlMs: number
|
|
168
176
|
private readonly requestTimeoutMs: number
|
|
177
|
+
private readonly recoveryCooldownMs: number
|
|
169
178
|
private healthy = true
|
|
179
|
+
// Sticky terminal failure (missing VAULT_ADDR/VAULT_TOKEN): no amount of
|
|
180
|
+
// re-probing fixes a misconfiguration, so this never self-heals — only a
|
|
181
|
+
// restart with corrected config does.
|
|
182
|
+
private misconfigured = false
|
|
183
|
+
// Timestamp of the last transient failure (timeout / network blip / 5xx).
|
|
184
|
+
// Drives the half-open circuit breaker in isHealthy(): after the cooldown the
|
|
185
|
+
// instance reports healthy again so the next call re-probes Vault.
|
|
186
|
+
private lastTransientFailureAt: number | null = null
|
|
170
187
|
private readonly debugEnabled: boolean
|
|
171
188
|
private static loggedInit = false
|
|
172
189
|
|
|
@@ -176,9 +193,11 @@ export class HashicorpVaultKmsService implements KmsService {
|
|
|
176
193
|
this.mountPath = (opts.mountPath || process.env.VAULT_KV_PATH || 'secret/data').replace(/\/+$/, '')
|
|
177
194
|
this.ttlMs = opts.ttlMs ?? 15 * 60 * 1000
|
|
178
195
|
this.requestTimeoutMs = resolveTimeoutMs(opts.requestTimeoutMs, resolveVaultRequestTimeoutMs())
|
|
196
|
+
this.recoveryCooldownMs = resolveTimeoutMs(opts.recoveryCooldownMs, resolveVaultRecoveryCooldownMs())
|
|
179
197
|
this.debugEnabled = isEncryptionDebugEnabled()
|
|
180
198
|
if (!this.vaultAddr || !this.vaultToken) {
|
|
181
199
|
this.healthy = false
|
|
200
|
+
this.misconfigured = true
|
|
182
201
|
if (this.debugEnabled) {
|
|
183
202
|
console.warn('⚠️ [encryption][kms] Vault misconfigured (missing VAULT_ADDR or VAULT_TOKEN)')
|
|
184
203
|
}
|
|
@@ -192,13 +211,34 @@ export class HashicorpVaultKmsService implements KmsService {
|
|
|
192
211
|
}
|
|
193
212
|
|
|
194
213
|
isHealthy(): boolean {
|
|
195
|
-
|
|
214
|
+
// A missing-config failure is terminal — never report healthy again.
|
|
215
|
+
if (this.misconfigured) return false
|
|
216
|
+
if (this.healthy) return true
|
|
217
|
+
// Half-open circuit breaker: once the cooldown since the last transient
|
|
218
|
+
// failure has elapsed, report healthy so the next read/write re-probes
|
|
219
|
+
// Vault. A successful probe flips `healthy` back on; a failing one records a
|
|
220
|
+
// fresh failure timestamp and re-opens the breaker for another cooldown.
|
|
221
|
+
if (this.lastTransientFailureAt === null) return false
|
|
222
|
+
return this.now() - this.lastTransientFailureAt >= this.recoveryCooldownMs
|
|
196
223
|
}
|
|
197
224
|
|
|
198
225
|
private now(): number {
|
|
199
226
|
return Date.now()
|
|
200
227
|
}
|
|
201
228
|
|
|
229
|
+
// Vault responded successfully (or is provably reachable): close the breaker.
|
|
230
|
+
private markHealthy(): void {
|
|
231
|
+
this.healthy = true
|
|
232
|
+
this.lastTransientFailureAt = null
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// Transient infra failure (timeout / network blip / 5xx): open the breaker and
|
|
236
|
+
// start the recovery cooldown so a later call can re-probe and self-heal.
|
|
237
|
+
private markTransientFailure(): void {
|
|
238
|
+
this.healthy = false
|
|
239
|
+
this.lastTransientFailureAt = this.now()
|
|
240
|
+
}
|
|
241
|
+
|
|
202
242
|
private cacheHit(tenantId: string): TenantDek | null {
|
|
203
243
|
const entry = this.cache.get(tenantId)
|
|
204
244
|
if (!entry) return null
|
|
@@ -212,6 +252,7 @@ export class HashicorpVaultKmsService implements KmsService {
|
|
|
212
252
|
private async readVault(path: string): Promise<VaultReadResponse | null> {
|
|
213
253
|
if (!this.vaultAddr || !this.vaultToken) {
|
|
214
254
|
this.healthy = false
|
|
255
|
+
this.misconfigured = true
|
|
215
256
|
return null
|
|
216
257
|
}
|
|
217
258
|
try {
|
|
@@ -221,16 +262,21 @@ export class HashicorpVaultKmsService implements KmsService {
|
|
|
221
262
|
timeoutMs: this.requestTimeoutMs,
|
|
222
263
|
})
|
|
223
264
|
if (!res.ok) {
|
|
224
|
-
|
|
265
|
+
// 5xx = Vault down/erroring (transient). <500 (auth/not-found/etc.) means
|
|
266
|
+
// Vault is reachable and answered, so keep it healthy — a 404 for a
|
|
267
|
+
// not-yet-created tenant DEK is the normal read-before-write path.
|
|
268
|
+
if (res.status >= 500) this.markTransientFailure()
|
|
269
|
+
else this.markHealthy()
|
|
225
270
|
console.warn('⚠️ [encryption][kms] Vault read failed', { path, status: res.status })
|
|
226
271
|
return null
|
|
227
272
|
}
|
|
273
|
+
this.markHealthy()
|
|
228
274
|
if (this.debugEnabled) {
|
|
229
275
|
console.info('🔍 [encryption][kms] Vault read ok', { path })
|
|
230
276
|
}
|
|
231
277
|
return (await res.json()) as VaultReadResponse
|
|
232
278
|
} catch (err) {
|
|
233
|
-
this.
|
|
279
|
+
this.markTransientFailure()
|
|
234
280
|
console.warn('⚠️ [encryption][kms] Vault read error', {
|
|
235
281
|
path,
|
|
236
282
|
error: (err as Error)?.message || String(err),
|
|
@@ -243,6 +289,7 @@ export class HashicorpVaultKmsService implements KmsService {
|
|
|
243
289
|
private async writeVault(path: string, key: string, opts?: { cas?: number }): Promise<VaultWriteOutcome> {
|
|
244
290
|
if (!this.vaultAddr || !this.vaultToken) {
|
|
245
291
|
this.healthy = false
|
|
292
|
+
this.misconfigured = true
|
|
246
293
|
return 'error'
|
|
247
294
|
}
|
|
248
295
|
const body: { data: { key: string }; options?: { cas: number } } = { data: { key } }
|
|
@@ -258,21 +305,22 @@ export class HashicorpVaultKmsService implements KmsService {
|
|
|
258
305
|
timeoutMs: this.requestTimeoutMs,
|
|
259
306
|
})
|
|
260
307
|
if (res.ok) {
|
|
261
|
-
this.
|
|
308
|
+
this.markHealthy()
|
|
262
309
|
return 'ok'
|
|
263
310
|
}
|
|
264
311
|
// KV v2 returns 400 when a check-and-set write loses to a concurrent
|
|
265
312
|
// writer (path already at a newer version). That is a normal race outcome,
|
|
266
|
-
// not an unhealthy Vault —
|
|
313
|
+
// not an unhealthy Vault — Vault is reachable, so close the breaker.
|
|
267
314
|
if (typeof opts?.cas === 'number' && res.status === 400) {
|
|
315
|
+
this.markHealthy()
|
|
268
316
|
console.warn('⚠️ [encryption][kms] Vault write CAS conflict (concurrent DEK create)', { path, status: res.status })
|
|
269
317
|
return 'conflict'
|
|
270
318
|
}
|
|
271
|
-
this.
|
|
319
|
+
this.markTransientFailure()
|
|
272
320
|
console.warn('⚠️ [encryption][kms] Vault write failed', { path, status: res.status })
|
|
273
321
|
return 'error'
|
|
274
322
|
} catch (err) {
|
|
275
|
-
this.
|
|
323
|
+
this.markTransientFailure()
|
|
276
324
|
console.warn('⚠️ [encryption][kms] Vault write error', {
|
|
277
325
|
path,
|
|
278
326
|
error: (err as Error)?.message || String(err),
|