From 207d8e425c0d4cc64906a98a91634548df5445c4 Mon Sep 17 00:00:00 2001 From: Kevin Nagurski Date: Thu, 6 Feb 2025 12:43:46 +0000 Subject: [PATCH 1/3] apply security audit fix --- package-lock.json | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/package-lock.json b/package-lock.json index 009e7f3..b108165 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2308,10 +2308,11 @@ "dev": true }, "node_modules/cross-spawn": { - "version": "7.0.3", - "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz", - "integrity": "sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==", + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", "dev": true, + "license": "MIT", "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", @@ -7817,9 +7818,9 @@ "dev": true }, "cross-spawn": { - "version": "7.0.3", - "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz", - "integrity": "sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==", + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", "dev": true, "requires": { "path-key": "^3.1.0", From 8978f1833e5c6a1732bad18a800bd65131f37f4b Mon Sep 17 00:00:00 2001 From: Kevin Nagurski Date: Thu, 6 Feb 2025 17:19:58 +0000 Subject: [PATCH 2/3] feat: [FFM-12306]: Add config to allow target attributes to be used when creating cache key --- README.md | 10 +++++++++- src/__tests__/cache.test.ts | 39 +++++++++++++++++++++++++++++++++++-- src/cache.ts | 24 ++++++++++++++++++++++- src/index.ts | 9 ++++----- src/types.ts | 8 ++++++++ 5 files changed, 81 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 9a12b58..667392f 100644 --- a/README.md +++ b/README.md @@ -314,6 +314,14 @@ interface CacheOptions { // storage mechanism to use, conforming to the Web Storage API standard, can be either synchronous or asynchronous // defaults to localStorage storage?: AsyncStorage | SyncStorage + /** + * use target attributes when deriving the cache key + * when set to `false` or omitted, the key will be formed using only the target identifier and SDK key + * when set to `true`, all target attributes with be used in addition to the target identifier and SDK key + * can be set to an array of target attributes to use a subset in addition to the target identifier and SDK key + * defaults to false + */ + deriveKeyFromTargetAttributes?: boolean | string[] } ``` @@ -355,7 +363,7 @@ If the request is aborted due to this timeout the SDK will fail to initialize an The default value if not specified is `0` which means that no timeout will occur. -**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) +**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) ```typescript const options = { diff --git a/src/__tests__/cache.test.ts b/src/__tests__/cache.test.ts index a496250..b0fff4e 100644 --- a/src/__tests__/cache.test.ts +++ b/src/__tests__/cache.test.ts @@ -1,5 +1,5 @@ -import type { AsyncStorage, Evaluation, SyncStorage } from '../types' -import { getCache } from '../cache' +import type { AsyncStorage, Evaluation, SyncStorage, Target } from '../types' +import { createCacheIdSeed, getCache } from '../cache' const sampleEvaluations: Evaluation[] = [ { flag: 'flag1', value: 'false', kind: 'boolean', identifier: 'false' }, @@ -149,3 +149,38 @@ describe('getCache', () => { }) }) }) + +describe('createCacheIdSeed', () => { + const apiKey = 'abc123' + const target: Target = { + name: 'Test Name', + identifier: 'test-identifier', + attributes: { + a: 'bcd', + b: 123, + c: ['x', 'y', 'z'] + } + } + + test('it should return the target id and api key when deriveKeyFromTargetAttributes is omitted', async () => { + expect(createCacheIdSeed(target, apiKey)).toEqual(target.identifier + apiKey) + }) + + test('it should return the target id and api key when deriveKeyFromTargetAttributes is false', async () => { + expect(createCacheIdSeed(target, apiKey, { deriveKeyFromTargetAttributes: false })).toEqual( + target.identifier + apiKey + ) + }) + + test('it should return the target id and api key with all attributes when deriveKeyFromTargetAttributes is true', async () => { + expect(createCacheIdSeed(target, apiKey, { deriveKeyFromTargetAttributes: true })).toEqual( + '{"a":"bcd","b":123,"c":["x","y","z"]}test-identifierabc123' + ) + }) + + test('it should return the target id and api key with a subset of attributes when deriveKeyFromTargetAttributes is an array', async () => { + expect(createCacheIdSeed(target, apiKey, { deriveKeyFromTargetAttributes: ['a', 'c'] })).toEqual( + '{"a":"bcd","c":["x","y","z"]}test-identifierabc123' + ) + }) +}) diff --git a/src/cache.ts b/src/cache.ts index 34fb054..a405ecf 100644 --- a/src/cache.ts +++ b/src/cache.ts @@ -1,4 +1,4 @@ -import type { AsyncStorage, CacheOptions, Evaluation, SyncStorage } from './types' +import type { AsyncStorage, CacheOptions, Evaluation, SyncStorage, Target } from './types' export interface GetCacheResponse { loadFromCache: () => Promise @@ -76,6 +76,28 @@ async function removeCachedEvaluation(cacheId: string, storage: AsyncStorage, fl } } +export function createCacheIdSeed(target: Target, apiKey: string, config: CacheOptions = {}) { + if (!config.deriveKeyFromTargetAttributes) return target.identifier + apiKey + + return ( + JSON.stringify( + Object.keys(target.attributes || {}) + .sort() + .filter( + attribute => + !Array.isArray(config.deriveKeyFromTargetAttributes) || + config.deriveKeyFromTargetAttributes.includes(attribute) + ) + .reduce( + (filteredAttributes, attribute) => ({ ...filteredAttributes, [attribute]: target.attributes[attribute] }), + {} + ) + ) + + target.identifier + + apiKey + ) +} + async function getCacheId(seed: string): Promise { let cacheId = seed diff --git a/src/index.ts b/src/index.ts index 7b7225d..1fd8e11 100644 --- a/src/index.ts +++ b/src/index.ts @@ -21,7 +21,7 @@ import { addMiddlewareToFetch } from './request' import { Streamer } from './stream' import { getVariation } from './variation' import Poller from './poller' -import { getCache } from './cache' +import { createCacheIdSeed, getCache } from './cache' const SDK_VERSION = '1.26.1' const SDK_INFO = `Javascript ${SDK_VERSION} Client` @@ -110,10 +110,9 @@ const initialize = (apiKey: string, target: Target, options?: Options): Result = try { let initialLoad = true - const cache = await getCache( - target.identifier + apiKey, - typeof configurations.cache === 'boolean' ? {} : configurations.cache - ) + const cacheConfig = typeof configurations.cache === 'boolean' ? {} : configurations.cache + + const cache = await getCache(createCacheIdSeed(target, apiKey, cacheConfig), cacheConfig) const cachedEvaluations = await cache.loadFromCache() diff --git a/src/types.ts b/src/types.ts index bdc6c7f..ce056c2 100644 --- a/src/types.ts +++ b/src/types.ts @@ -196,6 +196,14 @@ export interface CacheOptions { * @default localStorage */ storage?: AsyncStorage | SyncStorage + /** + * Use target attributes when deriving the cache key + * When set to `false` or omitted, the key will be formed using only the target identifier and SDK key + * When set to `true`, all target attributes with be used in addition to the target identifier and SDK key + * Can be set to an array of target attributes to use a subset in addition to the target identifier and SDK key + * @default false + */ + deriveKeyFromTargetAttributes?: boolean | string[] } export interface Logger { From 3e0dfc675d027128e33439ef0f7673f8be86dbfa Mon Sep 17 00:00:00 2001 From: Kevin Nagurski Date: Fri, 7 Feb 2025 12:06:02 +0000 Subject: [PATCH 3/3] feat: [FFM-13206]: Allow cache keys to be derived from target attributes --- package-lock.json | 4 ++-- package.json | 2 +- src/cache.ts | 3 ++- src/index.ts | 4 ++-- src/utils.ts | 6 +++++- 5 files changed, 12 insertions(+), 7 deletions(-) diff --git a/package-lock.json b/package-lock.json index b108165..e0a2f08 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@harnessio/ff-javascript-client-sdk", - "version": "1.29.0", + "version": "1.30.0", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "@harnessio/ff-javascript-client-sdk", - "version": "1.29.0", + "version": "1.30.0", "license": "Apache-2.0", "dependencies": { "jwt-decode": "^3.1.2", diff --git a/package.json b/package.json index c358033..7e6ec49 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@harnessio/ff-javascript-client-sdk", - "version": "1.29.0", + "version": "1.30.0", "author": "Harness", "license": "Apache-2.0", "main": "dist/sdk.cjs.js", diff --git a/src/cache.ts b/src/cache.ts index a405ecf..581418a 100644 --- a/src/cache.ts +++ b/src/cache.ts @@ -1,4 +1,5 @@ import type { AsyncStorage, CacheOptions, Evaluation, SyncStorage, Target } from './types' +import { sortEvaluations } from './utils' export interface GetCacheResponse { loadFromCache: () => Promise @@ -48,7 +49,7 @@ async function clearCachedEvaluations(cacheId: string, storage: AsyncStorage): P } async function saveToCache(cacheId: string, storage: AsyncStorage, evaluations: Evaluation[]): Promise { - await storage.setItem(cacheId, JSON.stringify(evaluations)) + await storage.setItem(cacheId, JSON.stringify(sortEvaluations(evaluations))) await storage.setItem(cacheId + '.ts', Date.now().toString()) } diff --git a/src/index.ts b/src/index.ts index 1fd8e11..106a384 100644 --- a/src/index.ts +++ b/src/index.ts @@ -16,7 +16,7 @@ import type { DefaultVariationEventPayload } from './types' import { Event } from './types' -import { defer, encodeTarget, getConfiguration } from './utils' +import { defer, encodeTarget, getConfiguration, sortEvaluations } from './utils' import { addMiddlewareToFetch } from './request' import { Streamer } from './stream' import { getVariation } from './variation' @@ -440,7 +440,7 @@ const initialize = (apiKey: string, target: Target, options?: Options): Result = ) if (res.ok) { - const data = await res.json() + const data = sortEvaluations(await res.json()) data.forEach(registerEvaluation) eventBus.emit(Event.FLAGS_LOADED, data) return { type: 'success', data: data } diff --git a/src/utils.ts b/src/utils.ts index 21b4723..cd79cdf 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -1,4 +1,4 @@ -import type { Options, Target } from './types' +import type { Evaluation, Options, Target } from './types' export const MIN_EVENTS_SYNC_INTERVAL = 60000 export const MIN_POLLING_INTERVAL = 60000 @@ -99,3 +99,7 @@ const utf8encode = (str: string): string => ) }) .join('') + +export function sortEvaluations(evaluations: Evaluation[]): Evaluation[] { + return [...evaluations].sort(({ flag: flagA }, { flag: flagB }) => (flagA < flagB ? -1 : 1)) +}