@bymax-one/nest-cache 1.1.0 → 1.2.0

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,96 @@ 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
+ ## [1.2.0] - 2026-08-21
8
+
9
+ ### Security
10
+
11
+ - **`validateOptions` now rejects a namespace containing a Redis glob metacharacter
12
+ (`*`, `?`, `[`, `\`).** The namespace is this library's isolation boundary and it was
13
+ composed unvalidated into `flushNamespace`'s destructive match pattern
14
+ (`{namespace}{separator}*`), so a metacharacter changed which keys `UNLINK` reached.
15
+ Measured against Redis 8.10.0, each one broke isolation differently: `*` and `?` **widen**
16
+ the pattern (namespace `ten*ant` matches every other tenant's keys, turning a scoped flush
17
+ into a cross-tenant delete); `\` **escapes** the next character (namespace `ten\ant`
18
+ matches `tenant:*` — a different keyspace — while sparing its own keys); and `[` opens a
19
+ character class that never closes, so the pattern matches **nothing** and `flushNamespace`
20
+ removes none of the namespace's keys while returning `0`, which reads as a successful flush.
21
+ Triggering it required a misconfigured namespace, so no default configuration was exposed;
22
+ the case to worry about is a namespace derived from input, such as multi-tenant wiring using
23
+ a tenant slug. `]` is deliberately still accepted — measured to be a literal that neither
24
+ widens nor silences the pattern.
25
+ - An **empty `keySeparator`** now fails with its own message. It was already rejected, but by
26
+ coincidence: the next guard is `namespace.includes(separator)` and `'anything'.includes('')`
27
+ is `true` for every string, so it reported _"namespace contains key separator"_ — something
28
+ the consumer had not done.
29
+
30
+ ### Added
31
+
32
+ - **New subpath `@bymax-one/nest-cache/admin`** — a privileged, read-only administration
33
+ surface: health, parsed `INFO` statistics, resolved configuration, keyspace listing, key
34
+ inspection and value reveal. Kept out of the main entry deliberately: importing it is a
35
+ greppable, reviewable act; a consumer who never wires it cannot resolve a reveal service
36
+ from DI by accident and does not pay for it in the main bundle.
37
+ - `BymaxCacheAdminModule.forRoot()` / `.forRootAsync()`, `CacheStatusService`,
38
+ `CacheAdminService`, and the scope model (`CacheScope`, `validateScopes`, `findScope`,
39
+ `isKeyInScope`).
40
+ - New error codes: `cache.invalid_scope`, `cache.scope_not_found`, `cache.scope_not_readable`,
41
+ `cache.key_not_in_scope`.
42
+ - `ResolvedOptions` and `DEFAULT_REDIS_PORT` are now exported from the main entry. The former
43
+ is the shape stored under the already-exported `BYMAX_CACHE_OPTIONS` token, which previously
44
+ had no public type.
45
+ - `pnpm check:admin-readonly` — a build gate that fails if the admin subpath declares a method
46
+ named after a mutating Redis command, sends a non-allowlisted command through the `call`
47
+ escape hatch, or imports `ioredis` as a value. Wired into `prepublishOnly`.
48
+
49
+ ### Administration surface — shapes chosen deliberately
50
+
51
+ - **The application declares which keyspaces exist; the library validates and serves them.**
52
+ A cache library cannot know that another library writes at Redis root through
53
+ `getClient()`, and must not depend on that library to learn it.
54
+ - **`isReadable: false` withholds the value only.** Listing, types, TTLs and sizes stay
55
+ available. A surface that renders an unreadable keyspace as empty tells an operator the
56
+ region holds nothing when it is full — the same defect as a blank log page during an outage.
57
+ - **Scope patterns are restricted to a literal prefix with at most one trailing `*`.** A
58
+ caller names a key, so the library must decide whether that key belongs to the named scope.
59
+ Deciding that for arbitrary globs means reimplementing Redis's `stringmatchlen`, and a
60
+ matcher even slightly _more_ permissive than the server's is a silent cross-scope leak that
61
+ no happy-path test would show. With this shape, membership is exact by construction — and it
62
+ is checked differentially against a real server in the E2E suite.
63
+ - **Health is three states and `latencyMs` cannot exist without a measurement.** The type is
64
+ a union, so a handler that caught a throwing ping and returned a confident status does not
65
+ compile. `mode` and `isScanSupported` sit outside the union: a cluster deployment that is
66
+ down should still report that scanning was never going to work.
67
+ - **Every reading that would carry two meanings under one `null` is a union.**
68
+ `maxmemory` is `unbounded | limited | unreported` (Redis spells "no ceiling" as
69
+ `maxmemory:0`, which read literally draws a full saturation bar on the least constrained
70
+ server there is); `TTL` is `expiring | persistent | missing` (`-1` and `-2` are different
71
+ facts, and the key that expired mid-listing is the one an operator is watching);
72
+ `aofEnabled` is nullable, because `false` for an absent field is a durability claim made
73
+ without evidence.
74
+ - **`connection.url` is never on the wire.** The config payload carries host, port and a TLS
75
+ flag; the URL and its password are never read into this subpath at all.
76
+ - **Sampled figures are named `sampledCount` / `sampledBytes`.** They are sums over a capped
77
+ `SCAN`, not measurements of the keyspace. `isComplete` is the fact; the names are the guard,
78
+ because a caller reaching for one does not necessarily read the other.
79
+ - **Pipeline batches are bounded in commands, not keys.** Redis is single-threaded, so a
80
+ pipeline converts a network cost into a server-blocking one — describing N keys is two or
81
+ three commands each, and one flush blocks every other client for the whole burst, on a
82
+ server someone is inspecting precisely because it is unwell. Sizing is opt-in for the same
83
+ reason.
84
+
85
+ ### Internal
86
+
87
+ - Bundle-size budgets recalibrated: server `14.50` → `15.00` kB, admin added at `7.25` kB
88
+ against a measured `6.60` kB. The admin entry marks `@bymax-one/nest-cache` **external** —
89
+ bundling the server modules would give it its own copies of `CacheService` and the DI
90
+ tokens, so `@Inject(CacheService)` in an admin provider would name a different class object
91
+ than the one `BymaxCacheModule` registered and DI would fail at a consumer's runtime.
92
+ - E2E coverage for the admin subpath against a real Redis, asserting all twenty-four `INFO`
93
+ field names the parser reads, real `MEMORY USAGE` sizing, and the differential scope-membership
94
+ check. `ioredis-mock` supports none of those three (measured), so a unit suite alone could not
95
+ have verified them.
96
+
7
97
  ## [1.1.0] - 2026-08-11
8
98
 
9
99
  ### Changed
@@ -190,6 +280,7 @@ type or export moved.
190
280
  - Published with npm OIDC provenance — no long-lived tokens
191
281
  - Zero direct runtime dependencies (`dependencies: {}`) — `ioredis` and NestJS via peer deps
192
282
 
283
+ [1.2.0]: https://github.com/bymaxone/nest-cache/compare/v1.1.0...v1.2.0
193
284
  [1.1.0]: https://github.com/bymaxone/nest-cache/compare/v1.0.6...v1.1.0
194
285
  [1.0.6]: https://github.com/bymaxone/nest-cache/compare/v1.0.5...v1.0.6
195
286
  [1.0.5]: https://github.com/bymaxone/nest-cache/compare/v1.0.4...v1.0.5
package/README.md CHANGED
@@ -97,19 +97,22 @@ pnpm add @bymax-one/nest-cache ioredis
97
97
 
98
98
  ## 📦 Subpath Exports
99
99
 
100
- One package, two entry points — import only what your app needs:
100
+ One package, three entry points — import only what your app needs:
101
101
 
102
102
  | Subpath | Import | Purpose | Dependencies |
103
103
  | ---------- | ------------------------------ | -------------------------------------------------------------------------------------------------------- | :------------------------------------: |
104
104
  | **Server** | `@bymax-one/nest-cache` | `BymaxCacheModule`, `CacheService`, `PubSubService`, `ScriptManagerService`, DI tokens, `CacheException` | NestJS 11, ioredis 6, reflect-metadata |
105
+ | **Admin** | `@bymax-one/nest-cache/admin` | Read-only administration — health, `INFO` statistics, keyspace listing, key inspection, value reveal | NestJS 11, ioredis 6, the server entry |
105
106
  | **Shared** | `@bymax-one/nest-cache/shared` | Types + constants — `CACHE_ERROR_CODES`, `CacheEventName`, config types | None |
106
107
 
107
108
  ```
108
109
  shared (zero deps)
109
110
 
110
- server
111
+ server ← admin
111
112
  ```
112
113
 
114
+ `/admin` is a **privileged** surface and is kept out of the main entry on purpose: importing it is a greppable, reviewable act, a consumer who never wires it cannot resolve a reveal service from DI by accident, and it never lands in the main bundle.
115
+
113
116
  The `/shared` subpath is safe to import in isomorphic code, test helpers, CLI scripts, or shared packages that must not pull in NestJS or ioredis.
114
117
 
115
118
  ---
@@ -386,6 +389,104 @@ export class HealthController {
386
389
 
387
390
  ---
388
391
 
392
+ ## 🔎 Administration surface (`/admin`)
393
+
394
+ A read-only surface for an operator-facing console: is the cache answering, what is it doing, what is in it.
395
+
396
+ ```ts
397
+ import { BymaxCacheModule } from '@bymax-one/nest-cache'
398
+ import { BymaxCacheAdminModule } from '@bymax-one/nest-cache/admin'
399
+
400
+ // `config` is a ConfigService reachable where the module is declared; see
401
+ // Scenario 4 above for the forRootAsync form that injects it properly.
402
+ @Module({
403
+ imports: [
404
+ BymaxCacheModule.forRoot({
405
+ connection: { url: config.getOrThrow<string>('REDIS_URL') },
406
+ namespace: 'my-app'
407
+ }),
408
+ BymaxCacheAdminModule.forRoot({
409
+ scopes: [
410
+ {
411
+ id: 'cache',
412
+ label: 'Application cache',
413
+ pattern: 'my-app:*',
414
+ isReadable: true,
415
+ origin: "the application's own namespace, written through the typed API"
416
+ },
417
+ {
418
+ id: 'auth',
419
+ label: 'Authentication',
420
+ pattern: 'auth:*',
421
+ isReadable: false,
422
+ origin:
423
+ 'written by another library through the un-namespaced client, so it sits at Redis ' +
424
+ 'root. Values are refused: this keyspace holds session records.'
425
+ }
426
+ ]
427
+ })
428
+ ]
429
+ })
430
+ export class AppModule {}
431
+ ```
432
+
433
+ ```ts
434
+ constructor(
435
+ @Inject(CacheStatusService) private readonly status: CacheStatusService,
436
+ @Inject(CacheAdminService) private readonly admin: CacheAdminService
437
+ ) {}
438
+
439
+ await this.status.health() // { status: 'up', latencyMs: 3, mode, isScanSupported, degradedAboveMs }
440
+ await this.status.stats() // parsed INFO
441
+ this.status.config() // resolved wiring, connection URL withheld
442
+ this.admin.listScopes() // never touches the connection
443
+ await this.admin.listKeys('cache', { includeSize: true })
444
+ await this.admin.revealValue('auth', 'auth:sess:1') // { status: 'withheld', origin }
445
+ ```
446
+
447
+ ### What the library owns, and what you own
448
+
449
+ The library owns the **mechanism**: validating the allowlist, scanning against it, describing keys, withholding values. The application owns **which keyspaces exist**, the `origin` prose that explains them, the routes, and the guards. A cache library cannot know that another library writes at Redis root through `getClient()`, and making it depend on that library to find out would invert two packages to save an application from stating one thing about itself.
450
+
451
+ ### `isReadable: false` withholds the value — and nothing else
452
+
453
+ Listing, types, TTLs and sizes stay available on an unreadable scope. Only the value is refused, and the refusal is returned _before_ the value is read.
454
+
455
+ This is the easy thing to get wrong, because "unreadable" reads like "return nothing" — and the unreadable scope is typically the one that holds the most interesting keys. A surface that renders it as empty tells an operator the region holds nothing while it is full, which is the same defect as a blank log page during an outage: a reading meaning _"I may not tell you"_ drawn identically to one meaning _"there is nothing here"_.
456
+
457
+ ### Scope patterns: a literal prefix, optionally ending in `*`
458
+
459
+ `auth:*`, `my-app:*` and exact keys are accepted. `app:*:v1`, `*`, `a?b` and `a[bc]` are refused at wiring.
460
+
461
+ The restriction exists because a caller names a **key**, so the library must decide whether that key belongs to the named scope — otherwise a caller names the readable scope and passes a key from the credential-bearing one. Deciding that for arbitrary globs means reimplementing Redis's `stringmatchlen` — greedy `*` with backtracking, `[a-z]` classes, `^` negation, escapes, and the unterminated-class case where `ten[ant` matches nothing at all — and **a matcher even slightly more permissive than the server's is a silent cross-scope leak that no happy-path test would show.** With this shape, membership is exact by construction, and the E2E suite checks it differentially against a real server's own `KEYS`.
462
+
463
+ Do not relax this to be helpful. Widening it later is compatible; a leak is not un-shippable.
464
+
465
+ ### Readings that would carry two meanings are unions, not nullables
466
+
467
+ | Reading | Type | Why |
468
+ | ------------ | --------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
469
+ | `maxmemory` | `unbounded \| limited \| unreported` | Redis spells "no ceiling" as `maxmemory:0`; read as a literal ceiling it draws a full saturation bar on the least constrained server there is — and "unbounded" is not "the server didn't say" |
470
+ | `TTL` | `expiring \| persistent \| missing` | `-1` and `-2` are different facts, and the key that expired between the scan and the read is the one an operator is watching |
471
+ | `aofEnabled` | `boolean \| null` | `false` for an absent field is a durability claim made without evidence |
472
+ | health | `{ up \| degraded, latencyMs } \| { down, reason, code }` | A latency exists if and only if the ping answered — expressed as `latencyMs: number \| null` that is a convention a future `catch` can break; as a union it does not compile |
473
+
474
+ `mode`, `isScanSupported` and `degradedAboveMs` sit **outside** the health union: a cluster deployment that is down should still report that scanning was never going to work.
475
+
476
+ ### Costs the surface does not hide
477
+
478
+ - **`sampledCount` / `sampledBytes`** are sums over a capped `SCAN`, not measurements of the keyspace. `isComplete` is the fact; the names are the guard.
479
+ - **A page may carry slightly more than `scanLimit` entries.** `SCAN` returns whole batches and the cursor has already moved past them, so the limit stops the loop rather than trimming the result — trimming would drop keys no later page could reach.
480
+ - **Sizing is opt-in** (`includeSize`), and every pipeline batch is bounded in **commands**, not keys. Redis is single-threaded, so a pipeline converts a network cost into a server-blocking one: one flush of N keys × 3 commands blocks every other client for the whole burst, on a server someone is inspecting precisely because it is unwell.
481
+ - **`connection.url` is never on the wire.** The config payload carries host, port and a TLS flag; the URL is never read into the admin subpath at all.
482
+ - **`mem_fragmentation_ratio` is reported raw.** On an instance holding very little, allocator and copy-on-write overhead dominate and the figure reads far above 1 without indicating a problem — 9.07 was measured on an instance holding 1.1 MiB. Turning it into a verdict is deployment policy.
483
+
484
+ ### Cluster
485
+
486
+ Every scan-based operation throws `UNSUPPORTED_IN_CLUSTER`, inherited from `CacheService.getClient()` rather than restated. `isScanSupported` travels on the health payload so a console never has to re-derive that rule from `mode`.
487
+
488
+ ---
489
+
389
490
  ## 🏗️ Architecture
390
491
 
391
492
  The package runs **inside** your NestJS application as a dynamic module — not as a separate service:
@@ -467,6 +568,10 @@ Error `details` are built to be safe to log:
467
568
 
468
569
  Scripts are declared up front — through `options.scripts` or `ScriptManagerService.register(name, lua)` — and executed **by name**. A call site passes `eval(scriptName, keys, args)`; it has no way to pass a script body. Keys are namespaced before execution and arguments arrive as Redis `ARGV[]`, which Lua treats as data, so request input cannot become script source. Standalone and Sentinel use `EVALSHA` with a `NOSCRIPT` reload-and-retry; Cluster sends the full body via `EVAL`, because `EVALSHA` routes by key slot and a keyless reload would not reach the node that reported `NOSCRIPT`.
469
570
 
571
+ ### The namespace cannot widen a destructive pattern
572
+
573
+ `validateOptions` rejects a namespace containing a Redis glob metacharacter (`*`, `?`, `[`, `\`). The namespace is composed into `flushNamespace()`'s match pattern, so a metacharacter there is not cosmetic — measured against Redis 8.10.0, `*` and `?` **widen** the pattern into other keyspaces, `\` **escapes** into a different one while sparing its own keys, and `[` opens a character class that never closes so the pattern matches **nothing** and the flush reports success having removed no keys. `]` is accepted: measured to be a literal that neither widens nor silences. This matters most when the namespace is derived from input — multi-tenant wiring using a tenant slug reads like isolation and would otherwise be one unsanitised character from a cross-tenant delete.
574
+
470
575
  ### Cluster mode refuses commands it cannot honor safely
471
576
 
472
577
  `scan()`, `flushNamespace()`, and `getClient()` throw `UNSUPPORTED_IN_CLUSTER` under `mode: 'cluster'` rather than silently operating on one node. A partial flush that reports success is worse than an error.
@@ -483,6 +588,8 @@ When integrating `@bymax-one/nest-cache` in production, verify each of the follo
483
588
  - 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
484
589
  - Cached entries carry a TTL sized to your data-retention policy; namespacing bounds who can read an entry, not how long it exists
485
590
  - Custom `ISerializer` implementations throw on malformed input rather than returning a fallback value
591
+ - If `/admin` is wired, its routes are behind the application's own authorization — the library validates scopes and withholds values, it does not authenticate anyone
592
+ - Any admin scope whose keyspace holds credentials is declared `isReadable: false`, and the deployment understands that this withholds the **value only** — listing, types, TTLs and sizes stay visible by design
486
593
 
487
594
  ---
488
595
 
@@ -491,7 +598,9 @@ When integrating `@bymax-one/nest-cache` in production, verify each of the follo
491
598
  | Layer | Implementation |
492
599
  | --------------------- | --------------------------------------------------------------------------------------------------------------------------- |
493
600
  | Tenant Isolation | Every key and channel composed by `KeyBuilder` as `{namespace}{sep}{prefix}{sep}{id}` — no bare-key path in the API |
494
- | Namespace Validation | Empty or separator-containing namespace rejected at bootstrap (`INVALID_NAMESPACE`) |
601
+ | Namespace Validation | Empty, separator-containing, or glob-metacharacter namespace rejected at bootstrap (`INVALID_NAMESPACE`) |
602
+ | Admin Scope Allowlist | Scopes declared at wiring, validated and frozen; a caller names a scope by id and can never supply a match pattern |
603
+ | Admin Read-Only | The `/admin` subpath issues no mutating command — enforced by the `check:admin-readonly` build gate, not by convention |
495
604
  | Key Validation | Empty `prefix` / `id` rejected before the command is issued (`INVALID_KEY`) |
496
605
  | Deserialization | Fails closed — `DESERIALIZATION_FAILED`; never a partial or wrongly-typed value |
497
606
  | Serialization | Top-level `undefined` / function / symbol rejected; the value is never echoed into `details` |