Skip to content

Commit 23d59df

Browse files
authored
feat: [FFM-12306]: Enhance caching key (#153)
* apply security audit fix * feat: [FFM-12306]: Add config to allow target attributes to be used when creating cache key
1 parent 88971c5 commit 23d59df

File tree

8 files changed

+100
-22
lines changed

8 files changed

+100
-22
lines changed

README.md

+9-1
Original file line numberDiff line numberDiff line change
@@ -314,6 +314,14 @@ interface CacheOptions {
314314
// storage mechanism to use, conforming to the Web Storage API standard, can be either synchronous or asynchronous
315315
// defaults to localStorage
316316
storage?: AsyncStorage | SyncStorage
317+
/**
318+
* use target attributes when deriving the cache key
319+
* when set to `false` or omitted, the key will be formed using only the target identifier and SDK key
320+
* when set to `true`, all target attributes with be used in addition to the target identifier and SDK key
321+
* can be set to an array of target attributes to use a subset in addition to the target identifier and SDK key
322+
* defaults to false
323+
*/
324+
deriveKeyFromTargetAttributes?: boolean | string[]
317325
}
318326
```
319327

@@ -355,7 +363,7 @@ If the request is aborted due to this timeout the SDK will fail to initialize an
355363

356364
The default value if not specified is `0` which means that no timeout will occur.
357365

358-
**This only applies to the authentiaction request. If you wish to set a read timeout on the remaining requests made by the SDK, you may register [API Middleware](#api-middleware)
366+
**This only applies to the authentication request. If you wish to set a read timeout on the remaining requests made by the SDK, you may register [API Middleware](#api-middleware)
359367

360368
```typescript
361369
const options = {

package-lock.json

+9-8
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@harnessio/ff-javascript-client-sdk",
3-
"version": "1.29.0",
3+
"version": "1.30.0",
44
"author": "Harness",
55
"license": "Apache-2.0",
66
"main": "dist/sdk.cjs.js",

src/__tests__/cache.test.ts

+37-2
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
1-
import type { AsyncStorage, Evaluation, SyncStorage } from '../types'
2-
import { getCache } from '../cache'
1+
import type { AsyncStorage, Evaluation, SyncStorage, Target } from '../types'
2+
import { createCacheIdSeed, getCache } from '../cache'
33

44
const sampleEvaluations: Evaluation[] = [
55
{ flag: 'flag1', value: 'false', kind: 'boolean', identifier: 'false' },
@@ -149,3 +149,38 @@ describe('getCache', () => {
149149
})
150150
})
151151
})
152+
153+
describe('createCacheIdSeed', () => {
154+
const apiKey = 'abc123'
155+
const target: Target = {
156+
name: 'Test Name',
157+
identifier: 'test-identifier',
158+
attributes: {
159+
a: 'bcd',
160+
b: 123,
161+
c: ['x', 'y', 'z']
162+
}
163+
}
164+
165+
test('it should return the target id and api key when deriveKeyFromTargetAttributes is omitted', async () => {
166+
expect(createCacheIdSeed(target, apiKey)).toEqual(target.identifier + apiKey)
167+
})
168+
169+
test('it should return the target id and api key when deriveKeyFromTargetAttributes is false', async () => {
170+
expect(createCacheIdSeed(target, apiKey, { deriveKeyFromTargetAttributes: false })).toEqual(
171+
target.identifier + apiKey
172+
)
173+
})
174+
175+
test('it should return the target id and api key with all attributes when deriveKeyFromTargetAttributes is true', async () => {
176+
expect(createCacheIdSeed(target, apiKey, { deriveKeyFromTargetAttributes: true })).toEqual(
177+
'{"a":"bcd","b":123,"c":["x","y","z"]}test-identifierabc123'
178+
)
179+
})
180+
181+
test('it should return the target id and api key with a subset of attributes when deriveKeyFromTargetAttributes is an array', async () => {
182+
expect(createCacheIdSeed(target, apiKey, { deriveKeyFromTargetAttributes: ['a', 'c'] })).toEqual(
183+
'{"a":"bcd","c":["x","y","z"]}test-identifierabc123'
184+
)
185+
})
186+
})

src/cache.ts

+25-2
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
1-
import type { AsyncStorage, CacheOptions, Evaluation, SyncStorage } from './types'
1+
import type { AsyncStorage, CacheOptions, Evaluation, SyncStorage, Target } from './types'
2+
import { sortEvaluations } from './utils'
23

34
export interface GetCacheResponse {
45
loadFromCache: () => Promise<Evaluation[]>
@@ -48,7 +49,7 @@ async function clearCachedEvaluations(cacheId: string, storage: AsyncStorage): P
4849
}
4950

5051
async function saveToCache(cacheId: string, storage: AsyncStorage, evaluations: Evaluation[]): Promise<void> {
51-
await storage.setItem(cacheId, JSON.stringify(evaluations))
52+
await storage.setItem(cacheId, JSON.stringify(sortEvaluations(evaluations)))
5253
await storage.setItem(cacheId + '.ts', Date.now().toString())
5354
}
5455

@@ -76,6 +77,28 @@ async function removeCachedEvaluation(cacheId: string, storage: AsyncStorage, fl
7677
}
7778
}
7879

80+
export function createCacheIdSeed(target: Target, apiKey: string, config: CacheOptions = {}) {
81+
if (!config.deriveKeyFromTargetAttributes) return target.identifier + apiKey
82+
83+
return (
84+
JSON.stringify(
85+
Object.keys(target.attributes || {})
86+
.sort()
87+
.filter(
88+
attribute =>
89+
!Array.isArray(config.deriveKeyFromTargetAttributes) ||
90+
config.deriveKeyFromTargetAttributes.includes(attribute)
91+
)
92+
.reduce(
93+
(filteredAttributes, attribute) => ({ ...filteredAttributes, [attribute]: target.attributes[attribute] }),
94+
{}
95+
)
96+
) +
97+
target.identifier +
98+
apiKey
99+
)
100+
}
101+
79102
async function getCacheId(seed: string): Promise<string> {
80103
let cacheId = seed
81104

src/index.ts

+6-7
Original file line numberDiff line numberDiff line change
@@ -16,12 +16,12 @@ import type {
1616
DefaultVariationEventPayload
1717
} from './types'
1818
import { Event } from './types'
19-
import { defer, encodeTarget, getConfiguration } from './utils'
19+
import { defer, encodeTarget, getConfiguration, sortEvaluations } from './utils'
2020
import { addMiddlewareToFetch } from './request'
2121
import { Streamer } from './stream'
2222
import { getVariation } from './variation'
2323
import Poller from './poller'
24-
import { getCache } from './cache'
24+
import { createCacheIdSeed, getCache } from './cache'
2525

2626
const SDK_VERSION = '1.26.1'
2727
const SDK_INFO = `Javascript ${SDK_VERSION} Client`
@@ -110,10 +110,9 @@ const initialize = (apiKey: string, target: Target, options?: Options): Result =
110110
try {
111111
let initialLoad = true
112112

113-
const cache = await getCache(
114-
target.identifier + apiKey,
115-
typeof configurations.cache === 'boolean' ? {} : configurations.cache
116-
)
113+
const cacheConfig = typeof configurations.cache === 'boolean' ? {} : configurations.cache
114+
115+
const cache = await getCache(createCacheIdSeed(target, apiKey, cacheConfig), cacheConfig)
117116

118117
const cachedEvaluations = await cache.loadFromCache()
119118

@@ -441,7 +440,7 @@ const initialize = (apiKey: string, target: Target, options?: Options): Result =
441440
)
442441

443442
if (res.ok) {
444-
const data = await res.json()
443+
const data = sortEvaluations(await res.json())
445444
data.forEach(registerEvaluation)
446445
eventBus.emit(Event.FLAGS_LOADED, data)
447446
return { type: 'success', data: data }

src/types.ts

+8
Original file line numberDiff line numberDiff line change
@@ -196,6 +196,14 @@ export interface CacheOptions {
196196
* @default localStorage
197197
*/
198198
storage?: AsyncStorage | SyncStorage
199+
/**
200+
* Use target attributes when deriving the cache key
201+
* When set to `false` or omitted, the key will be formed using only the target identifier and SDK key
202+
* When set to `true`, all target attributes with be used in addition to the target identifier and SDK key
203+
* Can be set to an array of target attributes to use a subset in addition to the target identifier and SDK key
204+
* @default false
205+
*/
206+
deriveKeyFromTargetAttributes?: boolean | string[]
199207
}
200208

201209
export interface Logger {

src/utils.ts

+5-1
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import type { Options, Target } from './types'
1+
import type { Evaluation, Options, Target } from './types'
22

33
export const MIN_EVENTS_SYNC_INTERVAL = 60000
44
export const MIN_POLLING_INTERVAL = 60000
@@ -99,3 +99,7 @@ const utf8encode = (str: string): string =>
9999
)
100100
})
101101
.join('')
102+
103+
export function sortEvaluations(evaluations: Evaluation[]): Evaluation[] {
104+
return [...evaluations].sort(({ flag: flagA }, { flag: flagB }) => (flagA < flagB ? -1 : 1))
105+
}

0 commit comments

Comments
 (0)