Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 7 additions & 43 deletions examples/javascript/custom-caching-function-support/hello.js
Original file line number Diff line number Diff line change
@@ -1,58 +1,22 @@
const fs = require('fs');

const {
getSecrets,
initializeStorage,
localConfigStorage,
postFunction
createCachingFunction
} = require('@keeper-security/secrets-manager-core')

const CACHE_FILENAME = 'cache.dat';

// This is basic example of creating custom caching function
// ⓘ This will store only last request, however you can use any tool to extend this functionality
// ⓘ Stale cache entries can cause version mismatches if records are updated from other keepersecurity utils. Prefer fresh reads

const cachingPostFunction = async (url, transmissionKey, payload, allowUnverifiedCertificate) => {
try {
const response = await postFunction(
url,
transmissionKey,
payload,
allowUnverifiedCertificate
)

if (response.statusCode == 200) {
fs.writeFileSync(CACHE_FILENAME, Buffer.concat([transmissionKey.key, response.data]))
}

return response
} catch (e) {
console.error(e)
let cachedData
try {
cachedData = fs.readFileSync(CACHE_FILENAME)
} catch {
}
if (!cachedData) {
throw new Error('Cached value does not exist')
}
console.log('Using cached data')
transmissionKey.key = cachedData.slice(0, 32)
return {
statusCode: 200,
data: cachedData.slice(32),
headers: []
}
}
}
// This is a basic example of using the SDK's built-in caching function.
// ⓘ createCachingFunction stores only the last successful request, but you can supply your own
// queryFunction to extend this behavior.
// ⓘ Stale cache entries can cause version mismatches if records are updated from other keepersecurity
// utils. createCachingFunction rejects cache entries older than its maxCacheAgeMs (default 24h).

const getKeeperRecords = async () => {
const storage = localConfigStorage("config.json")

const options = {
storage,
queryFunction: cachingPostFunction
queryFunction: createCachingFunction(storage)
}

// if your Keeper Account is in other region than US, update the hostname accordingly
Expand Down
10 changes: 10 additions & 0 deletions sdk/javascript/packages/core/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,16 @@
- KSM-1263 - Fixed config and cache file permissions not being re-applied on every write. `fs.openSync`'s mode argument only takes effect when a file is created, so a config or cache file that already existed with looser permissions kept them; permissions are now explicitly reset to 0600 after every write.
- KSM-1267 - `getFolders()` now classifies why an undecryptable folder was skipped (`integrity`, `format`, `missing-key`, or `malformed-data`) instead of logging an opaque, unclassified error, and logs one summary line naming every folder UID it had to omit. Added an optional `onDecryptionError` callback to `SecretManagerOptions`, invoked once per skipped folder, so a caller can react to or throw to fail closed on a partial result; existing callers that do not set it see no behavior change. Both the Node and browser platforms' `unwrap()` now reject an unwrapped key of the wrong length immediately (a corrupted-but-plausible 16- or 24-byte result was previously accepted by both platforms and cached, failing later at an unrelated call site with a much harder to diagnose error). The underlying finding (the shared-folder key wrap uses unauthenticated AES-256-CBC, a format fixed server-side that the SDK cannot change unilaterally) was reviewed and confirmed low-impact: a manipulated folder key is still caught by the existing AES-GCM authentication on the record keys inside that folder.
- KSM-1266 - Fixed `localConfigStorage` treating every config-read failure as "no config yet." A missing file is still a legitimate fresh start, and so now is one left completely empty by a process killed mid-save (a partially-written file is not covered by this - there is no reliable way to distinguish a truncated write from genuine corruption, so it still throws). Permission errors, malformed JSON, invalid UTF-8 byte sequences, and JSON that parses but isn't an object (`null`, a number, an array) now throw a typed `KeeperError` instead of silently starting fresh or misbehaving on first use. A leading UTF-8 BOM (produced by tools like Windows Notepad or PowerShell's `Set-Content`) is stripped and the file is read normally, not treated as corruption. `saveStorage`'s write path now writes to a temporary file and renames it into place atomically, instead of truncating the destination before writing (which could previously leave a 0-byte file on disk after a failed write), and wraps its own failures (e.g. `EACCES`, `ENOSPC`) in the same `KeeperError` guarantee. Node validates config readability eagerly, at construction; the browser `localConfigStorage` (KSM-1332, same release) defers the equivalent check lazily to first storage access, since IndexedDB has no synchronous API to check eagerly against - this timing difference between the two platforms is expected and now documented in-code. The write path now resolves a symlinked config path and writes through the real file. It no longer replaces the symlink. Some deployments manage a "current config" symlink this way. This also covers a symlink whose target does not exist yet, for example a symlink an ops tool creates before the target file exists, including a chain of such symlinks up to 40 hops deep, the same bound the operating system's own resolution enforces. A resolution failure partway through that chain, for example a permissions error on an intermediate link, now fails the save instead of silently writing through the wrong path. A hard-linked config path now goes through the same atomic write as any other file. Only the resolved path gets the update; a second hard-linked name keeps its old content, because the atomic write always creates a new file at the resolved path. Before this fix, a hard-linked config path was written in place, so every hard link saw the update, but that write was not atomic: a failure partway through could corrupt the file with no recovery. An atomic write now needs write and execute permission on the config file's directory, not just the file itself. A directory locked down to file-only write access will fail every save from now on. If a crash happens between opening the temporary file and the rename, the SDK now removes the leftover file automatically on the next read. Before this fix, the file stayed on disk indefinitely. That cleanup resolves the config path the same way the write path does, so it also finds the leftover file when the config path is itself a symlink, including one whose target didn't exist at write time. A failed save no longer leaves the in-memory value ahead of the value on disk. `localConfigStorage` now throws the new `KeeperStorageError`, which extends `KeeperError`. Its `code` field carries the original filesystem error code, for example `EACCES` or `ENOSPC`, when one exists. Concurrent `saveString`/`saveBytes`/`delete` calls on the same `localConfigStorage` instance now run one at a time. Before this fix, two overlapping calls could interleave so that a failed save's rollback erased a different, already-successful call's data.
- KSM-1265 - **BREAKING (Node only):** Security fix (CWE-312, CWE-345): the Node `cachingPostFunction` stored its AES transmission key in plaintext next to the ciphertext it protected, in a fixed path relative to the process's working directory, and restored it with no integrity check. Replaced it with `createCachingFunction(storage, options?)`:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

N21 (low): A breaking change ships as a plain fix commit inside a minor version bump

cachingPostFunction is exported on master at 17.5.0 and is removed here, while the version stays 17.6.0, a minor bump. semver.satisfies('17.6.0','^17.3.0') is true, and the four sibling storage packages all pin ^17.3.0, so they accept it with no action. A TypeScript consumer gets TS2305; a CommonJS JavaScript consumer gets a runtime TypeError. All three commits are plain fix(javascript) with no exclamation mark and no BREAKING CHANGE footer, while the repo does use that marker elsewhere and the last breaking JavaScript change took the major bump to 17.0.0. Please put the marker on the squashed commit and add the footer, so the history matches the CHANGELOG's own BREAKING label, and confirm with the release owner that a minor is the accepted number here.

```
// before
queryFunction: cachingPostFunction
// after
queryFunction: createCachingFunction(storage)
// with a custom cache path or freshness window
queryFunction: createCachingFunction(storage, {cachePath, maxCacheAgeMs})
```
The cache is now encrypted with a key derived from the app key already held in the config (so reading the cache requires the config, not just the cache file), authenticated so a tampered or corrupted file is rejected instead of silently trusted, bounded by a configurable freshness window (default 24h), and located at `~/.keeper/ksm-cache.dat` by default instead of the working directory. Usage was limited to the opt-in caching example, which has been updated to use the new function. If you called `cachingPostFunction` directly, delete the old cache file in your working directory after upgrading; it is not removed automatically. `cachePath` and `maxCacheAgeMs` are now named fields on an options object instead of positional arguments, since Node's and the browser's second positional argument meant different things; the browser signature (`createCachingFunction(storage, maxCacheAgeMs?)`) is unchanged and still non-breaking there, since the new `maxCacheAgeMs` parameter is optional and an old-format cached value is simply treated as a cache miss. A symlinked cache directory is rejected outright (throws) on both read and write, on POSIX platforms; Windows has no equivalent `O_NOFOLLOW`/`O_DIRECTORY` protection, so on that platform the cache's protection is the encryption and authentication alone. A symlinked cache file itself is not rejected with an error - the write silently replaces it with a real file instead of following it, and the read still requires the actual content underneath to pass its own integrity check - there is no legitimate externally-managed symlink convention for a path the SDK itself names, unlike the config file's own symlink handling, which is unchanged and intentionally different (see the KSM-1266 entry above). Both the config file and the cache file are now written atomically (to a temporary file, then renamed into place, via one shared primitive), so a write that fails partway through can no longer leave a corrupted or truncated file behind. The cache directory is created at `0700`. On the default path (`~/.keeper`), it's re-hardened to `0700` on every write, matching how the cache file itself already self-heals; on a caller-supplied `cachePath` pointing at a directory that already exists (for example, a file directly inside `$HOME`), its permissions are left alone - the SDK does not narrow permissions on a directory it doesn't own. Known limitation: the very first (bind) call's response is not cached, since caching requires an app key that the bind call itself establishes; every call after that caches normally. In the browser, when the app key is held as a non-extractable `CryptoKey` (`useObjects: true`), caching is a no-op rather than an error, the same graceful degradation already used for a network failure with no prior cache. A network failure served from cache now logs a warning, since the caller is getting a response that may be stale. This package now also declares an `exports` field so bundlers and modern TypeScript resolve the correct platform-specific type declarations for the browser bundle; a consumer still on TypeScript's legacy `moduleResolution: "node"` continues to see the Node type declarations regardless, unchanged from before.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

C15 (low): Round-5 N11 still open: the CHANGELOG's only sentence about the exports field is wrong about both bundlers and TypeScript, and now also understates the change

Still open from round 5 (N11), and now also incomplete.

The KSM-1265 entry says the exports field exists so bundlers and modern TypeScript resolve the correct platform-specific type declarations for the browser bundle. Both halves are wrong. I re-checked with the package's own TypeScript 5.9 and traceResolution. moduleResolution node, node10, node16, nodenext and bundler all resolve dist/node/index.d.ts, and node16, nodenext and bundler each report a non-matching browser condition. Only customConditions: ["browser"] reaches dist/browser/index.d.ts. Bundlers do not read the types condition at all; they read the runtime conditions.

The sentence also understates the change, because the field decides runtime resolution and removes subpaths. A native-ESM Node consumer now resolves dist/index.cjs.js, which is what the node condition fixed. A resolver whose conditions are module and import resolves the browser ES bundle at this head and resolved the Node bundle on the release branch. Deep paths inside the package no longer resolve at all.

Please replace the sentence with three plain statements. The browser type declarations are selected only when a consumer opts in with customConditions on TypeScript 5.0 or later; every other setting continues to see the Node type declarations. A Node consumer, ESM or CommonJS, resolves the Node build. Paths inside the package are no longer importable, so import the package root. The entry also does not mention the new node condition at all.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

C22 (low): The CHANGELOG says a symlinked cache directory throws on read and write, but the write-side rejection never reaches the caller

The KSM-1265 entry now says a symlinked cache directory is rejected outright and throws on both read and write, on POSIX platforms. Thank you for the Windows qualifier. The read half is accurate: readCacheFile's error propagates through postQuery, which awaits the query function with no try and catch, so getSecrets rejects.

The write half does not reach the caller. writeCacheFile does throw on the directory open, but createCachingFunction catches every failure of that write and only logs "Failed to update cached response". I put the cache directory behind a symlink and called the caching function: it returned statusCode 200 with the full response body, nothing landed in the target directory, and that one log line was the only trace. A control with a plain directory wrote the cache file, so the write path really did run. Caching then stays off for the life of the process with no exception anywhere.

Please reword to say the read path throws and the write path skips the cache and logs. The behaviour itself is right: a failed cache write should degrade silently, so only the sentence needs to change.

- Maintenance: Updated `minimatch`, `@babel/core`, and `handlebars` dev dependencies.

## 17.5.0
Expand Down
4 changes: 2 additions & 2 deletions sdk/javascript/packages/core/internal/quicktest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import {
import {nodePlatform} from '../src/node/nodePlatform';
import {connectPlatform} from '../src/platform';
import {inspect} from 'util';
import {cachingPostFunction, localConfigStorage} from "../src/node";
import {createCachingFunction, localConfigStorage} from "../src/node";

process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0'

Expand All @@ -26,7 +26,7 @@ async function test() {
await initializeStorage(kvs, oneTimeToken)
const options: SecretManagerOptions = {
storage: kvs,
// queryFunction: cachingPostFunction
// queryFunction: createCachingFunction(kvs)
allowUnverifiedCertificate: true
}
const { records } = await getSecrets(options)
Expand Down
17 changes: 17 additions & 0 deletions sdk/javascript/packages/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,23 @@
"browser": "dist/index.es.js",
"main": "dist/index.cjs.js",
"types": "dist/node/index.d.ts",
"exports": {
Comment thread
stas-schaller marked this conversation as resolved.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

C13 (low): Adding the exports field removed every deep subpath, and the CHANGELOG does not record the removal

Adding the exports field removed every deep subpath, and nothing records that.

The map declares only "." and "./package.json", so Node rejects every other specifier. I verified with a scratch consumer: require and dynamic import of the package's dist/index.cjs.js and dist/index.es.js all fail with ERR_PACKAGE_PATH_NOT_EXPORTED, while the root and ./package.json still resolve. The published 17.5.0 tarball has no exports field and it ships dist and src, so all of those paths resolved before. TypeScript deep type imports break too: a deep import of a declaration file compiles clean against the release-branch shape and reports TS2307 at this head under node16.

17.6.0 is a minor bump that a caret range accepts automatically, and the KSM-1265 entry never mentions subpaths, so a consumer who pinned an internal file gets a build failure with no warning.

Please either add "./dist/": "./dist/" for one release to keep the previous behaviour, or state in the BREAKING list that the package root is now the only entry point. Encapsulating internals is a fair goal; the ask is to record it. Note test/exports.test.ts covers the root specifier only, so nothing pins subpath behaviour in either direction.

".": {
"types": {
"browser": "./dist/browser/index.d.ts",
"default": "./dist/node/index.d.ts"
},
"node": {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

C25 (nit): The node condition sits ahead of browser, so a toolchain that sets both conditions now receives the Node CommonJS bundle

Small packaging note on condition order. The node key sits before browser, and conditional exports match in key order. So a toolchain that activates both conditions now resolves dist/index.cjs.js, where the previous head and the release branch both gave it dist/index.es.js. webpack sets both conditions for its electron-renderer and nwjs targets.

For electron-preload and electron-main this is an improvement, since they previously received the browser bundle. The one regressing shape is an electron-renderer build run without Node integration, which then fails on the CommonJS requires; target web is the correct setting there. No package in this repository sets both conditions.

If you want a browser-targeting build that also sets node to keep the ES bundle, list browser before node. Node never sets the browser condition, so real Node consumers and test/exports.test.ts are unaffected. This is a preference, not a defect.

"import": "./dist/index.cjs.js",
"require": "./dist/index.cjs.js"
},
"browser": "./dist/index.es.js",
"import": "./dist/index.cjs.js",
"require": "./dist/index.cjs.js",
"default": "./dist/index.cjs.js"
},
"./package.json": "./package.json"
},
"repository": "https://github.com/Keeper-Security/secrets-manager",
"author": "sm@keepersecurity.com",
"license": "MIT",
Expand Down
55 changes: 46 additions & 9 deletions sdk/javascript/packages/core/src/browser/localConfigStorage.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
import {EncryptedPayload, KeeperHttpResponse, KeyValueStorage, TransmissionKey, platform} from "../platform";
import {KeeperError} from "../errors";
import {KEY_APP_KEY, deriveCacheKey, encodeCacheBlob, decodeCacheBlob, DEFAULT_MAX_CACHE_AGE_MS, isRawKeyBytes, concatBytes} from "../cache";

const CACHE_STORAGE_KEY = 'cache'

type Reject = (reason: Error) => void

Expand Down Expand Up @@ -202,30 +205,64 @@ export const secureStorage = async (dbName: string): Promise<KeyValueStorage> =>
}
}

export function createCachingFunction(storage: KeyValueStorage): (url: string, transmissionKey: TransmissionKey, payload: EncryptedPayload) => Promise<KeeperHttpResponse> {
// Same cache codec (../cache) as node/localConfigStorage.ts's createCachingFunction; only the
// storage medium differs (IndexedDB here, a file there). Replaces the old plaintext
// key-beside-data format (CWE-312, CWE-345) with one encrypted under a key derived from the app
// key, authenticated, and bounded by a freshness window. An old-format cached value simply fails
// the version check and is treated as a cache miss, the same graceful degradation the Node fix
// uses for its old-format files.
export function createCachingFunction(storage: KeyValueStorage, maxCacheAgeMs: number = DEFAULT_MAX_CACHE_AGE_MS): (url: string, transmissionKey: TransmissionKey, payload: EncryptedPayload) => Promise<KeeperHttpResponse> {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

N4 (medium): The browser factory still takes a bare maxCacheAgeMs, so the Node options object disables the freshness window there

Node's createCachingFunction now takes an options object. The browser's second parameter is still a bare number. A consumer who passes the Node shape to a browser bundle makes every staleness comparison NaN-false, so the cache never expires. I confirmed this: after caching an entry and moving the clock ten years forward, the browser served it as a 200 with no error. tsc does not catch it, because every standard moduleResolution setting resolves dist/node/index.d.ts. Please give the browser the same options-object signature, and reject a maxCacheAgeMs that is not a finite positive number at the factory.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

C1 (medium): Round-5 N4 still open: the browser factory takes a positional number, the object form typechecks and silently disables the freshness window, and the correct browser call is a hard type error

Still open from round 5 (N4). src/browser/localConfigStorage.ts is byte-identical to the previous head, so this was not addressed this round.

The browser factory still takes a positional number, and the Node factory takes an options object. Round 5 already reported that the object form makes the staleness comparison NaN-false, so the browser cache never expires. I reproduced that again: with the object form and an entry one year old, the browser served it as a statusCode 200; with the number form the same entry was rejected.

One measurement is new, and it is the strongest argument for fixing this. The correct browser call, createCachingFunction(storage, 1000), is a hard type error under every default setting. moduleResolution node, node10, node16, nodenext and bundler all resolve dist/node/index.d.ts and report TS2559. Only customConditions: ["browser"] reaches dist/browser/index.d.ts. So the compiler rejects the right call and accepts the wrong one. The new exports field also exposes only "." and "./package.json", so importing the browser declarations by subpath now reports TS2307 under bundler and nodenext. That removes the workaround.

A non-finite value has the same effect on both platforms. Number(process.env.MAX_AGE) with the variable unset gives NaN, and on Node "options.maxCacheAgeMs ?? DEFAULT_MAX_CACHE_AGE_MS" does not filter it, because ?? only replaces undefined and null. I measured a ten-year-old entry served as a statusCode 200 in both builds.

Suggested fix: accept (storage, options?: number | {maxCacheAgeMs?: number}) on the browser for one release, and normalise a bare number to {maxCacheAgeMs}. Then, in both factories, reject a maxCacheAgeMs that is not a finite number above zero. That makes the documented browser call expressible under default TypeScript settings, and makes the mistake loud.

Impact, stated plainly: no shipped code passes the object form on the browser, the default 24 hour window works, and the mis-called path keeps this PR's encryption and integrity gains. Freshness falls back to release-branch behaviour rather than below it. The CHANGELOG does state the browser form in the same entry, so the docs are right and the type system is not.


return async (url: string, transmissionKey: TransmissionKey, payload: EncryptedPayload): Promise<KeeperHttpResponse> => {
let response: KeeperHttpResponse
try {
const response = await platform.post(url, payload.payload, {
response = await platform.post(url, payload.payload, {
PublicKeyId: transmissionKey.publicKeyId.toString(),
TransmissionKey: platform.bytesToBase64(transmissionKey.encryptedKey),
Authorization: `Signature ${platform.bytesToBase64(payload.signature)}`
})
if (response.statusCode == 200) {
await storage.saveBytes('cache', new Uint8Array([...transmissionKey.key, ...response.data]))
}
return response
} catch (e) {
const cachedData = await storage.getBytes('cache')
if (!cachedData) {
throw new Error('Cached value does not exist')
const appKey = await storage.getBytes(KEY_APP_KEY)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

N3 (low): The browser fallback still calls storage.getBytes unguarded, the half of round 4's comment that was not fixed

Node now wraps storage.getBytes(KEY_APP_KEY) in try/catch and normalizes a storage failure to 'Cached value does not exist'. The browser still calls getBytes bare for both the app key and the cache entry, even though round 4's comment named browser/localConfigStorage.ts:225. With indexedDB absent, the browser fallback rejects with a bare ReferenceError, where Node returns the documented KeeperError. Please mirror the Node guard and add the matching browser test, so the two platforms cannot drift again.

if (!appKey || !isRawKeyBytes(appKey)) {
throw new KeeperError('Cached value does not exist')
}
const raw = await storage.getBytes(CACHE_STORAGE_KEY)
if (!raw) {
throw new KeeperError('Cached value does not exist')
}
let cachedData: Uint8Array
try {
cachedData = await decodeCacheBlob(raw, await deriveCacheKey(appKey), maxCacheAgeMs)
} catch (e2: Error | any) {
throw new KeeperError(`Cached value is invalid: ${e2.message}`)
}
console.error(`Network request failed (${describeCause(e)}); serving cached response, which may be stale`)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

N15 (nit): The browser stale-cache warning still has no test, so that line can be deleted with the suite green

Node gained a regression test for the 'serving cached response, which may be stale' warning in this round. The browser did not, so deleting the browser line changes no test result. Round 4's comment said the log had no test on either platform, so this is the remaining half. The only console.error assertion in test/browserLocalConfigStorage.test.ts targets the new useObjects message. About ten lines mirroring the Node test would close it.

transmissionKey.key = cachedData.slice(0, 32)
return {
statusCode: 200,
data: cachedData.slice(32),
headers: []
}
}
if (response.statusCode == 200) {
try {
const appKey = await storage.getBytes(KEY_APP_KEY)
if (appKey && isRawKeyBytes(appKey)) {
Comment thread
stas-schaller marked this conversation as resolved.
const blob = await encodeCacheBlob(concatBytes(transmissionKey.key, response.data), await deriveCacheKey(appKey))
await storage.saveBytes(CACHE_STORAGE_KEY, blob)
} else if (appKey) {
// appKey exists but isn't raw bytes - useObjects: true wraps it as a
// non-extractable CryptoKey, so caching is a deliberate no-op here (matches
// the identical guard in the fallback branch above), not a failure. Logged
// once per call, same as the fallback branch's own log a few lines up, so a
// caller who opted into useObjects: true has some signal that caching isn't
// doing anything for them before their first real outage.
console.error('Caching is a no-op with useObjects: true - the app key is not available as raw bytes')
}
} catch (e) {
console.error(`Failed to update cached response: ${describeCause(e)}`)
}
}
return response
}
}
Loading