@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 +113 -0
- package/README.md +153 -3
- package/dist/admin/index.cjs +745 -0
- package/dist/admin/index.d.cts +967 -0
- package/dist/admin/index.d.ts +967 -0
- package/dist/admin/index.mjs +727 -0
- package/dist/server/index.cjs +81 -27
- package/dist/server/index.d.cts +25 -1
- package/dist/server/index.d.ts +25 -1
- package/dist/server/index.mjs +79 -28
- package/dist/shared/index.cjs +5 -1
- package/dist/shared/index.d.cts +4 -0
- package/dist/shared/index.d.ts +4 -0
- package/dist/shared/index.mjs +5 -1
- package/package.json +16 -2
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,
|
|
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
|
|
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` |
|