layercache 1.3.0 → 1.3.1

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/index.d.ts CHANGED
@@ -1,5 +1,5 @@
1
- import { I as InvalidationBus, C as CacheLogger, a as InvalidationMessage, b as CacheTagIndex, c as CacheStack, d as CacheWrapOptions, e as CacheGetOptions, f as CacheLayer, g as CacheSerializer, h as CacheLayerSetManyEntry, i as CacheSingleFlightCoordinator, j as CacheSingleFlightExecutionOptions } from './edge-BXWTKlI1.js';
2
- export { k as CacheAdaptiveTtlOptions, l as CacheCircuitBreakerOptions, m as CacheDegradationOptions, n as CacheHealthCheckResult, o as CacheHitRateSnapshot, p as CacheInspectResult, q as CacheLayerLatency, r as CacheMGetEntry, s as CacheMSetEntry, t as CacheMetricsSnapshot, u as CacheMissError, v as CacheNamespace, w as CacheRateLimitOptions, x as CacheSnapshotEntry, y as CacheStackEvents, z as CacheStackOptions, A as CacheStatsSnapshot, B as CacheTtlPolicy, D as CacheTtlPolicyContext, E as CacheWarmEntry, F as CacheWarmOptions, G as CacheWarmProgress, H as CacheWriteBehindOptions, J as CacheWriteOptions, K as EvictionPolicy, L as LayerTtlMap, M as MemoryLayer, N as MemoryLayerOptions, O as MemoryLayerSnapshotEntry, P as PatternMatcher, T as TagIndex, Q as createHonoCacheMiddleware } from './edge-BXWTKlI1.js';
1
+ import { I as InvalidationBus, C as CacheLogger, a as InvalidationMessage, b as CacheTagIndex, c as CacheStack, d as CacheWrapOptions, e as CacheGetOptions, f as CacheLayer, g as CacheSerializer, h as CacheLayerSetManyEntry, i as CacheSingleFlightCoordinator, j as CacheSingleFlightExecutionOptions } from './edge-CUHTP9Bc.js';
2
+ export { k as CacheAdaptiveTtlOptions, l as CacheCircuitBreakerOptions, m as CacheDegradationOptions, n as CacheHealthCheckResult, o as CacheHitRateSnapshot, p as CacheInspectResult, q as CacheLayerLatency, r as CacheMGetEntry, s as CacheMSetEntry, t as CacheMetricsSnapshot, u as CacheMissError, v as CacheNamespace, w as CacheRateLimitOptions, x as CacheSnapshotEntry, y as CacheStackEvents, z as CacheStackOptions, A as CacheStatsSnapshot, B as CacheTtlPolicy, D as CacheTtlPolicyContext, E as CacheWarmEntry, F as CacheWarmOptions, G as CacheWarmProgress, H as CacheWriteBehindOptions, J as CacheWriteOptions, K as EvictionPolicy, L as LayerTtlMap, M as MemoryLayer, N as MemoryLayerOptions, O as MemoryLayerSnapshotEntry, P as PatternMatcher, T as TagIndex, Q as createHonoCacheMiddleware } from './edge-CUHTP9Bc.js';
3
3
  import Redis from 'ioredis';
4
4
  import 'node:events';
5
5
 
@@ -63,6 +63,11 @@ declare class RedisTagIndex implements CacheTagIndex {
63
63
  }
64
64
 
65
65
  interface CacheStatsHandlerOptions {
66
+ /**
67
+ * @deprecated Exposing cache stats without authentication is a security risk.
68
+ * Provide an `authorize` callback instead. This option will be removed in a
69
+ * future release.
70
+ */
66
71
  allowPublicAccess?: boolean;
67
72
  authorize?: (request: unknown) => boolean | Promise<boolean>;
68
73
  unauthorizedStatusCode?: number;
@@ -91,6 +96,11 @@ interface FastifyLikeReply {
91
96
  interface FastifyLayercachePluginOptions {
92
97
  exposeStatsRoute?: boolean;
93
98
  statsPath?: string;
99
+ /**
100
+ * @deprecated Exposing cache stats without authentication is a security risk.
101
+ * Provide an `authorizeStatsRoute` callback instead. This option will be
102
+ * removed in a future release.
103
+ */
94
104
  allowPublicStatsRoute?: boolean;
95
105
  authorizeStatsRoute?: (request: unknown) => boolean | Promise<boolean>;
96
106
  unauthorizedStatusCode?: number;
@@ -246,6 +256,8 @@ declare class RedisLayer implements CacheLayer {
246
256
  forEachKey(visitor: (key: string) => void | Promise<void>): Promise<void>;
247
257
  private scanKeys;
248
258
  private withPrefix;
259
+ private validateKey;
260
+ private displayKey;
249
261
  private deserializeOrDelete;
250
262
  private deleteCorruptedKey;
251
263
  private rewriteWithPrimarySerializer;
@@ -274,7 +286,8 @@ interface DiskLayerOptions {
274
286
  /**
275
287
  * Maximum number of cache files to store on disk. When exceeded, the oldest
276
288
  * entries (by file mtime) are evicted to keep the directory bounded.
277
- * Defaults to unlimited.
289
+ * Defaults to 50 000. Set to `Infinity` to disable the limit (not recommended
290
+ * in production — unbounded growth will eventually exhaust disk space).
278
291
  */
279
292
  maxFiles?: number;
280
293
  /**
@@ -283,6 +296,19 @@ interface DiskLayerOptions {
283
296
  * Set to `false` to disable the limit.
284
297
  */
285
298
  maxEntryBytes?: number | false;
299
+ /**
300
+ * Encrypt cached data at rest using AES-256-GCM. Accepts a string or Buffer.
301
+ * The key material is hashed with SHA-256 to derive the actual cipher key.
302
+ * Encryption also provides authenticated integrity — a separate signingKey
303
+ * is unnecessary when encryption is enabled.
304
+ */
305
+ encryptionKey?: string | Buffer;
306
+ /**
307
+ * Sign cached data at rest using HMAC-SHA256 for integrity verification.
308
+ * Accepts a string or Buffer. Ignored when `encryptionKey` is also provided
309
+ * (AES-GCM already provides integrity).
310
+ */
311
+ signingKey?: string | Buffer;
286
312
  }
287
313
  /**
288
314
  * A file-system backed cache layer.
@@ -303,6 +329,7 @@ declare class DiskLayer implements CacheLayer {
303
329
  private readonly serializer;
304
330
  private readonly maxFiles;
305
331
  private readonly maxEntryBytes;
332
+ private readonly protection;
306
333
  private writeQueue;
307
334
  constructor(options: DiskLayerOptions);
308
335
  get<T>(key: string): Promise<T | null>;
@@ -429,9 +456,27 @@ declare class RedisSingleFlightCoordinator implements CacheSingleFlightCoordinat
429
456
  private runCommand;
430
457
  }
431
458
 
459
+ interface StampedeGuardOptions {
460
+ /**
461
+ * Maximum number of concurrent in-flight keys. When exceeded, new `execute`
462
+ * calls for keys that are not already in-flight will throw immediately.
463
+ * Defaults to 10 000.
464
+ */
465
+ maxInFlight?: number;
466
+ /**
467
+ * Maximum milliseconds to wait for a single in-flight task before rejecting
468
+ * with a timeout error. When a timeout fires the entry is released so
469
+ * subsequent callers can retry. Defaults to no timeout.
470
+ */
471
+ entryTimeoutMs?: number;
472
+ }
432
473
  declare class StampedeGuard {
433
474
  private readonly inFlight;
475
+ private readonly maxInFlight;
476
+ private readonly entryTimeoutMs;
477
+ constructor(options?: StampedeGuardOptions);
434
478
  execute<T>(key: string, task: () => Promise<T>): Promise<T>;
479
+ private withTimeout;
435
480
  private releaseEntry;
436
481
  }
437
482
 
package/dist/index.js CHANGED
@@ -457,15 +457,14 @@ function createInstanceId() {
457
457
  if (globalThis.crypto?.randomUUID) {
458
458
  return globalThis.crypto.randomUUID();
459
459
  }
460
- const bytes = new Uint8Array(16);
461
460
  if (globalThis.crypto?.getRandomValues) {
461
+ const bytes = new Uint8Array(16);
462
462
  globalThis.crypto.getRandomValues(bytes);
463
- } else {
464
- for (let i = 0; i < bytes.length; i += 1) {
465
- bytes[i] = Math.floor(Math.random() * 256);
466
- }
463
+ return `layercache-${Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("")}`;
467
464
  }
468
- return `layercache-${Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("")}`;
465
+ throw new Error(
466
+ "layercache requires a cryptographic random source. Neither crypto.randomUUID nor crypto.getRandomValues is available in this runtime."
467
+ );
469
468
  }
470
469
 
471
470
  // src/internal/CacheStackGeneration.ts
@@ -1286,7 +1285,8 @@ var CircuitBreakerManager = class {
1286
1285
  }
1287
1286
  const remainingMs = state.openUntil - now;
1288
1287
  const remainingSecs = Math.ceil(remainingMs / 1e3);
1289
- throw new Error(`Circuit breaker is open for key "${key}" (resets in ${remainingSecs}s).`);
1288
+ const displayKey = key.length > 64 ? `${key.slice(0, 64)}...` : key;
1289
+ throw new Error(`Circuit breaker is open for key "${displayKey}" (resets in ${remainingSecs}s).`);
1290
1290
  }
1291
1291
  recordFailure(key, options) {
1292
1292
  if (!options) {
@@ -1802,7 +1802,14 @@ var JsonSerializer = class {
1802
1802
  }
1803
1803
  deserialize(payload) {
1804
1804
  const normalized = Buffer.isBuffer(payload) ? payload.toString("utf8") : payload;
1805
- return sanitizeStructuredData(JSON.parse(normalized), {
1805
+ let parsed;
1806
+ try {
1807
+ parsed = JSON.parse(normalized);
1808
+ } catch (error) {
1809
+ const message = error instanceof Error ? error.message : String(error);
1810
+ throw new Error(`JsonSerializer: failed to parse JSON payload: ${message}`);
1811
+ }
1812
+ return sanitizeStructuredData(parsed, {
1806
1813
  label: "JSON payload",
1807
1814
  maxDepth: 200,
1808
1815
  maxNodes: 1e4
@@ -1813,6 +1820,12 @@ var JsonSerializer = class {
1813
1820
  // src/stampede/StampedeGuard.ts
1814
1821
  var StampedeGuard = class {
1815
1822
  inFlight = /* @__PURE__ */ new Map();
1823
+ maxInFlight;
1824
+ entryTimeoutMs;
1825
+ constructor(options = {}) {
1826
+ this.maxInFlight = options.maxInFlight ?? 1e4;
1827
+ this.entryTimeoutMs = options.entryTimeoutMs;
1828
+ }
1816
1829
  async execute(key, task) {
1817
1830
  const existing = this.inFlight.get(key);
1818
1831
  if (existing) {
@@ -1823,8 +1836,15 @@ var StampedeGuard = class {
1823
1836
  this.releaseEntry(key, existing);
1824
1837
  }
1825
1838
  }
1839
+ if (this.inFlight.size >= this.maxInFlight) {
1840
+ throw new Error(
1841
+ `StampedeGuard: in-flight limit of ${this.maxInFlight} exceeded. Rejecting new key to prevent memory exhaustion.`
1842
+ );
1843
+ }
1844
+ const taskPromise = Promise.resolve().then(task);
1845
+ const guardedPromise = this.entryTimeoutMs ? this.withTimeout(key, taskPromise, this.entryTimeoutMs) : taskPromise;
1826
1846
  const entry = {
1827
- promise: Promise.resolve().then(task),
1847
+ promise: guardedPromise,
1828
1848
  references: 1
1829
1849
  };
1830
1850
  this.inFlight.set(key, entry);
@@ -1834,6 +1854,27 @@ var StampedeGuard = class {
1834
1854
  this.releaseEntry(key, entry);
1835
1855
  }
1836
1856
  }
1857
+ withTimeout(key, promise, timeoutMs) {
1858
+ return new Promise((resolve2, reject) => {
1859
+ const timer = setTimeout(() => {
1860
+ reject(
1861
+ new Error(
1862
+ `StampedeGuard: task for key "${key.slice(0, 64)}${key.length > 64 ? "..." : ""}" timed out after ${timeoutMs}ms.`
1863
+ )
1864
+ );
1865
+ }, timeoutMs);
1866
+ promise.then(
1867
+ (value) => {
1868
+ clearTimeout(timer);
1869
+ resolve2(value);
1870
+ },
1871
+ (error) => {
1872
+ clearTimeout(timer);
1873
+ reject(error);
1874
+ }
1875
+ );
1876
+ });
1877
+ }
1837
1878
  releaseEntry(key, entry) {
1838
1879
  entry.references -= 1;
1839
1880
  const current = this.inFlight.get(key);
@@ -1899,6 +1940,10 @@ var CacheStack = class extends EventEmitter {
1899
1940
  const maxProfileEntries = options.maxProfileEntries ?? DEFAULT_MAX_PROFILE_ENTRIES;
1900
1941
  this.ttlResolver = new TtlResolver({ maxProfileEntries });
1901
1942
  this.circuitBreakerManager = new CircuitBreakerManager({ maxEntries: maxProfileEntries });
1943
+ this.stampedeGuard = new StampedeGuard({
1944
+ maxInFlight: options.stampedeMaxInFlight,
1945
+ entryTimeoutMs: options.stampedeEntryTimeoutMs
1946
+ });
1902
1947
  this.currentGeneration = options.generation;
1903
1948
  if (options.publishSetInvalidation !== void 0) {
1904
1949
  console.warn(
@@ -1977,7 +2022,7 @@ var CacheStack = class extends EventEmitter {
1977
2022
  }
1978
2023
  layers;
1979
2024
  options;
1980
- stampedeGuard = new StampedeGuard();
2025
+ stampedeGuard;
1981
2026
  metricsCollector = new MetricsCollector();
1982
2027
  instanceId = createInstanceId();
1983
2028
  startup;
@@ -2215,7 +2260,8 @@ var CacheStack = class extends EventEmitter {
2215
2260
  return promise;
2216
2261
  }
2217
2262
  if (existing.fetch !== entry.fetch || existing.optionsSignature !== optionsSignature) {
2218
- throw new Error(`mget received conflicting entries for key "${entry.key}".`);
2263
+ const displayKey = entry.key.length > 64 ? `${entry.key.slice(0, 64)}...` : entry.key;
2264
+ throw new Error(`mget received conflicting entries for key "${displayKey}".`);
2219
2265
  }
2220
2266
  return existing.promise;
2221
2267
  })
@@ -3267,6 +3313,11 @@ var RedisInvalidationBus = class {
3267
3313
 
3268
3314
  // src/http/createCacheStatsHandler.ts
3269
3315
  function createCacheStatsHandler(cache, options = {}) {
3316
+ if (options.allowPublicAccess === true) {
3317
+ console.warn(
3318
+ "[layercache] WARNING: Stats endpoint is publicly accessible without authentication. Set allowPublicAccess: false (or provide an authorize callback) before deploying to production."
3319
+ );
3320
+ }
3270
3321
  return async (request, response) => {
3271
3322
  response.setHeader?.("content-type", "application/json; charset=utf-8");
3272
3323
  response.setHeader?.("cache-control", "no-store");
@@ -3309,6 +3360,11 @@ function createCachedMethodDecorator(options) {
3309
3360
 
3310
3361
  // src/integrations/fastify.ts
3311
3362
  function createFastifyLayercachePlugin(cache, options = {}) {
3363
+ if (options.exposeStatsRoute === true && options.allowPublicStatsRoute === true) {
3364
+ console.warn(
3365
+ "[layercache] WARNING: Cache stats route is publicly accessible without authentication. Set allowPublicStatsRoute: false (or provide an authorizeStatsRoute callback) before deploying to production."
3366
+ );
3367
+ }
3312
3368
  return async (fastify) => {
3313
3369
  fastify.decorate("cache", cache);
3314
3370
  if (options.exposeStatsRoute === true && fastify.get) {
@@ -3525,7 +3581,11 @@ var RedisLayer = class {
3525
3581
  return unwrapStoredValue(payload);
3526
3582
  }
3527
3583
  async getEntry(key) {
3528
- const payload = await this.runCommand(`get("${key}")`, () => this.client.getBuffer(this.withPrefix(key)));
3584
+ this.validateKey(key);
3585
+ const payload = await this.runCommand(
3586
+ `get(${this.displayKey(key)})`,
3587
+ () => this.client.getBuffer(this.withPrefix(key))
3588
+ );
3529
3589
  if (payload === null) {
3530
3590
  return null;
3531
3591
  }
@@ -3535,6 +3595,9 @@ var RedisLayer = class {
3535
3595
  if (keys.length === 0) {
3536
3596
  return [];
3537
3597
  }
3598
+ for (const key of keys) {
3599
+ this.validateKey(key);
3600
+ }
3538
3601
  const pipeline = this.client.pipeline();
3539
3602
  for (const key of keys) {
3540
3603
  pipeline.getBuffer(this.withPrefix(key));
@@ -3557,6 +3620,9 @@ var RedisLayer = class {
3557
3620
  if (entries.length === 0) {
3558
3621
  return;
3559
3622
  }
3623
+ for (const entry of entries) {
3624
+ this.validateKey(entry.key);
3625
+ }
3560
3626
  const pipeline = this.client.pipeline();
3561
3627
  for (const entry of entries) {
3562
3628
  const serialized = this.primarySerializer().serialize(entry.value);
@@ -3571,33 +3637,43 @@ var RedisLayer = class {
3571
3637
  await this.runCommand(`mset(${entries.length})`, () => pipeline.exec());
3572
3638
  }
3573
3639
  async set(key, value, ttl = this.defaultTtl) {
3640
+ this.validateKey(key);
3574
3641
  const serialized = this.primarySerializer().serialize(value);
3575
3642
  const payload = await this.encodePayload(serialized);
3576
3643
  const normalizedKey = this.withPrefix(key);
3577
3644
  if (ttl && ttl > 0) {
3578
- await this.runCommand(`set("${key}")`, () => this.client.set(normalizedKey, payload, "EX", ttl));
3645
+ await this.runCommand(
3646
+ `set(${this.displayKey(key)})`,
3647
+ () => this.client.set(normalizedKey, payload, "EX", ttl)
3648
+ );
3579
3649
  return;
3580
3650
  }
3581
- await this.runCommand(`set("${key}")`, () => this.client.set(normalizedKey, payload));
3651
+ await this.runCommand(`set(${this.displayKey(key)})`, () => this.client.set(normalizedKey, payload));
3582
3652
  }
3583
3653
  async delete(key) {
3584
- await this.runCommand(`delete("${key}")`, () => this.client.del(this.withPrefix(key)));
3654
+ this.validateKey(key);
3655
+ await this.runCommand(`delete(${this.displayKey(key)})`, () => this.client.del(this.withPrefix(key)));
3585
3656
  }
3586
3657
  async deleteMany(keys) {
3587
3658
  if (keys.length === 0) {
3588
3659
  return;
3589
3660
  }
3661
+ for (const key of keys) {
3662
+ this.validateKey(key);
3663
+ }
3590
3664
  await this.runCommand(
3591
3665
  `deleteMany(${keys.length})`,
3592
3666
  () => this.client.del(...keys.map((key) => this.withPrefix(key)))
3593
3667
  );
3594
3668
  }
3595
3669
  async has(key) {
3596
- const exists = await this.runCommand(`has("${key}")`, () => this.client.exists(this.withPrefix(key)));
3670
+ this.validateKey(key);
3671
+ const exists = await this.runCommand(`has(${this.displayKey(key)})`, () => this.client.exists(this.withPrefix(key)));
3597
3672
  return exists > 0;
3598
3673
  }
3599
3674
  async ttl(key) {
3600
- const remaining = await this.runCommand(`ttl("${key}")`, () => this.client.ttl(this.withPrefix(key)));
3675
+ this.validateKey(key);
3676
+ const remaining = await this.runCommand(`ttl(${this.displayKey(key)})`, () => this.client.ttl(this.withPrefix(key)));
3601
3677
  if (remaining < 0) {
3602
3678
  return null;
3603
3679
  }
@@ -3697,6 +3773,23 @@ var RedisLayer = class {
3697
3773
  withPrefix(key) {
3698
3774
  return `${this.prefix}${key}`;
3699
3775
  }
3776
+ validateKey(key) {
3777
+ if (key.length === 0) {
3778
+ throw new Error("RedisLayer: key must not be empty.");
3779
+ }
3780
+ if (key.length > 1024) {
3781
+ throw new Error(`RedisLayer: key length must be at most 1 024 characters (got ${key.length}).`);
3782
+ }
3783
+ if (/[\u0000-\u001F\u007F]/.test(key)) {
3784
+ throw new Error("RedisLayer: key contains unsupported control characters.");
3785
+ }
3786
+ if (/[\uD800-\uDFFF]/.test(key)) {
3787
+ throw new Error("RedisLayer: key contains unsupported surrogate code points.");
3788
+ }
3789
+ }
3790
+ displayKey(key) {
3791
+ return key.length > 64 ? `${key.slice(0, 64)}...` : key;
3792
+ }
3700
3793
  async deserializeOrDelete(key, payload) {
3701
3794
  let decodedPayload;
3702
3795
  try {
@@ -3720,23 +3813,30 @@ var RedisLayer = class {
3720
3813
  }
3721
3814
  async deleteCorruptedKey(key) {
3722
3815
  try {
3723
- await this.runCommand(`deleteCorrupted("${key}")`, () => this.client.del(this.withPrefix(key)));
3816
+ await this.runCommand(`deleteCorrupted(${this.displayKey(key)})`, () => this.client.del(this.withPrefix(key)));
3724
3817
  } catch (deleteError) {
3725
- console.warn(`[layercache] RedisLayer: failed to delete corrupted key "${key}"`, deleteError);
3818
+ const displayKey = key.length > 64 ? `${key.slice(0, 64)}...` : key;
3819
+ console.warn(`[layercache] RedisLayer: failed to delete corrupted key "${displayKey}"`, deleteError);
3726
3820
  }
3727
3821
  }
3728
3822
  async rewriteWithPrimarySerializer(key, value) {
3729
3823
  const serialized = this.primarySerializer().serialize(value);
3730
3824
  const payload = await this.encodePayload(serialized);
3731
- const ttl = await this.runCommand(`rewrite-ttl("${key}")`, () => this.client.ttl(this.withPrefix(key)));
3825
+ const ttl = await this.runCommand(
3826
+ `rewrite-ttl(${this.displayKey(key)})`,
3827
+ () => this.client.ttl(this.withPrefix(key))
3828
+ );
3732
3829
  if (ttl > 0) {
3733
3830
  await this.runCommand(
3734
- `rewrite-set("${key}")`,
3831
+ `rewrite-set(${this.displayKey(key)})`,
3735
3832
  () => this.client.set(this.withPrefix(key), payload, "EX", ttl)
3736
3833
  );
3737
3834
  return;
3738
3835
  }
3739
- await this.runCommand(`rewrite-set("${key}")`, () => this.client.set(this.withPrefix(key), payload));
3836
+ await this.runCommand(
3837
+ `rewrite-set(${this.displayKey(key)})`,
3838
+ () => this.client.set(this.withPrefix(key), payload)
3839
+ );
3740
3840
  }
3741
3841
  primarySerializer() {
3742
3842
  const serializer = this.serializers[0];
@@ -3864,9 +3964,131 @@ var RedisLayer = class {
3864
3964
  };
3865
3965
 
3866
3966
  // src/layers/DiskLayer.ts
3867
- import { createHash, randomBytes as randomBytes2 } from "crypto";
3967
+ import { createHash as createHash2, randomBytes as randomBytes3 } from "crypto";
3868
3968
  import { promises as fs2 } from "fs";
3869
3969
  import { join, resolve } from "path";
3970
+
3971
+ // src/internal/PayloadProtection.ts
3972
+ import { createCipheriv, createDecipheriv, createHash, createHmac, randomBytes as randomBytes2, timingSafeEqual } from "crypto";
3973
+ var MAGIC_ENCRYPTED = Buffer.from("LCP1:");
3974
+ var MAGIC_SIGNED = Buffer.from("LCS1:");
3975
+ var ALGORITHM = "aes-256-gcm";
3976
+ var IV_LENGTH = 12;
3977
+ var AUTH_TAG_LENGTH = 16;
3978
+ var HMAC_LENGTH = 32;
3979
+ var PayloadProtection = class {
3980
+ encryptionKey;
3981
+ signingKey;
3982
+ constructor(options) {
3983
+ if (options.encryptionKey) {
3984
+ const raw = Buffer.isBuffer(options.encryptionKey) ? options.encryptionKey : Buffer.from(options.encryptionKey, "utf8");
3985
+ this.encryptionKey = createHash("sha256").update(raw).digest();
3986
+ }
3987
+ if (options.signingKey && !options.encryptionKey) {
3988
+ const raw = Buffer.isBuffer(options.signingKey) ? options.signingKey : Buffer.from(options.signingKey, "utf8");
3989
+ this.signingKey = createHash("sha256").update(raw).digest();
3990
+ }
3991
+ }
3992
+ /** Returns `true` when any protection (encryption or signing) is configured. */
3993
+ get isEnabled() {
3994
+ return this.encryptionKey !== void 0 || this.signingKey !== void 0;
3995
+ }
3996
+ /**
3997
+ * Applies the configured protection (encryption or signing) to a payload.
3998
+ * Returns the input unchanged when no protection is configured.
3999
+ */
4000
+ protect(payload) {
4001
+ if (this.encryptionKey) {
4002
+ return this.encrypt(payload, this.encryptionKey);
4003
+ }
4004
+ if (this.signingKey) {
4005
+ return this.sign(payload, this.signingKey);
4006
+ }
4007
+ return payload;
4008
+ }
4009
+ /**
4010
+ * Removes the protection layer from a payload.
4011
+ *
4012
+ * - Protected payloads are decrypted/verified using the configured keys.
4013
+ * - Legacy unprotected payloads pass through unchanged when **no** protection
4014
+ * is configured.
4015
+ * - If protection **is** configured but the payload is not protected, the
4016
+ * payload is treated as a legacy entry. Callers can handle this case by
4017
+ * checking `isEnabled` separately.
4018
+ */
4019
+ unprotect(payload) {
4020
+ if (this.startsWith(payload, MAGIC_ENCRYPTED)) {
4021
+ if (!this.encryptionKey) {
4022
+ throw new PayloadProtectionError("Encrypted payload but no encryptionKey configured.");
4023
+ }
4024
+ return this.decrypt(payload, this.encryptionKey);
4025
+ }
4026
+ if (this.startsWith(payload, MAGIC_SIGNED)) {
4027
+ if (!this.signingKey) {
4028
+ throw new PayloadProtectionError("Signed payload but no signingKey configured.");
4029
+ }
4030
+ return this.verify(payload, this.signingKey);
4031
+ }
4032
+ return payload;
4033
+ }
4034
+ // ── Encryption (AES-256-GCM) ──────────────────────────────────────────
4035
+ encrypt(plaintext, key) {
4036
+ const iv = randomBytes2(IV_LENGTH);
4037
+ const cipher = createCipheriv(ALGORITHM, key, iv, { authTagLength: AUTH_TAG_LENGTH });
4038
+ const encrypted = Buffer.concat([cipher.update(plaintext), cipher.final()]);
4039
+ const authTag = cipher.getAuthTag();
4040
+ return Buffer.concat([MAGIC_ENCRYPTED, iv, authTag, encrypted]);
4041
+ }
4042
+ decrypt(payload, key) {
4043
+ const headerEnd = MAGIC_ENCRYPTED.length;
4044
+ const iv = payload.subarray(headerEnd, headerEnd + IV_LENGTH);
4045
+ const authTag = payload.subarray(headerEnd + IV_LENGTH, headerEnd + IV_LENGTH + AUTH_TAG_LENGTH);
4046
+ const ciphertext = payload.subarray(headerEnd + IV_LENGTH + AUTH_TAG_LENGTH);
4047
+ try {
4048
+ const decipher = createDecipheriv(ALGORITHM, key, iv, {
4049
+ authTagLength: AUTH_TAG_LENGTH
4050
+ });
4051
+ decipher.setAuthTag(authTag);
4052
+ return Buffer.concat([decipher.update(ciphertext), decipher.final()]);
4053
+ } catch {
4054
+ throw new PayloadProtectionError(
4055
+ "Decryption failed. The data may have been tampered with or the encryptionKey is incorrect."
4056
+ );
4057
+ }
4058
+ }
4059
+ // ── Signing (HMAC-SHA256) ─────────────────────────────────────────────
4060
+ sign(payload, key) {
4061
+ const hmac = createHmac("sha256", key).update(payload).digest();
4062
+ return Buffer.concat([MAGIC_SIGNED, hmac, payload]);
4063
+ }
4064
+ verify(payload, key) {
4065
+ const headerEnd = MAGIC_SIGNED.length;
4066
+ const receivedHmac = payload.subarray(headerEnd, headerEnd + HMAC_LENGTH);
4067
+ const data = payload.subarray(headerEnd + HMAC_LENGTH);
4068
+ const expectedHmac = createHmac("sha256", key).update(data).digest();
4069
+ if (receivedHmac.length !== HMAC_LENGTH || !timingSafeEqual(receivedHmac, expectedHmac)) {
4070
+ throw new PayloadProtectionError(
4071
+ "HMAC verification failed. The data may have been tampered with or the signingKey is incorrect."
4072
+ );
4073
+ }
4074
+ return data;
4075
+ }
4076
+ // ── Helpers ────────────────────────────────────────────────────────────
4077
+ startsWith(buffer, prefix) {
4078
+ if (buffer.length < prefix.length) {
4079
+ return false;
4080
+ }
4081
+ return buffer.subarray(0, prefix.length).equals(prefix);
4082
+ }
4083
+ };
4084
+ var PayloadProtectionError = class extends Error {
4085
+ constructor(message) {
4086
+ super(message);
4087
+ this.name = "PayloadProtectionError";
4088
+ }
4089
+ };
4090
+
4091
+ // src/layers/DiskLayer.ts
3870
4092
  var FILE_SCAN_CONCURRENCY = 32;
3871
4093
  var DiskLayer = class {
3872
4094
  name;
@@ -3876,6 +4098,7 @@ var DiskLayer = class {
3876
4098
  serializer;
3877
4099
  maxFiles;
3878
4100
  maxEntryBytes;
4101
+ protection;
3879
4102
  writeQueue = Promise.resolve();
3880
4103
  constructor(options) {
3881
4104
  this.directory = this.resolveDirectory(options.directory);
@@ -3884,6 +4107,10 @@ var DiskLayer = class {
3884
4107
  this.serializer = options.serializer ?? new JsonSerializer();
3885
4108
  this.maxFiles = this.normalizeMaxFiles(options.maxFiles);
3886
4109
  this.maxEntryBytes = this.normalizeMaxEntryBytes(options.maxEntryBytes);
4110
+ this.protection = new PayloadProtection({
4111
+ encryptionKey: options.encryptionKey,
4112
+ signingKey: options.signingKey
4113
+ });
3887
4114
  }
3888
4115
  async get(key) {
3889
4116
  return unwrapStoredValue(await this.getEntry(key));
@@ -3916,10 +4143,12 @@ var DiskLayer = class {
3916
4143
  expiresAt: ttl && ttl > 0 ? Date.now() + ttl * 1e3 : null
3917
4144
  };
3918
4145
  const payload = this.serializer.serialize(entry);
4146
+ const raw = Buffer.isBuffer(payload) ? payload : Buffer.from(payload, "utf8");
4147
+ const protectedPayload = this.protection.protect(raw);
3919
4148
  const targetPath = this.keyToPath(key);
3920
- const tempPath = `${targetPath}.${process.pid}.${Date.now()}.${randomBytes2(8).toString("hex")}.tmp`;
4149
+ const tempPath = `${targetPath}.${process.pid}.${Date.now()}.${randomBytes3(8).toString("hex")}.tmp`;
3921
4150
  try {
3922
- await fs2.writeFile(tempPath, payload);
4151
+ await fs2.writeFile(tempPath, protectedPayload);
3923
4152
  await fs2.rename(tempPath, targetPath);
3924
4153
  } catch (error) {
3925
4154
  await this.safeDelete(tempPath);
@@ -4017,7 +4246,7 @@ var DiskLayer = class {
4017
4246
  async dispose() {
4018
4247
  }
4019
4248
  keyToPath(key) {
4020
- const hash = createHash("sha256").update(key).digest("hex");
4249
+ const hash = createHash2("sha256").update(key).digest("hex");
4021
4250
  return join(this.directory, `${hash}.lc`);
4022
4251
  }
4023
4252
  resolveDirectory(directory) {
@@ -4031,10 +4260,13 @@ var DiskLayer = class {
4031
4260
  }
4032
4261
  normalizeMaxFiles(maxFiles) {
4033
4262
  if (maxFiles === void 0) {
4263
+ return 5e4;
4264
+ }
4265
+ if (maxFiles === Number.POSITIVE_INFINITY) {
4034
4266
  return void 0;
4035
4267
  }
4036
4268
  if (!Number.isInteger(maxFiles) || maxFiles <= 0) {
4037
- throw new Error("DiskLayer.maxFiles must be a positive integer.");
4269
+ throw new Error("DiskLayer.maxFiles must be a positive integer or Infinity.");
4038
4270
  }
4039
4271
  return maxFiles;
4040
4272
  }
@@ -4146,7 +4378,8 @@ var DiskLayer = class {
4146
4378
  );
4147
4379
  }
4148
4380
  deserializeEntry(raw) {
4149
- const entry = this.serializer.deserialize(raw);
4381
+ const unprotected = this.protection.unprotect(raw);
4382
+ const entry = this.serializer.deserialize(unprotected);
4150
4383
  if (!isDiskEntry(entry)) {
4151
4384
  throw new Error("Invalid disk cache entry.");
4152
4385
  }
@@ -4268,7 +4501,10 @@ var MemcachedLayer = class {
4268
4501
  validateKey(key) {
4269
4502
  const fullKey = this.withPrefix(key);
4270
4503
  if (Buffer.byteLength(fullKey, "utf8") > 250) {
4271
- throw new Error(`MemcachedLayer: key exceeds 250-byte Memcached limit: "${fullKey.slice(0, 60)}..."`);
4504
+ const displayKey = fullKey.slice(0, 64);
4505
+ throw new Error(
4506
+ `MemcachedLayer: key exceeds 250-byte Memcached limit: "${displayKey}${fullKey.length > 64 ? "..." : ""}"`
4507
+ );
4272
4508
  }
4273
4509
  if (/[\s\x00-\x1f\x7f]/.test(fullKey)) {
4274
4510
  throw new Error(
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "layercache",
3
- "version": "1.3.0",
3
+ "version": "1.3.1",
4
4
  "description": "Production-ready multi-layer caching for Node.js. Stack memory + Redis + disk behind one API with stampede prevention, tag invalidation, stale serving, and full observability.",
5
5
  "keywords": [
6
6
  "cache",