@bymax-one/nest-cache 1.1.0 → 1.2.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/CHANGELOG.md CHANGED
@@ -4,6 +4,117 @@ 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.1] - 2026-08-21
8
+
9
+ ### Changed
10
+
11
+ - **`RevealedValue`'s hash arm now names each entry `field`, not `name`**
12
+ (`{ field, value }`). Redis's own vocabulary for a hash is field/value —
13
+ `HSET key field value`, `HDEL key field`, and `HGETALL` returns field-value
14
+ pairs — so a generic `name` lost the domain term in the one place a reader
15
+ would check the shape against the server's documentation.
16
+
17
+ This is a **type-level breaking change** shipped as a patch, deliberately and
18
+ with the trade-off stated: `RevealedValue` was published in 1.2.0 roughly an
19
+ hour earlier, no consumer had wired it yet, and correcting a freshly published
20
+ name is cheaper than carrying a permanent vocabulary mismatch or maintaining
21
+ two names for one concept. A consumer that already destructured `name` gets a
22
+ compile error, not a silent behaviour change.
23
+
24
+ The library emits **one** name. Aliasing `field` and `name` together would have
25
+ been two names for one idea, which is the defect this corrects rather than a
26
+ softer way to ship it.
27
+
28
+ ## [1.2.0] - 2026-08-21
29
+
30
+ ### Security
31
+
32
+ - **`validateOptions` now rejects a namespace containing a Redis glob metacharacter
33
+ (`*`, `?`, `[`, `\`).** The namespace is this library's isolation boundary and it was
34
+ composed unvalidated into `flushNamespace`'s destructive match pattern
35
+ (`{namespace}{separator}*`), so a metacharacter changed which keys `UNLINK` reached.
36
+ Measured against Redis 8.10.0, each one broke isolation differently: `*` and `?` **widen**
37
+ the pattern (namespace `ten*ant` matches every other tenant's keys, turning a scoped flush
38
+ into a cross-tenant delete); `\` **escapes** the next character (namespace `ten\ant`
39
+ matches `tenant:*` — a different keyspace — while sparing its own keys); and `[` opens a
40
+ character class that never closes, so the pattern matches **nothing** and `flushNamespace`
41
+ removes none of the namespace's keys while returning `0`, which reads as a successful flush.
42
+ Triggering it required a misconfigured namespace, so no default configuration was exposed;
43
+ the case to worry about is a namespace derived from input, such as multi-tenant wiring using
44
+ a tenant slug. `]` is deliberately still accepted — measured to be a literal that neither
45
+ widens nor silences the pattern.
46
+ - An **empty `keySeparator`** now fails with its own message. It was already rejected, but by
47
+ coincidence: the next guard is `namespace.includes(separator)` and `'anything'.includes('')`
48
+ is `true` for every string, so it reported _"namespace contains key separator"_ — something
49
+ the consumer had not done.
50
+
51
+ ### Added
52
+
53
+ - **New subpath `@bymax-one/nest-cache/admin`** — a privileged, read-only administration
54
+ surface: health, parsed `INFO` statistics, resolved configuration, keyspace listing, key
55
+ inspection and value reveal. Kept out of the main entry deliberately: importing it is a
56
+ greppable, reviewable act; a consumer who never wires it cannot resolve a reveal service
57
+ from DI by accident and does not pay for it in the main bundle.
58
+ - `BymaxCacheAdminModule.forRoot()` / `.forRootAsync()`, `CacheStatusService`,
59
+ `CacheAdminService`, and the scope model (`CacheScope`, `validateScopes`, `findScope`,
60
+ `isKeyInScope`).
61
+ - New error codes: `cache.invalid_scope`, `cache.scope_not_found`, `cache.scope_not_readable`,
62
+ `cache.key_not_in_scope`.
63
+ - `ResolvedOptions` and `DEFAULT_REDIS_PORT` are now exported from the main entry. The former
64
+ is the shape stored under the already-exported `BYMAX_CACHE_OPTIONS` token, which previously
65
+ had no public type.
66
+ - `pnpm check:admin-readonly` — a build gate that fails if the admin subpath declares a method
67
+ named after a mutating Redis command, sends a non-allowlisted command through the `call`
68
+ escape hatch, or imports `ioredis` as a value. Wired into `prepublishOnly`.
69
+
70
+ ### Administration surface — shapes chosen deliberately
71
+
72
+ - **The application declares which keyspaces exist; the library validates and serves them.**
73
+ A cache library cannot know that another library writes at Redis root through
74
+ `getClient()`, and must not depend on that library to learn it.
75
+ - **`isReadable: false` withholds the value only.** Listing, types, TTLs and sizes stay
76
+ available. A surface that renders an unreadable keyspace as empty tells an operator the
77
+ region holds nothing when it is full — the same defect as a blank log page during an outage.
78
+ - **Scope patterns are restricted to a literal prefix with at most one trailing `*`.** A
79
+ caller names a key, so the library must decide whether that key belongs to the named scope.
80
+ Deciding that for arbitrary globs means reimplementing Redis's `stringmatchlen`, and a
81
+ matcher even slightly _more_ permissive than the server's is a silent cross-scope leak that
82
+ no happy-path test would show. With this shape, membership is exact by construction — and it
83
+ is checked differentially against a real server in the E2E suite.
84
+ - **Health is three states and `latencyMs` cannot exist without a measurement.** The type is
85
+ a union, so a handler that caught a throwing ping and returned a confident status does not
86
+ compile. `mode` and `isScanSupported` sit outside the union: a cluster deployment that is
87
+ down should still report that scanning was never going to work.
88
+ - **Every reading that would carry two meanings under one `null` is a union.**
89
+ `maxmemory` is `unbounded | limited | unreported` (Redis spells "no ceiling" as
90
+ `maxmemory:0`, which read literally draws a full saturation bar on the least constrained
91
+ server there is); `TTL` is `expiring | persistent | missing` (`-1` and `-2` are different
92
+ facts, and the key that expired mid-listing is the one an operator is watching);
93
+ `aofEnabled` is nullable, because `false` for an absent field is a durability claim made
94
+ without evidence.
95
+ - **`connection.url` is never on the wire.** The config payload carries host, port and a TLS
96
+ flag; the URL and its password are never read into this subpath at all.
97
+ - **Sampled figures are named `sampledCount` / `sampledBytes`.** They are sums over a capped
98
+ `SCAN`, not measurements of the keyspace. `isComplete` is the fact; the names are the guard,
99
+ because a caller reaching for one does not necessarily read the other.
100
+ - **Pipeline batches are bounded in commands, not keys.** Redis is single-threaded, so a
101
+ pipeline converts a network cost into a server-blocking one — describing N keys is two or
102
+ three commands each, and one flush blocks every other client for the whole burst, on a
103
+ server someone is inspecting precisely because it is unwell. Sizing is opt-in for the same
104
+ reason.
105
+
106
+ ### Internal
107
+
108
+ - Bundle-size budgets recalibrated: server `14.50` → `15.00` kB, admin added at `7.25` kB
109
+ against a measured `6.60` kB. The admin entry marks `@bymax-one/nest-cache` **external** —
110
+ bundling the server modules would give it its own copies of `CacheService` and the DI
111
+ tokens, so `@Inject(CacheService)` in an admin provider would name a different class object
112
+ than the one `BymaxCacheModule` registered and DI would fail at a consumer's runtime.
113
+ - E2E coverage for the admin subpath against a real Redis, asserting all twenty-four `INFO`
114
+ field names the parser reads, real `MEMORY USAGE` sizing, and the differential scope-membership
115
+ check. `ioredis-mock` supports none of those three (measured), so a unit suite alone could not
116
+ have verified them.
117
+
7
118
  ## [1.1.0] - 2026-08-11
8
119
 
9
120
  ### Changed
@@ -190,6 +301,8 @@ type or export moved.
190
301
  - Published with npm OIDC provenance — no long-lived tokens
191
302
  - Zero direct runtime dependencies (`dependencies: {}`) — `ioredis` and NestJS via peer deps
192
303
 
304
+ [1.2.1]: https://github.com/bymaxone/nest-cache/compare/v1.2.0...v1.2.1
305
+ [1.2.0]: https://github.com/bymaxone/nest-cache/compare/v1.1.0...v1.2.0
193
306
  [1.1.0]: https://github.com/bymaxone/nest-cache/compare/v1.0.6...v1.1.0
194
307
  [1.0.6]: https://github.com/bymaxone/nest-cache/compare/v1.0.5...v1.0.6
195
308
  [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,145 @@ 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
+ ### Reading a value: `revealed` / `withheld` / `missing`
477
+
478
+ `revealValue()` answers with a discriminated union, not a nullable value, because **"I may not tell you" and "there is nothing here" are different answers** and rendering them identically is the defect this whole surface exists to avoid.
479
+
480
+ ```ts
481
+ import type { CacheAdminService } from '@bymax-one/nest-cache/admin'
482
+
483
+ async function describeValue(admin: CacheAdminService, scope: string, key: string) {
484
+ const result = await admin.revealValue(scope, key)
485
+
486
+ switch (result.status) {
487
+ case 'revealed':
488
+ // `result.type` is the Redis type; `result.value` is shaped by it.
489
+ return { type: result.type, value: result.value }
490
+ case 'withheld':
491
+ // The scope declares `isReadable: false`. `origin` explains why, verbatim.
492
+ // Listing, types, TTLs and sizes for this key remain available.
493
+ return { refusedBecause: result.origin }
494
+ case 'missing':
495
+ return { gone: true }
496
+ }
497
+ }
498
+ ```
499
+
500
+ A withheld value is **not an authorization failure**. The caller is allowed to ask; the deployment declared the keyspace unreadable at wiring, and no credential changes that — so serving it as `403` would tell a client that some other permission would unlock it, which is false.
501
+
502
+ The revealed value is shaped by the key's type:
503
+
504
+ | `value.kind` | Shape | Notes |
505
+ | ------------- | ---------------------------------- | ---------------------------------------------------------------------------------------- |
506
+ | `string` | `{ value: string }` | Truncated to `revealStringLimit` |
507
+ | `hash` | `{ fields: { field, value }[] }` | `field`, matching Redis's own vocabulary (`HSET key field value`) |
508
+ | `members` | `{ members: string[] }` | **Sets and lists both.** The enclosing `type` says which — see the ordering caveat below |
509
+ | `scored` | `{ members: { member, score }[] }` | Sorted sets. `score` is a **string**: parsing to a number would round large integers |
510
+ | `unsupported` | `{ type: RedisKeyType }` | Streams and module types — says so, rather than returning an empty value |
511
+
512
+ Every arm except `unsupported` carries `isComplete`. It is deliberately **positive polarity**: an absent boolean reads as `false`, and under a name like `truncated` that default would be the _reassuring_ answer — a value silently claiming nothing was cut.
513
+
514
+ > [!IMPORTANT]
515
+ > **List order is meaningful; set order is not**, and both arrive in the same `members` array. Nothing in the type stops a renderer from sorting either one. Sorting a set for display is fine; sorting a list is a lie about the data — an `LPUSH` queue shown alphabetically misreports what pops next. Gate any client-side ordering on `type === 'set'`.
516
+
517
+ ### Costs the surface does not hide
518
+
519
+ - **`sampledCount` / `sampledBytes`** are sums over a capped `SCAN`, not measurements of the keyspace. `isComplete` is the fact; the names are the guard.
520
+ - **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.
521
+ - **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.
522
+ - **`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.
523
+ - **`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.
524
+
525
+ ### Cluster
526
+
527
+ 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`.
528
+
529
+ ---
530
+
389
531
  ## 🏗️ Architecture
390
532
 
391
533
  The package runs **inside** your NestJS application as a dynamic module — not as a separate service:
@@ -467,6 +609,10 @@ Error `details` are built to be safe to log:
467
609
 
468
610
  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
611
 
612
+ ### The namespace cannot widen a destructive pattern
613
+
614
+ `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.
615
+
470
616
  ### Cluster mode refuses commands it cannot honor safely
471
617
 
472
618
  `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 +629,8 @@ When integrating `@bymax-one/nest-cache` in production, verify each of the follo
483
629
  - 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
630
  - Cached entries carry a TTL sized to your data-retention policy; namespacing bounds who can read an entry, not how long it exists
485
631
  - Custom `ISerializer` implementations throw on malformed input rather than returning a fallback value
632
+ - 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
633
+ - 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
634
 
487
635
  ---
488
636
 
@@ -491,7 +639,9 @@ When integrating `@bymax-one/nest-cache` in production, verify each of the follo
491
639
  | Layer | Implementation |
492
640
  | --------------------- | --------------------------------------------------------------------------------------------------------------------------- |
493
641
  | 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`) |
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 |
495
645
  | Key Validation | Empty `prefix` / `id` rejected before the command is issued (`INVALID_KEY`) |
496
646
  | Deserialization | Fails closed — `DESERIALIZATION_FAILED`; never a partial or wrongly-typed value |
497
647
  | Serialization | Top-level `undefined` / function / symbol rejected; the value is never echoed into `details` |