@bymax-one/nest-cache 1.2.1 → 1.2.3

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/CHANGELOG.md CHANGED
@@ -4,6 +4,99 @@ All notable changes to this project are documented in this file. The format is
4
4
  based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this
5
5
  project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
6
 
7
+ ## [Unreleased]
8
+
9
+ ## [1.2.3] - 2026-10-30
10
+
11
+ ### Changed
12
+
13
+ - `JsonSerializer.deserialize` throws `DESERIALIZATION_FAILED` with `details` of a fixed `error` text and the
14
+ `length` of the stored value. `details.preview` and the parser's own message, which quoted the start of the
15
+ input, are gone. The error code is unchanged; a consumer that read `details.preview` must stop.
16
+ - **Apply to a consumer.** Do not read `details.preview` or `details.error` to debug a decoding
17
+ failure. Log the error on the server by its code, `cache.deserialization_failed`; the call site already
18
+ knows the prefix and id it asked for. Use `details.length` when you need a size signal. A backend that
19
+ sent `details` to its clients needs no other change: the response no longer carries any part of the
20
+ stored value.
21
+ - Apart from that, nothing you execute changes.
22
+
23
+ ### Documentation
24
+
25
+ - `README.md`, `docs/technical_specification.md` and the contributor documents state that
26
+ `KeyBuilder` validates `prefix` and `id` only for emptiness. A separator inside either
27
+ collides (`build('a', 'b:c')` and `build('a:b', 'c')` both give `app:a:b:c`), and a glob
28
+ metacharacter in `prefix` widens `keys()` and `scan()` within the namespace. Behaviour is
29
+ unchanged. Keys are unambiguous only for a single-character separator that appears in neither field; encoding each field with `encodeURIComponent` provides that for a separator it escapes and never emits, such as the default `':'`.
30
+ - Contributor and security documents match the current tooling: pnpm 11, the admin
31
+ read-only check in the verification chain, mutation testing after merge and weekly, and
32
+ how dependency advisories are handled.
33
+
34
+ ### Security
35
+
36
+ - **A non-JSON stored value no longer appears in the exception body.** `CacheException` extends
37
+ `HttpException`, so a default Nest filter sends `details` to the HTTP client. A cached value that was
38
+ not JSON (written by another writer, or by another serializer) leaked up to 100 characters of itself
39
+ through `details.preview`, and the start of it through `details.error`. It could carry PII or a token.
40
+ Only a malformed entry triggers it; a valid JSON value never does.
41
+ - Dependency advisories in development-only transitive dependencies are fixed by raising the
42
+ `overrides` floors in `pnpm-workspace.yaml`. None of them ships: the package
43
+ publishes with `"dependencies": {}`, so no consumer was exposed to these advisories.
44
+ - `GHSA-gcqx-w48m-w2h4` (`deepmerge`, reached only through `jest-config`) has no patched
45
+ release. It is suppressed in `osv-scanner.toml` until 2027-01-27 and nowhere else.
46
+
47
+ ### Internal
48
+
49
+ - Development dependencies updated within their ranges; GitHub Actions bumped.
50
+
51
+ ## [1.2.2] - 2026-09-04
52
+
53
+ ### Changed
54
+
55
+ - **Nothing you execute changes.** `dist/` built from this tag is byte-for-byte
56
+ identical to 1.2.1 — verified by unpacking the published 1.2.1 tarball and
57
+ diffing it against a fresh build, not asserted. `src/` has not been touched
58
+ since 1.2.1.
59
+
60
+ Two files in an installed copy do differ, and neither is code: the `version`
61
+ field in `package.json`, and this changelog. Upgrading from 1.2.1 changes no
62
+ behaviour; staying on 1.2.1 costs you nothing.
63
+
64
+ The version exists because the repository moved underneath the artifact, and a
65
+ released tag is where that movement is recorded.
66
+
67
+ ### Security
68
+
69
+ - **Three advisory fixes, all in development dependencies.** `browserslist` to
70
+ 4.28.8 (GHSA-73wf-gq98-2v4g, GHSA-c83g-rgw3-j3cx), `fast-uri` to 3.1.6 (four
71
+ advisories at CVSS 7.5) and `qs` to 6.16.0 (GHSA-4mjr-xmp4-gh2g,
72
+ GHSA-x5fp-wj9c-mxmx). All three are reached through `@stryker-mutator/*`, which
73
+ is a devDependency, and this package publishes with `"dependencies": {}`.
74
+
75
+ **No consumer was exposed and none is protected by this release.** The fixes
76
+ protect the build environment. They are listed because "Security" in a changelog
77
+ is read as "upgrade for this", and here that reading would be wrong.
78
+
79
+ Two of the three were already pinned in `overrides` at floors that already
80
+ admitted the fixed versions, and protected nothing: a caret there is a floor
81
+ applied when resolution happens, not a standing instruction to re-resolve. The
82
+ check that works is the resolved version in the lockfile, never the presence of
83
+ an entry.
84
+
85
+ ### Internal
86
+
87
+ - ESLint 9 to 10, with `eslint-plugin-import` replaced by the maintained fork
88
+ `eslint-plugin-import-x`. The 9 line is end-of-life: 9.39.5 is the last release
89
+ and is itself deprecated, and the `maintenance` dist-tag points at it.
90
+ - `import-x/parsers` is now configured, which fixes a rule that had never worked.
91
+ `no-cycle` was declared `error` and reported as active by `--print-config` while
92
+ a real two-file cycle passed with exit 0, because the dependency parser skips in
93
+ silence every extension it cannot map. No cycles exist in `src/`; the gate was
94
+ simply open.
95
+ - `@parcel/watcher`'s build script is recorded as denied, so `pnpm install` stops
96
+ failing on it under pnpm 11.
97
+ - The shared Bymax code-review rules are adopted in `AGENTS.md`, kept in step by
98
+ the `agents-sync` workflow.
99
+
7
100
  ## [1.2.1] - 2026-08-21
8
101
 
9
102
  ### Changed
@@ -301,6 +394,9 @@ type or export moved.
301
394
  - Published with npm OIDC provenance — no long-lived tokens
302
395
  - Zero direct runtime dependencies (`dependencies: {}`) — `ioredis` and NestJS via peer deps
303
396
 
397
+ [Unreleased]: https://github.com/bymaxone/nest-cache/compare/v1.2.3...HEAD
398
+ [1.2.3]: https://github.com/bymaxone/nest-cache/compare/v1.2.2...v1.2.3
399
+ [1.2.2]: https://github.com/bymaxone/nest-cache/compare/v1.2.1...v1.2.2
304
400
  [1.2.1]: https://github.com/bymaxone/nest-cache/compare/v1.2.0...v1.2.1
305
401
  [1.2.0]: https://github.com/bymaxone/nest-cache/compare/v1.1.0...v1.2.0
306
402
  [1.1.0]: https://github.com/bymaxone/nest-cache/compare/v1.0.6...v1.1.0
package/README.md CHANGED
@@ -47,7 +47,7 @@ so you control exact versions and the supply-chain surface stays minimal.
47
47
  - **🧬 Typed get/set** — `get<T>` / `set<T>` go through a pluggable `ISerializer` (JSON by default). Deserialization **fails closed** — a malformed payload throws `CacheException`, never a half-decoded value.
48
48
  - **📡 Batteries included** — Pub/Sub on namespaced channels and a Lua script manager (`EVALSHA` + `NOSCRIPT` fallback) ship in the box, on top of the full string/hash/set/numeric command surface.
49
49
  - **♻️ Lifecycle done right** — singleton connection with `OnModuleInit` / `OnModuleDestroy`, bounded retry strategy, `READONLY`-failover reconnect, and a graceful `quit()` with shutdown timeout.
50
- - **🔌 Bring your own observability** — connection events surface through an `events.onEvent` callback; plug in [`@bymax-one/nest-logger`](https://github.com/bymaxone/nest-logger) or your metrics layer. No observability peer deps forced on you.
50
+ - **🔌 Bring your own observability** — connection events and Pub/Sub handler failures surface through an `events.onEvent` callback; plug in [`@bymax-one/nest-logger`](https://github.com/bymaxone/nest-logger) or your metrics layer. No observability peer deps forced on you.
51
51
 
52
52
  ```
53
53
  pnpm add @bymax-one/nest-cache ioredis
@@ -69,21 +69,21 @@ pnpm add @bymax-one/nest-cache ioredis
69
69
  - ✅ **Automatic namespacing** — the key builder enforces tenant/feature isolation; no manual string concatenation
70
70
  - ✅ **Namespaced channels** — Pub/Sub channels are composed through the same builder as keys
71
71
  - ✅ **Surgical flush** — `flushNamespace()` scans only `{namespace}:*`, never another namespace's keys
72
- - ✅ **Validated at bootstrap** — an empty namespace, or one containing the key separator, fails module initialization
72
+ - ✅ **Validated at bootstrap** — an empty namespace, an empty key separator, or a namespace containing the key separator or a Redis glob metacharacter (`*`, `?`, `[`, `\`) fails module initialization
73
73
 
74
74
  ### ⚙️ Reliability & Topology
75
75
 
76
76
  - ✅ **Multi-topology** — standalone, Sentinel, and Cluster modes from the same options shape
77
77
  - ✅ **Managed lifecycle** — singleton connection via `OnModuleInit` / `OnModuleDestroy`, graceful `quit()` with a shutdown timeout
78
78
  - ✅ **Bounded retries** — `maxRetriesPerRequest` and a reconnect policy that triggers on `READONLY` replica failover
79
- - ✅ **Connection events** — `connect` / `ready` / `error` / `close` / `reconnecting` / `end` surfaced via `events.onEvent`
79
+ - ✅ **Connection events** — `connect` / `ready` / `error` / `close` / `reconnecting` / `end` surfaced via `events.onEvent`, which also receives Pub/Sub handler and deserialization failures as `'error'` with `reason: 'handler_error'`
80
80
  - ✅ **Health checks** — `isHealthy()` / `ping()` / `info()` for readiness and liveness endpoints
81
81
 
82
82
  ### 🛡️ Safety
83
83
 
84
84
  - ✅ **Fail-closed serialization** — malformed payloads raise `CacheException(DESERIALIZATION_FAILED)`, never a partial value
85
85
  - ✅ **Production flush guard** — `flushNamespace()` is blocked under `NODE_ENV=production` unless explicitly allowed
86
- - ✅ **Secrets never echoed** — connection URLs and cached values are kept out of error `details`; previews are truncated
86
+ - ✅ **Secrets never echoed** — connection URLs and cached values are kept out of error `details`; no part of a stored value is ever echoed
87
87
  - ✅ **Script bodies are registered, not interpolated** — call sites pass a name plus keys/args, never Lua source
88
88
 
89
89
  ### 🧩 Developer Experience
@@ -248,18 +248,25 @@ Keys resolve to `app:user-profile:<userId>` — namespaced automatically.
248
248
 
249
249
  ## ⚙️ Configuration
250
250
 
251
- | Option | Type | Default | Description |
252
- | ------------------- | ----------------------------------------- | ---------------- | ------------------------------------------------------------------ |
253
- | `mode` | `'standalone' \| 'sentinel' \| 'cluster'` | `'standalone'` | Redis topology |
254
- | `connection.url` | `string` | — | `redis://` / `rediss://` URL (overrides discrete host/port fields) |
255
- | `connection.tls` | `tls.ConnectionOptions` | — | TLS options for `rediss://` |
256
- | `namespace` | `string` | `'app'` | Key prefix for tenant/feature isolation |
257
- | `serializer` | `ISerializer` | `JsonSerializer` | Value encoding/decoding (plug MsgPack, CBOR, etc.) |
258
- | `events.onEvent` | `(event, data) => void` | — | Connection-event hook (plug a logger or metrics) |
259
- | `scripts` | `IScriptDefinition[]` | `[]` | Lua scripts to preload on init |
260
- | `shutdownTimeoutMs` | `number` | `5000` | Graceful `quit()` timeout before forced `disconnect()` |
261
-
262
- Both `forRoot(options)` (synchronous) and `forRootAsync({ useFactory, inject, imports })` are supported. The module registers globally by default — pass `isGlobal: false` to scope it to the importing module.
251
+ | Option | Type | Default | Description |
252
+ | ------------------------ | ----------------------------------------- | ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
253
+ | `mode` | `'standalone' \| 'sentinel' \| 'cluster'` | `'standalone'` | Redis topology |
254
+ | `connection.url` | `string` | — | `redis://` / `rediss://` URL (overrides discrete host/port fields) |
255
+ | `connection.tls` | `tls.ConnectionOptions` | — | TLS options for `rediss://` |
256
+ | `namespace` | `string` | `'app'` | Key prefix for tenant/feature isolation |
257
+ | `serializer` | `ISerializer` | `JsonSerializer` | Value encoding/decoding (plug MsgPack, CBOR, etc.) |
258
+ | `events.onEvent` | `(event, data) => void` | — | Lifecycle and Pub/Sub failure hook (plug a logger or metrics); receives connection events, and `'error'` with `reason: 'handler_error'` when a subscriber handler or deserialization fails |
259
+ | `scripts` | `IScriptDefinition[]` | — | Lua scripts to preload on init |
260
+ | `shutdownTimeoutMs` | `number` | `5000` | Graceful `quit()` timeout before forced `disconnect()` (minimum `100`) |
261
+ | `keySeparator` | `string` | `':'` | Separator between namespace, prefix and id segments |
262
+ | `allowFlushInProduction` | `boolean` | `false` | Allows `flushNamespace()` when `NODE_ENV=production` |
263
+ | `isGlobal` | `boolean` | `true` | Register the module globally |
264
+ | `sentinel` | `{ sentinels, name, ... }` | — | Sentinel connection block; required when `mode: 'sentinel'` |
265
+ | `cluster` | `{ nodes, options? }` | — | Cluster connection block; required when `mode: 'cluster'` |
266
+
267
+ `connection` also accepts `host`, `port`, `password`, `db`, `username`, `lazyConnect`, `connectTimeout` (minimum `100`), `commandTimeout`, `maxRetriesPerRequest`, `enableReadyCheck`, `enableOfflineQueue`, `retryStrategy`, `reconnectOnError`, `keepAlive`, `noDelay` and `family`; `url` takes precedence over the discrete fields.
268
+
269
+ Both `forRoot(options)` (synchronous) and `forRootAsync({ useFactory, inject, imports })` are supported. The module registers globally by default — pass `isGlobal: false` to scope it to the importing module. With `forRootAsync`, `isGlobal` is read at the call site (`forRootAsync({ isGlobal, ... })`); a value returned from `useFactory` has no effect.
263
270
 
264
271
  ---
265
272
 
@@ -271,6 +278,26 @@ keeps tenants and features isolated and makes `flushNamespace()` surgical. Reach
271
278
  `getClient()` to set raw, un-namespaced keys is supported as an escape hatch but documented as an
272
279
  anti-pattern.
273
280
 
281
+ `prefix` and `id` are supplied by the caller and the library validates them only for emptiness
282
+ (`INVALID_KEY`); it does **not** reject the separator or glob characters inside them. Two
283
+ consequences follow:
284
+
285
+ - **Separator collision.** `build('a', 'b:c')` and `build('a:b', 'c')` both produce `app:a:b:c`, so
286
+ two different `(prefix, id)` pairs address the same key.
287
+ - **Glob widening.** `keys(prefix, pattern)` and `scan(prefix, pattern)` pass the composed key to
288
+ Redis as a match pattern, so a glob metacharacter (`*`, `?`, `[`, `\`) in `prefix` can match keys
289
+ outside that prefix, still within the namespace. Single-key commands (`get`, `set`, `del`, ...)
290
+ treat the key literally and are not affected; `flushNamespace()` uses only the validated namespace.
291
+
292
+ This is documentation only; behavior is unchanged. When a `prefix` or `id` comes from untrusted
293
+ input, encode each field, for example `cache.get(encodeURIComponent(prefix), encodeURIComponent(id))`.
294
+ Keys are unambiguous only when `keySeparator` is a single character that appears in neither `prefix`
295
+ nor `id`. Per-field encoding guarantees that for a separator `encodeURIComponent` escapes and never
296
+ emits: the default `':'` qualifies; `%` and `- _ . ! ~ * ' ( )` do not, so for them reject the
297
+ separator in the fields. A multi-character separator can straddle a field boundary (with `'aa'`,
298
+ `('ba', 'c')` and `('b', 'ac')` build the same key), so keep a single character. A `prefix` used with
299
+ `keys()` / `scan()` must also avoid glob characters (`*`, `?`, `[`, `\`).
300
+
274
301
  ---
275
302
 
276
303
  ## 📡 Pub/Sub
@@ -575,7 +602,7 @@ A cache sits between your application and every value it has ever computed, so t
575
602
 
576
603
  ### Namespace isolation is structural
577
604
 
578
- Every key and every Pub/Sub channel is composed through `KeyBuilder`, which prepends `{namespace}{separator}` before the command reaches Redis. `CacheService.get('user-profile', '42')` can only ever read `app:user-profile:42` — there is no code path through the typed API that emits a bare key.
605
+ Every key and every Pub/Sub channel is composed through `KeyBuilder`, which prepends `{namespace}{separator}` before the command reaches Redis. With fixed literals, `CacheService.get('user-profile', '42')` reads `app:user-profile:42` — there is no code path through the typed API that emits a bare key. The namespace is the isolation boundary; the library does not constrain `prefix` and `id` beyond non-emptiness.
579
606
 
580
607
  The namespace itself is validated at module bootstrap, not at call time:
581
608
 
@@ -583,10 +610,14 @@ The namespace itself is validated at module bootstrap, not at call time:
583
610
  | --------------------------------------- | ---------------------------------------------------------------- |
584
611
  | Empty or whitespace-only namespace | `CacheException(INVALID_NAMESPACE)` — the module fails to start |
585
612
  | Namespace containing the key separator | `CacheException(INVALID_NAMESPACE)` — prevents prefix collisions |
613
+ | Empty key separator | `CacheException(INVALID_NAMESPACE)` — the module fails to start |
614
+ | Namespace containing `*`, `?`, `[`, `\` | `CacheException(INVALID_NAMESPACE)` — see the glob section below |
586
615
  | Empty `prefix` or `id` at the call site | `CacheException(INVALID_KEY)` — no key is sent to Redis |
587
616
 
588
617
  A namespace containing the separator is rejected because `namespace: 'a:b'` and `namespace: 'a'` with prefix `b` would resolve to the same keyspace — a tenant boundary that reads as isolated but is not.
589
618
 
619
+ The same collision exists below the namespace and is **not** enforced by the library: `prefix` and `id` are validated only for emptiness, so `build('a', 'b:c')` and `build('a:b', 'c')` both give `app:a:b:c`, and a glob metacharacter in `prefix` widens `keys()` / `scan()` within the namespace. Behavior is unchanged; keys are unambiguous only for a single-character `keySeparator` that appears in neither `prefix` nor `id`: encode untrusted values per field with `encodeURIComponent` only when the separator is one it escapes and never emits (the default `':'` is; `%` and `- _ . ! ~ * ' ( )` are not), otherwise reject the separator in them; a multi-character separator can straddle a field boundary, so keep a single character; reject glob characters in `prefix`.
620
+
590
621
  ### Deserialization fails closed
591
622
 
592
623
  `JsonSerializer.deserialize` throws `CacheException(DESERIALIZATION_FAILED)` on any payload that is not valid JSON. It never returns `undefined`, never returns a partial object, and never lets a corrupted entry masquerade as a valid `T`. The same contract is required of any custom `ISerializer` — fail-closed is the invariant, not the default implementation's private choice.
@@ -599,7 +630,7 @@ Error `details` are built to be safe to log:
599
630
 
600
631
  - A malformed `connection.url` throws `CONNECTION_FAILED` with `reason: 'invalid connection.url'` — the URL is omitted, because it may embed a password.
601
632
  - `SERIALIZATION_FAILED` carries the encoder's message, never the value being encoded.
602
- - `DESERIALIZATION_FAILED` carries a `preview` of the raw payload truncated to **100 characters** with an ellipsis — enough to debug a codec mismatch, bounded so a cached record full of PII is not copied into a log line.
633
+ - `DESERIALIZATION_FAILED` carries a fixed `error` text and the `length` of the stored value, and nothing from its content — not a preview, not the parser's message, which quotes the start of its input. The exception is an `HttpException`, so a default Nest filter sends `details` to the HTTP client; a cached record holding PII or a token must never reach it.
603
634
 
604
635
  ### Destructive operations are guarded in production
605
636
 
@@ -624,6 +655,7 @@ When integrating `@bymax-one/nest-cache` in production, verify each of the follo
624
655
  - `namespace` is distinct per tenant or per application — it is the isolation boundary, and a shared value makes every other guarantee moot
625
656
  - `allowFlushInProduction` stays unset; if it is on, the reason belongs in a security review
626
657
  - `getClient()` and `pipeline()` call sites are audited — they are the only paths that bypass namespacing
658
+ - `prefix` and `id` that derive from untrusted input are encoded per field (`encodeURIComponent`) — the library checks them only for emptiness, so a separator inside either collides keys and a glob character in `prefix` widens `keys()` / `scan()`; keys are unambiguous only for a single-character `keySeparator` absent from both fields; per-field `encodeURIComponent` provides that only for a separator it escapes and never emits (the default `':'`; not `%` or `- _ . ! ~ * ' ( )`), otherwise the separator must be rejected, and a multi-character separator cannot be made safe that way; glob characters in `prefix` must be rejected
627
659
  - Connection credentials come from the environment (`config.getOrThrow('REDIS_URL')`), never from a URL literal in module options in source control
628
660
  - `rediss://` (or an explicit `connection.tls`) is used for any Redis reachable off-host
629
661
  - Values that must not be readable by whoever can read Redis are encrypted by the application (or a custom `ISerializer`) before they are cached — the library stores what you hand it
@@ -636,22 +668,22 @@ When integrating `@bymax-one/nest-cache` in production, verify each of the follo
636
668
 
637
669
  ## 🛡️ Security Table
638
670
 
639
- | Layer | Implementation |
640
- | --------------------- | --------------------------------------------------------------------------------------------------------------------------- |
641
- | Tenant Isolation | Every key and channel composed by `KeyBuilder` as `{namespace}{sep}{prefix}{sep}{id}` — no bare-key path in the API |
642
- | Namespace Validation | Empty, separator-containing, or glob-metacharacter namespace rejected at bootstrap (`INVALID_NAMESPACE`) |
643
- | Admin Scope Allowlist | Scopes declared at wiring, validated and frozen; a caller names a scope by id and can never supply a match pattern |
644
- | Admin Read-Only | The `/admin` subpath issues no mutating command — enforced by the `check:admin-readonly` build gate, not by convention |
645
- | Key Validation | Empty `prefix` / `id` rejected before the command is issued (`INVALID_KEY`) |
646
- | Deserialization | Fails closed — `DESERIALIZATION_FAILED`; never a partial or wrongly-typed value |
647
- | Serialization | Top-level `undefined` / function / symbol rejected; the value is never echoed into `details` |
648
- | Error Payloads | Connection URLs omitted (may embed credentials); raw payload previews truncated to 100 characters |
649
- | Destructive Ops | `flushNamespace()` blocked under `NODE_ENV=production` unless `allowFlushInProduction`; `SCAN` + `UNLINK`, namespace-scoped |
650
- | Lua Execution | Scripts registered by name; call sites pass keys/args only — request input never reaches a script body |
651
- | Cluster Safety | `scan` / `flushNamespace` / `getClient` throw `UNSUPPORTED_IN_CLUSTER` instead of acting on a single node |
652
- | Transport | `rediss://` sets TLS on the client; explicit `connection.tls` supported for custom CA / mTLS |
653
- | Connection Resilience | Bounded `maxRetriesPerRequest`, `READONLY`-failover reconnect, graceful `quit()` with a shutdown timeout |
654
- | Supply Chain | `"dependencies": {}` — no transitive runtime packages of the library's own choosing; published with npm provenance |
671
+ | Layer | Implementation |
672
+ | --------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- |
673
+ | Tenant Isolation | Every key and channel composed by `KeyBuilder` as `{namespace}{sep}{prefix}{sep}{id}` — no bare-key path in the API |
674
+ | Namespace Validation | Empty, separator-containing, or glob-metacharacter namespace rejected at bootstrap (`INVALID_NAMESPACE`) |
675
+ | Admin Scope Allowlist | Scopes declared at wiring, validated and frozen; a caller names a scope by id and can never supply a match pattern |
676
+ | Admin Read-Only | The `/admin` subpath issues no mutating command — enforced by the `check:admin-readonly` build gate, not by convention |
677
+ | Key Validation | Empty `prefix` / `id` rejected before the command is issued (`INVALID_KEY`); emptiness only — separators and glob characters in `prefix` / `id` are not rejected |
678
+ | Deserialization | Fails closed — `DESERIALIZATION_FAILED`; never a partial or wrongly-typed value |
679
+ | Serialization | Top-level `undefined` / function / symbol rejected; the value is never echoed into `details` |
680
+ | Error Payloads | Connection URLs omitted (may embed credentials); a stored value is never echoed (a fixed text and its length only) |
681
+ | Destructive Ops | `flushNamespace()` blocked under `NODE_ENV=production` unless `allowFlushInProduction`; `SCAN` + `UNLINK`, namespace-scoped |
682
+ | Lua Execution | Scripts registered by name; call sites pass keys/args only — request input never reaches a script body |
683
+ | Cluster Safety | `scan` / `flushNamespace` / `getClient` throw `UNSUPPORTED_IN_CLUSTER` instead of acting on a single node |
684
+ | Transport | `rediss://` sets TLS on the client; explicit `connection.tls` supported for custom CA / mTLS |
685
+ | Connection Resilience | Bounded `maxRetriesPerRequest`, `READONLY`-failover reconnect, graceful `quit()` with a shutdown timeout |
686
+ | Supply Chain | `"dependencies": {}` — no transitive runtime packages of the library's own choosing; published with npm provenance |
655
687
 
656
688
  > [!IMPORTANT]
657
689
  > The namespace is the isolation boundary. Anything reached through `getClient()` or `pipeline()` sits outside it — those call sites are where cross-tenant reads get introduced.
@@ -666,7 +698,7 @@ When integrating `@bymax-one/nest-cache` in production, verify each of the follo
666
698
  [![Node.js](https://img.shields.io/badge/Node.js-24-339933?style=flat-square&logo=node.js&logoColor=white)](https://nodejs.org)
667
699
  [![Jest](https://img.shields.io/badge/Jest-30-C21325?style=flat-square&logo=jest)](https://jestjs.io)
668
700
  [![Stryker](https://img.shields.io/badge/Stryker-Mutation_Testing-red?style=flat-square)](https://stryker-mutator.io)
669
- [![pnpm](https://img.shields.io/badge/pnpm-10-F69220?style=flat-square&logo=pnpm&logoColor=white)](https://pnpm.io)
701
+ [![pnpm](https://img.shields.io/badge/pnpm-11-F69220?style=flat-square&logo=pnpm&logoColor=white)](https://pnpm.io)
670
702
  [![tsup](https://img.shields.io/badge/tsup-8-orange?style=flat-square)](https://tsup.egoist.dev)
671
703
 
672
704
  ---
@@ -676,7 +708,7 @@ When integrating `@bymax-one/nest-cache` in production, verify each of the follo
676
708
  A cache is consulted on the hot path of every request that touches it, so the suite is held to a bar beyond "it runs" — every behavior is pinned so that a regression **fails a test**.
677
709
 
678
710
  - ✅ **100% line coverage** — statements, branches, functions, and lines, enforced by `jest.coverage.config.ts` as a pre-publish gate, not a target
679
- - ✅ **100% mutation score** — verified with [Stryker](https://stryker-mutator.io/) at `break: 100` and `ignoreStatic: false`; 441 killed, 6 timed out, **0 survived**, and [documented in full](./docs/mutation_testing_results.md)
711
+ - ✅ **100% mutation score** — verified with [Stryker](https://stryker-mutator.io/) at `break: 100` and `ignoreStatic: false`, **0 survived**, and [documented in full](./docs/mutation_testing_results.md)
680
712
  - ✅ **One documented equivalent** — the production source carries a single `// Stryker disable` directive, on `configurable: false` of the withheld connection accessor, genuinely equivalent because the resolved options are frozen on the way out (freezing already makes every property non-configurable); `check:mutants` proves it parses and carries its reason, so the score is an accounting rather than a number
681
713
  - ✅ **No real Redis in unit tests** — `ioredis-mock` throughout; e2e tests exercise the wired module through `@nestjs/testing` and Testcontainers against a real Redis for connection lifecycle, Pub/Sub, and Lua scripts
682
714
  - ✅ **Published-package smoke test** — `scripts/dogfood-smoke-test.mjs` validates exports, tarball shape, and a consumer install before tagging
@@ -685,7 +717,7 @@ A cache is consulted on the hot path of every request that touches it, so the su
685
717
  pnpm test # unit tests (Jest)
686
718
  pnpm test:e2e # end-to-end tests (@nestjs/testing + Testcontainers)
687
719
  pnpm test:cov:all # full coverage gate (100% statements/branches/functions/lines)
688
- pnpm mutation # Stryker mutation testing (95% break gate)
720
+ pnpm mutation # Stryker mutation testing (100% break gate)
689
721
  pnpm typecheck # tsc strict check
690
722
  pnpm lint # ESLint
691
723
  ```
@@ -732,23 +764,27 @@ pnpm lint # ESLint
732
764
 
733
765
  All errors are instances of `CacheException` and carry a stable `code` string from `CACHE_ERROR_CODES`:
734
766
 
735
- | Code | HTTP | When thrown |
736
- | ------------------------------------ | ---- | --------------------------------------------------------------- |
737
- | `cache.connection_failed` | 500 | Cannot connect after retries |
738
- | `cache.command_timeout` | 504 | Command exceeded `commandTimeout` |
739
- | `cache.connection_lost` | 503 | Connection dropped during an in-flight operation |
740
- | `cache.deserialization_failed` | 500 | Malformed payload in `get<T>` |
741
- | `cache.serialization_failed` | 500 | Unserializable value in `set<T>` |
742
- | `cache.invalid_key` | 400 | Empty prefix or id passed to `build` / `applyNamespace` |
743
- | `cache.invalid_namespace` | 500 | Empty or separator-containing namespace |
744
- | `cache.script_not_registered` | 500 | `eval(name)` before `register(name)` |
745
- | `cache.script_execution_failed` | 500 | Lua runtime error or NOSCRIPT retry failure |
746
- | `cache.script_registry_missing` | 500 | `eval` called when no `ScriptManagerService` is wired |
747
- | `cache.flush_disabled_in_production` | 403 | `flushNamespace()` in prod without `allowFlushInProduction` |
748
- | `cache.unsupported_in_cluster` | 500 | `scan`, `flushNamespace`, or `getClient` called in cluster mode |
749
- | `cache.cluster_misconfigured` | 500 | `mode: 'cluster'` without `cluster.nodes` |
750
- | `cache.sentinel_misconfigured` | 500 | `mode: 'sentinel'` without `sentinel.sentinels`/`name` |
751
- | `cache.shutdown_timeout` | 500 | `quit()` exceeded `shutdownTimeoutMs` |
767
+ | Code | HTTP | When thrown |
768
+ | ------------------------------------ | ---- | ----------------------------------------------------------------------------------------------------------------------------------------- |
769
+ | `cache.connection_failed` | 500 | Cannot connect after retries; also bootstrap validation of `connection` and timeouts |
770
+ | `cache.command_timeout` | 504 | Reserved; not currently thrown by the library |
771
+ | `cache.connection_lost` | 503 | Reserved; not currently thrown by the library |
772
+ | `cache.deserialization_failed` | 500 | Malformed payload in `get<T>` |
773
+ | `cache.serialization_failed` | 500 | Unserializable value in `set<T>` |
774
+ | `cache.invalid_key` | 400 | Empty prefix or id (`build`), or empty key (`applyNamespace`) |
775
+ | `cache.invalid_namespace` | 500 | Empty or whitespace-only namespace, empty `keySeparator`, namespace containing the separator or a glob metacharacter (`*`, `?`, `[`, `\`) |
776
+ | `cache.script_not_registered` | 500 | `eval(name)` before `register(name)` |
777
+ | `cache.script_execution_failed` | 500 | Lua runtime error or NOSCRIPT retry failure |
778
+ | `cache.script_registry_missing` | 500 | `eval` called when no `ScriptManagerService` is wired |
779
+ | `cache.flush_disabled_in_production` | 403 | `flushNamespace()` in prod without `allowFlushInProduction` |
780
+ | `cache.unsupported_in_cluster` | 500 | `scan`, `flushNamespace`, or `getClient` called in cluster mode |
781
+ | `cache.cluster_misconfigured` | 500 | `mode: 'cluster'` without `cluster.nodes` |
782
+ | `cache.sentinel_misconfigured` | 500 | `mode: 'sentinel'` without `sentinel.sentinels`/`name` |
783
+ | `cache.shutdown_timeout` | 500 | `quit()` exceeded `shutdownTimeoutMs` |
784
+ | `cache.invalid_scope` | 500 | `/admin` scope allowlist invalid at wiring |
785
+ | `cache.scope_not_found` | 500 | `/admin` call named an undeclared scope id |
786
+ | `cache.scope_not_readable` | 500 | Reserved; not currently thrown (unreadable scopes return `withheld`) |
787
+ | `cache.key_not_in_scope` | 500 | `/admin` call named a key outside the named scope |
752
788
 
753
789
  Full catalog and HTTP status mapping: [`docs/technical_specification.md §12`](./docs/technical_specification.md).
754
790
 
@@ -775,7 +811,7 @@ See §13 of the [technical specification](./docs/technical_specification.md) for
775
811
  Contributions are welcome. Development follows the Bymax coding standards:
776
812
 
777
813
  - TypeScript strict (`noImplicitAny`, `exactOptionalPropertyTypes`, `noUncheckedIndexedAccess`)
778
- - TDD with a 100% coverage gate and a 95% mutation break threshold
814
+ - TDD with a 100% coverage gate and a 100% mutation break threshold
779
815
  - Conventional Commits enforced by commitlint + husky
780
816
  - No direct dependencies — peer deps only
781
817
  - All boolean identifiers prefixed with `is / has / should / can`
@@ -801,7 +837,7 @@ pnpm typecheck
801
837
  Run the full gate before opening a PR:
802
838
 
803
839
  ```bash
804
- pnpm typecheck && pnpm lint && pnpm test:cov:all && pnpm build && pnpm size
840
+ pnpm typecheck && pnpm lint && pnpm check:admin-readonly && pnpm test:cov:all && pnpm build && pnpm size && pnpm check:exports
805
841
  ```
806
842
 
807
843
  See [CONTRIBUTING.md](./CONTRIBUTING.md) and [CODE_OF_CONDUCT.md](./CODE_OF_CONDUCT.md) for the full process.
@@ -711,7 +711,7 @@ exports.KeyBuilder = __decorateClass([
711
711
  common.Injectable(),
712
712
  __decorateParam(0, common.Inject(BYMAX_CACHE_OPTIONS))
713
713
  ], exports.KeyBuilder);
714
- var MAX_PREVIEW_LENGTH = 100;
714
+ var DESERIALIZATION_ERROR = "stored value is not valid JSON";
715
715
  var extractErrorMessage = (err) => err instanceof Error ? err.message : String(err);
716
716
  exports.JsonSerializer = class JsonSerializer {
717
717
  /**
@@ -753,17 +753,17 @@ exports.JsonSerializer = class JsonSerializer {
753
753
  * @param raw - The JSON string to decode.
754
754
  * @returns The decoded value, typed as `T`.
755
755
  * @throws {CacheException} `DESERIALIZATION_FAILED` when `raw` is not valid
756
- * JSON. `details.preview` carries at most {@link MAX_PREVIEW_LENGTH}
757
- * characters of `raw` (truncated with an ellipsis) to aid debugging without
758
- * leaking a large payload that may contain PII.
756
+ * JSON. `details` holds a fixed `error` text and the `length` of `raw`; no
757
+ * part of the stored value is echoed, as it may carry PII or a credential
758
+ * (CLAUDE.md §4).
759
759
  */
760
760
  deserialize(raw) {
761
761
  try {
762
762
  return JSON.parse(raw);
763
- } catch (err) {
763
+ } catch {
764
764
  throw new CacheException(CACHE_ERROR_CODES.DESERIALIZATION_FAILED, {
765
- error: extractErrorMessage(err),
766
- preview: raw.length > MAX_PREVIEW_LENGTH ? `${raw.substring(0, MAX_PREVIEW_LENGTH)}...` : raw
765
+ error: DESERIALIZATION_ERROR,
766
+ length: raw.length
767
767
  });
768
768
  }
769
769
  }
@@ -971,9 +971,9 @@ declare class JsonSerializer implements ISerializer {
971
971
  * @param raw - The JSON string to decode.
972
972
  * @returns The decoded value, typed as `T`.
973
973
  * @throws {CacheException} `DESERIALIZATION_FAILED` when `raw` is not valid
974
- * JSON. `details.preview` carries at most {@link MAX_PREVIEW_LENGTH}
975
- * characters of `raw` (truncated with an ellipsis) to aid debugging without
976
- * leaking a large payload that may contain PII.
974
+ * JSON. `details` holds a fixed `error` text and the `length` of `raw`; no
975
+ * part of the stored value is echoed, as it may carry PII or a credential
976
+ * (CLAUDE.md §4).
977
977
  */
978
978
  deserialize<T>(raw: string): T;
979
979
  }
@@ -971,9 +971,9 @@ declare class JsonSerializer implements ISerializer {
971
971
  * @param raw - The JSON string to decode.
972
972
  * @returns The decoded value, typed as `T`.
973
973
  * @throws {CacheException} `DESERIALIZATION_FAILED` when `raw` is not valid
974
- * JSON. `details.preview` carries at most {@link MAX_PREVIEW_LENGTH}
975
- * characters of `raw` (truncated with an ellipsis) to aid debugging without
976
- * leaking a large payload that may contain PII.
974
+ * JSON. `details` holds a fixed `error` text and the `length` of `raw`; no
975
+ * part of the stored value is echoed, as it may carry PII or a credential
976
+ * (CLAUDE.md §4).
977
977
  */
978
978
  deserialize<T>(raw: string): T;
979
979
  }
@@ -709,7 +709,7 @@ KeyBuilder = __decorateClass([
709
709
  Injectable(),
710
710
  __decorateParam(0, Inject(BYMAX_CACHE_OPTIONS))
711
711
  ], KeyBuilder);
712
- var MAX_PREVIEW_LENGTH = 100;
712
+ var DESERIALIZATION_ERROR = "stored value is not valid JSON";
713
713
  var extractErrorMessage = (err) => err instanceof Error ? err.message : String(err);
714
714
  var JsonSerializer = class {
715
715
  /**
@@ -751,17 +751,17 @@ var JsonSerializer = class {
751
751
  * @param raw - The JSON string to decode.
752
752
  * @returns The decoded value, typed as `T`.
753
753
  * @throws {CacheException} `DESERIALIZATION_FAILED` when `raw` is not valid
754
- * JSON. `details.preview` carries at most {@link MAX_PREVIEW_LENGTH}
755
- * characters of `raw` (truncated with an ellipsis) to aid debugging without
756
- * leaking a large payload that may contain PII.
754
+ * JSON. `details` holds a fixed `error` text and the `length` of `raw`; no
755
+ * part of the stored value is echoed, as it may carry PII or a credential
756
+ * (CLAUDE.md §4).
757
757
  */
758
758
  deserialize(raw) {
759
759
  try {
760
760
  return JSON.parse(raw);
761
- } catch (err) {
761
+ } catch {
762
762
  throw new CacheException(CACHE_ERROR_CODES.DESERIALIZATION_FAILED, {
763
- error: extractErrorMessage(err),
764
- preview: raw.length > MAX_PREVIEW_LENGTH ? `${raw.substring(0, MAX_PREVIEW_LENGTH)}...` : raw
763
+ error: DESERIALIZATION_ERROR,
764
+ length: raw.length
765
765
  });
766
766
  }
767
767
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bymax-one/nest-cache",
3
- "version": "1.2.1",
3
+ "version": "1.2.3",
4
4
  "description": "Typed Redis cache for NestJS based on ioredis 6, with namespace strategy, Pub/Sub and Lua script management.",
5
5
  "author": "Bymax One <support@bymax.one>",
6
6
  "license": "MIT",
@@ -111,7 +111,7 @@
111
111
  "@arethetypeswrong/cli": "^0.18.5",
112
112
  "@commitlint/cli": "^21.2.1",
113
113
  "@commitlint/config-conventional": "^21.2.0",
114
- "@eslint/js": "^9.39.4",
114
+ "@eslint/js": "^10.0.1",
115
115
  "@nestjs/common": "^11.1.20",
116
116
  "@nestjs/core": "^11.1.20",
117
117
  "@nestjs/testing": "^11.1.20",
@@ -122,10 +122,10 @@
122
122
  "@types/node": "^24",
123
123
  "@typescript-eslint/eslint-plugin": "^8.59.3",
124
124
  "@typescript-eslint/parser": "^8.59.3",
125
- "eslint": "^9.39.4",
125
+ "eslint": "^10.9.1",
126
126
  "eslint-config-prettier": "^10.1.8",
127
- "eslint-import-resolver-typescript": "^4.4.4",
128
- "eslint-plugin-import": "^2.32.0",
127
+ "eslint-import-resolver-typescript": "^4.4.5",
128
+ "eslint-plugin-import-x": "^4.17.1",
129
129
  "eslint-plugin-prettier": "^5.5.5",
130
130
  "eslint-plugin-security": "^4.0.0",
131
131
  "globals": "^17.6.0",