Priority
P2 — Medium — prevent malformed JavaScript or untyped input from becoming a valid shared cache identity.
Current baseline
At v0.11.0 / e06d833ba245706a499d66056fdad15dc1210b68, the TypeScript CacheKeySpec contract requires either a scalar id or { id, args? }. Correctly typed callers are protected at compile time.
At runtime, however, a structured object without id is accepted. String(spec.id) converts the missing value to the literal string "undefined", so the malformed operation proceeds through normal caching and coalescing rather than the existing key_construction fail-open path.
Problem
An untyped caller, JavaScript consumer, stale generated wrapper, or unsafe cast can supply { args: { locale: "en" } } or { id: undefined, args: ... }. DialCache constructs an id of "undefined", which can collide with a legitimate string id "undefined" under the same namespace, key type, use case, and arguments.
The collision can share request-local or process-scoped in-flight work, local and Redis values, and tracked invalidation identity. Because key construction does not throw, the operation is cached instead of logging/counting key_construction and running its source-of-truth fallback uncached. Direct new DialCacheKey(...) construction has the same missing-id coercion risk.
Pinned evidence
- The public typed key contract requires
id:
|
type CacheKeyArgs = Record<string, string | number | boolean | bigint | null | undefined>; |
|
type Id = string | number | bigint; |
|
|
|
/** A cache-key spec: a bare id, or an id plus extra (secondary) key dimensions. */ |
|
export type CacheKeySpec = Id | { readonly id: Id; readonly args?: CacheKeyArgs }; |
- Runtime key construction stringifies
spec.id without validating it:
|
private buildKey<Value>( |
|
options: CacheOperationOptions<Value>, |
|
cacheKey: CacheKeySpec, |
|
defaultConfig: DialCacheKeyConfig | null, |
|
): DialCacheKey { |
|
const spec = typeof cacheKey === "object" ? cacheKey : { id: cacheKey }; |
|
return new DialCacheKey({ |
|
keyType: options.keyType, |
|
id: String(spec.id), |
|
useCase: options.useCase, |
|
args: normalizeArgs(spec.args ?? {}), |
|
namespace: this.namespace, |
|
defaultConfig, |
|
serializer: (options.serializer as Serializer<unknown> | null | undefined) ?? null, |
|
trackForInvalidation: options.trackForInvalidation ?? false, |
|
}); |
- Direct
DialCacheKey construction also accepts and encodes the supplied id without runtime validation:
|
constructor(init: DialCacheKeyInit) { |
|
if (Object.hasOwn(init, "urnPrefix")) { |
|
throw new TypeError('DialCacheKeyInit.urnPrefix was renamed to "namespace"'); |
|
} |
|
|
|
this.keyType = init.keyType; |
|
this.id = init.id; |
|
this.useCase = init.useCase; |
|
this.args = init.args ?? []; |
|
this.defaultConfig = init.defaultConfig ?? null; |
|
this.serializer = init.serializer ?? null; |
|
this.trackForInvalidation = init.trackForInvalidation ?? false; |
|
this.namespace = init.namespace ?? "urn"; |
|
assertValidNamespace(this.namespace); |
|
|
|
const rawPrefix = joinUrnComponents(this.namespace, this.keyType, this.id); |
|
this.prefix = this.trackForInvalidation ? redisClusterHashTag(invalidationPrefix(this.namespace, this.keyType, this.id)) : rawPrefix; |
|
const args = this.args.length > 0 ? `?${this.args.map(([name, value]) => `${encodeComponent(name)}=${encodeComponent(value)}`).join("&")}` : ""; |
|
this.urn = `${this.prefix}${args}#${encodeComponent(this.useCase)}`; |
- DialCache already has a safe key-construction failure path that logs, records bounded metrics, and runs uncached:
|
let key: DialCacheKey; |
|
try { |
|
key = this.buildKey(options, selectKey(), defaultConfig); |
|
} catch (error) { |
|
this.logger.error("Could not construct DialCache key", error); |
|
this.metrics?.error({ |
|
...noLayerLabels, |
|
error: "key_construction", |
|
inFallback: false, |
|
}); |
|
return await this.callFallback(noLayerLabels, fallback); |
|
} |
- The README defines the selected/direct key as the value-identity contract:
|
For `cached()`, the key comes from the required `cacheKey` selector whose parameters are inferred from `fn`. `getOrLoad()` accepts the same bare id or `{ id, args }` shape directly through `key`: |
|
|
|
The selected or direct key is the value identity contract. It must include every input dimension that can affect the returned value; otherwise distinct calls can reuse the same cached value or share the same in-flight fallback through request coalescing. |
A built-artifact reproduction at the pinned commit passed {} twice and returned the first loader's value both times while running the loader only once.
Simplest scope
- Add one internal runtime assertion for valid ids.
- Accept only the existing supported scalar domain:
string | number | bigint for wrapper/direct-operation key specs.
- Require direct
DialCacheKeyInit.id to be a string, matching its public type.
- Reject missing,
undefined, null, object, boolean, symbol, and function ids instead of stringifying them.
- Let wrapper-driven failures use the existing
key_construction fail-open path.
- Let direct
new DialCacheKey(...) misuse throw a focused TypeError.
- Do not add a public error class, key mode, normalization option, or identity dimension.
- Preserve exact key bytes for every currently valid key.
Acceptance criteria
- An enabled
cached() selector returning {} or { id: undefined } through an untyped boundary:
- records one
key_construction error;
- executes the source-of-truth fallback uncached;
- does not populate request-local, process-local, or Redis state;
- does not coalesce with a legitimate
"undefined" id.
getOrLoad() has the same behavior for malformed structured keys.
- Direct
new DialCacheKey(...) rejects a missing or non-string id clearly.
- Valid string, number, and bigint wrapper keys retain the existing string-canonical identity contract.
- A legitimate string id
"undefined" remains supported and keeps its existing key bytes.
- Calls outside an enabled scope remain true pass-through and do not inspect or validate their selected/direct key.
- Focused tests cover cached, inline, direct-constructor, local, Redis, and coalescing paths.
- Typecheck, unit tests, build/declarations, packed ESM/CommonJS consumers, and integration tests remain green.
Compatibility
This is runtime hardening for malformed inputs. Correctly typed callers and valid key bytes are unchanged. Untyped callers that previously cached under an accidental "undefined" identity instead fail open and execute uncached.
Related issues
Priority
P2 — Medium — prevent malformed JavaScript or untyped input from becoming a valid shared cache identity.
Current baseline
At
v0.11.0/e06d833ba245706a499d66056fdad15dc1210b68, the TypeScriptCacheKeySpeccontract requires either a scalar id or{ id, args? }. Correctly typed callers are protected at compile time.At runtime, however, a structured object without
idis accepted.String(spec.id)converts the missing value to the literal string"undefined", so the malformed operation proceeds through normal caching and coalescing rather than the existingkey_constructionfail-open path.Problem
An untyped caller, JavaScript consumer, stale generated wrapper, or unsafe cast can supply
{ args: { locale: "en" } }or{ id: undefined, args: ... }. DialCache constructs an id of"undefined", which can collide with a legitimate string id"undefined"under the same namespace, key type, use case, and arguments.The collision can share request-local or process-scoped in-flight work, local and Redis values, and tracked invalidation identity. Because key construction does not throw, the operation is cached instead of logging/counting
key_constructionand running its source-of-truth fallback uncached. Directnew DialCacheKey(...)construction has the same missing-id coercion risk.Pinned evidence
id:DialCache/src/dialcache.ts
Lines 36 to 40 in e06d833
spec.idwithout validating it:DialCache/src/dialcache.ts
Lines 697 to 712 in e06d833
DialCacheKeyconstruction also accepts and encodes the supplied id without runtime validation:DialCache/src/key.ts
Lines 27 to 45 in e06d833
DialCache/src/dialcache.ts
Lines 324 to 335 in e06d833
DialCache/README.md
Lines 158 to 160 in e06d833
A built-artifact reproduction at the pinned commit passed
{}twice and returned the first loader's value both times while running the loader only once.Simplest scope
string | number | bigintfor wrapper/direct-operation key specs.DialCacheKeyInit.idto be a string, matching its public type.undefined,null, object, boolean, symbol, and function ids instead of stringifying them.key_constructionfail-open path.new DialCacheKey(...)misuse throw a focusedTypeError.Acceptance criteria
cached()selector returning{}or{ id: undefined }through an untyped boundary:key_constructionerror;"undefined"id.getOrLoad()has the same behavior for malformed structured keys.new DialCacheKey(...)rejects a missing or non-string id clearly."undefined"remains supported and keeps its existing key bytes.Compatibility
This is runtime hardening for malformed inputs. Correctly typed callers and valid key bytes are unchanged. Untyped callers that previously cached under an accidental
"undefined"identity instead fail open and execute uncached.Related issues
getOrLoad()direct-key contract.