From 65986fe23730eb18bf32605a8b458675bef0b5d4 Mon Sep 17 00:00:00 2001 From: Sameeran Kunche Date: Wed, 8 Jan 2025 13:44:59 -0800 Subject: [PATCH] feat: obfuscated precomputed assignments (#164) * make getMD5HashWithSalt * add tests * Export saltedHasher * Fix tests * Alter test to use obfuscated file * Change branch name for test data * Get all the tests to pass * Make more obvious that the salt was decoded * Switch to using appendBinary for the salt * Clean up * Include salt in convenience method for setting precomputed flag store * Add a helper to convert context attributes to subject attributes * Change default to isObfuscated since we expect the precomputed api to mainly be used by clients * v4.7.1-alpha.0 * Revert "v4.7.1-alpha.0" This reverts commit b81175fc82ff81ef3825c797bb0d462a03fd6a97. * v4.7.0-alpha.0 * Switch to initializing the client with an options object * Make response data not optional * precomputedFlag variable casing * update hashing * fix lint * handoff and address comments * bump version * Inf is a numeric attribute too * Remove unnecessary public methods * Remove more unnecessary functions * Add to exported interfaces * Update src/interfaces.ts Co-authored-by: Oleksii Shmalko * Update src/attributes.ts attributes is ContextAttributes Co-authored-by: Oleksii Shmalko * Remove redundant 'subjectAttributes as ContextAttributes' * Also print error if store is missing salt * Remove buildContextAttributes * v4.8.0-alpha.0 --------- Co-authored-by: Ty Potter Co-authored-by: Leo Romanovsky Co-authored-by: Oleksii Shmalko --- Makefile | 1 + package.json | 2 +- src/attributes.ts | 76 ++++ src/client/eppo-client.ts | 94 +--- src/client/eppo-precomputed-client.spec.ts | 414 +++++++++++------- src/client/eppo-precomputed-client.ts | 144 +++--- .../configuration-store-utils.ts | 2 + .../configuration-store.ts | 1 + src/configuration-store/memory.store.ts | 2 + src/decoding.ts | 11 +- src/index.ts | 7 +- src/interfaces.ts | 24 +- src/obfuscation.ts | 6 +- src/precomputed-requestor.spec.ts | 5 +- src/precomputed-requestor.ts | 7 +- test/testHelpers.ts | 10 + 16 files changed, 472 insertions(+), 334 deletions(-) create mode 100644 src/attributes.ts diff --git a/Makefile b/Makefile index e73df080..0a914fc2 100644 --- a/Makefile +++ b/Makefile @@ -36,6 +36,7 @@ test-data: mkdir -p $(tempDir) git clone -b ${branchName} --depth 1 --single-branch ${githubRepoLink} ${gitDataDir} cp -r ${gitDataDir}ufc ${testDataDir} + cp -r ${gitDataDir}configuration-wire ${testDataDir} rm -rf ${tempDir} ## prepare diff --git a/package.json b/package.json index 1db9deac..353b3622 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@eppo/js-client-sdk-common", - "version": "4.7.1", + "version": "4.8.0-alpha.0", "description": "Common library for Eppo JavaScript SDKs (web, react native, and node)", "main": "dist/index.js", "files": [ diff --git a/src/attributes.ts b/src/attributes.ts new file mode 100644 index 00000000..6f5929c1 --- /dev/null +++ b/src/attributes.ts @@ -0,0 +1,76 @@ +import { Attributes, BanditActions, BanditSubjectAttributes, ContextAttributes } from './types'; + +export function isInstanceOfContextualAttributes( + attributes: unknown, +): attributes is ContextAttributes { + return Boolean( + typeof attributes === 'object' && + attributes && // exclude null + 'numericAttributes' in attributes && + 'categoricalAttributes' in attributes, + ); +} + +export function ensureNonContextualSubjectAttributes( + subjectAttributes: BanditSubjectAttributes, +): Attributes { + let result: Attributes; + if (isInstanceOfContextualAttributes(subjectAttributes)) { + const contextualSubjectAttributes = subjectAttributes; + result = { + ...contextualSubjectAttributes.numericAttributes, + ...contextualSubjectAttributes.categoricalAttributes, + }; + } else { + // Attributes are non-contextual + result = subjectAttributes as Attributes; + } + return result; +} + +export function ensureContextualSubjectAttributes( + subjectAttributes: BanditSubjectAttributes, +): ContextAttributes { + if (isInstanceOfContextualAttributes(subjectAttributes)) { + return subjectAttributes; + } else { + return deduceAttributeContext(subjectAttributes as Attributes); + } +} + +export function deduceAttributeContext(attributes: Attributes): ContextAttributes { + const contextualAttributes: ContextAttributes = { + numericAttributes: {}, + categoricalAttributes: {}, + }; + Object.entries(attributes).forEach(([attribute, value]) => { + const isNumeric = typeof value === 'number'; + if (isNumeric) { + contextualAttributes.numericAttributes[attribute] = value; + } else { + contextualAttributes.categoricalAttributes[attribute] = value; + } + }); + return contextualAttributes; +} + +export function ensureActionsWithContextualAttributes( + actions: BanditActions, +): Record { + let result: Record = {}; + if (Array.isArray(actions)) { + // no context + actions.forEach((action) => { + result[action] = { numericAttributes: {}, categoricalAttributes: {} }; + }); + } else if (!Object.values(actions).every(isInstanceOfContextualAttributes)) { + // Actions have non-contextual attributes; bucket based on number or not + Object.entries(actions).forEach(([action, attributes]) => { + result[action] = deduceAttributeContext(attributes); + }); + } else { + // Actions already have contextual attributes + result = actions as Record; + } + return result; +} diff --git a/src/client/eppo-client.ts b/src/client/eppo-client.ts index 1425db99..ac685922 100644 --- a/src/client/eppo-client.ts +++ b/src/client/eppo-client.ts @@ -3,6 +3,11 @@ import { v4 as randomUUID } from 'uuid'; import ApiEndpoints from '../api-endpoints'; import { logger } from '../application-logger'; import { IAssignmentEvent, IAssignmentLogger } from '../assignment-logger'; +import { + ensureActionsWithContextualAttributes, + ensureContextualSubjectAttributes, + ensureNonContextualSubjectAttributes, +} from '../attributes'; import { BanditEvaluator } from '../bandit-evaluator'; import { IBanditEvent, IBanditLogger } from '../bandit-logger'; import { AssignmentCache } from '../cache/abstract-assignment-cache'; @@ -535,9 +540,8 @@ export default class EppoClient { if (banditKey) { const banditParameters = this.banditModelConfigurationStore?.get(banditKey); if (banditParameters) { - const contextualSubjectAttributes = - this.ensureContextualSubjectAttributes(subjectAttributes); - const actionsWithContextualAttributes = this.ensureActionsWithContextualAttributes(actions); + const contextualSubjectAttributes = ensureContextualSubjectAttributes(subjectAttributes); + const actionsWithContextualAttributes = ensureActionsWithContextualAttributes(actions); result = this.banditEvaluator.evaluateBestBanditAction( contextualSubjectAttributes, @@ -571,7 +575,7 @@ export default class EppoClient { // Get the assigned variation for the flag with a possible bandit // Note for getting assignments, we don't care about context const nonContextualSubjectAttributes = - this.ensureNonContextualSubjectAttributes(subjectAttributes); + ensureNonContextualSubjectAttributes(subjectAttributes); const { variation: assignedVariation, evaluationDetails: assignmentEvaluationDetails } = this.getStringAssignmentDetails( flagKey, @@ -683,8 +687,8 @@ export default class EppoClient { } const banditModelData = banditParameters.modelData; - const contextualSubjectAttributes = this.ensureContextualSubjectAttributes(subjectAttributes); - const actionsWithContextualAttributes = this.ensureActionsWithContextualAttributes(actions); + const contextualSubjectAttributes = ensureContextualSubjectAttributes(subjectAttributes); + const actionsWithContextualAttributes = ensureActionsWithContextualAttributes(actions); const banditEvaluation = this.banditEvaluator.evaluateBandit( flagKey, subjectKey, @@ -715,79 +719,6 @@ export default class EppoClient { return action; } - private ensureNonContextualSubjectAttributes( - subjectAttributes: BanditSubjectAttributes, - ): Attributes { - let result: Attributes; - if (this.isInstanceOfContextualAttributes(subjectAttributes)) { - const contextualSubjectAttributes = subjectAttributes as ContextAttributes; - result = { - ...contextualSubjectAttributes.numericAttributes, - ...contextualSubjectAttributes.categoricalAttributes, - }; - } else { - // Attributes are non-contextual - result = subjectAttributes as Attributes; - } - return result; - } - - private ensureContextualSubjectAttributes( - subjectAttributes: BanditSubjectAttributes, - ): ContextAttributes { - if (this.isInstanceOfContextualAttributes(subjectAttributes)) { - return subjectAttributes as ContextAttributes; - } else { - return this.deduceAttributeContext(subjectAttributes as Attributes); - } - } - - private ensureActionsWithContextualAttributes( - actions: BanditActions, - ): Record { - let result: Record = {}; - if (Array.isArray(actions)) { - // no context - actions.forEach((action) => { - result[action] = { numericAttributes: {}, categoricalAttributes: {} }; - }); - } else if (!Object.values(actions).every(this.isInstanceOfContextualAttributes)) { - // Actions have non-contextual attributes; bucket based on number or not - Object.entries(actions).forEach(([action, attributes]) => { - result[action] = this.deduceAttributeContext(attributes); - }); - } else { - // Actions already have contextual attributes - result = actions as Record; - } - return result; - } - - private isInstanceOfContextualAttributes(attributes: unknown): boolean { - return Boolean( - typeof attributes === 'object' && - attributes && // exclude null - 'numericAttributes' in attributes && - 'categoricalAttributes' in attributes, - ); - } - - private deduceAttributeContext(attributes: Attributes): ContextAttributes { - const contextualAttributes: ContextAttributes = { - numericAttributes: {}, - categoricalAttributes: {}, - }; - Object.entries(attributes).forEach(([attribute, value]) => { - const isNumeric = typeof value === 'number' && isFinite(value); - if (isNumeric) { - contextualAttributes.numericAttributes[attribute] = value; - } else { - contextualAttributes.categoricalAttributes[attribute] = value as AttributeType; - } - }); - return contextualAttributes; - } - private logBanditAction(banditEvent: IBanditEvent): void { // First we check if this bandit action has been logged before const subjectKey = banditEvent.subject; @@ -944,8 +875,8 @@ export default class EppoClient { ): string { const configDetails = this.getConfigDetails(); - const subjectContextualAttributes = this.ensureContextualSubjectAttributes(subjectAttributes); - const subjectFlatAttributes = this.ensureNonContextualSubjectAttributes(subjectAttributes); + const subjectContextualAttributes = ensureContextualSubjectAttributes(subjectAttributes); + const subjectFlatAttributes = ensureNonContextualSubjectAttributes(subjectAttributes); const flags = this.getAllAssignments(subjectKey, subjectFlatAttributes); const precomputedConfig: IPrecomputedConfiguration = obfuscated @@ -1095,6 +1026,7 @@ export default class EppoClient { configPublishedAt: this.flagConfigurationStore.getConfigPublishedAt() ?? '', configEnvironment: this.flagConfigurationStore.getEnvironment() ?? { name: '' }, configFormat: this.flagConfigurationStore.getFormat() ?? '', + salt: this.flagConfigurationStore.salt, }; } diff --git a/src/client/eppo-precomputed-client.spec.ts b/src/client/eppo-precomputed-client.spec.ts index 39ba32d1..329735ab 100644 --- a/src/client/eppo-precomputed-client.spec.ts +++ b/src/client/eppo-precomputed-client.spec.ts @@ -1,78 +1,55 @@ import * as td from 'testdouble'; +import { + MOCK_PRECOMPUTED_WIRE_FILE, + readMockConfigurationWireResponse, +} from '../../test/testHelpers'; import ApiEndpoints from '../api-endpoints'; +import { logger } from '../application-logger'; import { IAssignmentLogger } from '../assignment-logger'; +import { + ensureContextualSubjectAttributes, + ensureNonContextualSubjectAttributes, +} from '../attributes'; +import { IPrecomputedConfigurationResponse } from '../configuration'; import { IConfigurationStore } from '../configuration-store/configuration-store'; import { MemoryOnlyConfigurationStore } from '../configuration-store/memory.store'; import { DEFAULT_POLL_INTERVAL_MS, MAX_EVENT_QUEUE_SIZE, POLL_JITTER_PCT } from '../constants'; import FetchHttpClient from '../http-client'; import { FormatEnum, PrecomputedFlag, VariationType } from '../interfaces'; -import { encodeBase64, getMD5Hash } from '../obfuscation'; +import { decodeBase64, encodeBase64, getMD5Hash } from '../obfuscation'; import PrecomputedRequestor from '../precomputed-requestor'; import EppoPrecomputedClient, { PrecomputedFlagsRequestParameters, + Subject, } from './eppo-precomputed-client'; describe('EppoPrecomputedClient E2E test', () => { - const precomputedFlags = { - createdAt: '2024-11-18T14:23:39.456Z', - format: 'PRECOMPUTED', - environment: { - name: 'Test', - }, - flags: { - 'string-flag': { - allocationKey: 'allocation-123', - variationKey: 'variation-123', - variationType: 'STRING', - variationValue: 'red', - extraLogging: {}, - doLog: true, - }, - 'boolean-flag': { - allocationKey: 'allocation-124', - variationKey: 'variation-124', - variationType: 'BOOLEAN', - variationValue: true, - extraLogging: {}, - doLog: true, - }, - 'integer-flag': { - allocationKey: 'allocation-125', - variationKey: 'variation-125', - variationType: 'INTEGER', - variationValue: 42, - extraLogging: {}, - doLog: true, - }, - 'numeric-flag': { - allocationKey: 'allocation-126', - variationKey: 'variation-126', - variationType: 'NUMERIC', - variationValue: 3.14, - extraLogging: {}, - doLog: true, - }, - 'json-flag': { - allocationKey: 'allocation-127', - variationKey: 'variation-127', - variationType: 'JSON', - variationValue: '{"key": "value", "number": 123}', - extraLogging: {}, - doLog: true, - }, - }, - }; // TODO: readMockPrecomputedFlagsResponse(MOCK_PRECOMPUTED_FLAGS_RESPONSE_FILE); + const precomputedConfigurationWire = readMockConfigurationWireResponse( + MOCK_PRECOMPUTED_WIRE_FILE, + ); + const unparsedPrecomputedResponse = JSON.parse(precomputedConfigurationWire).precomputed.response; + const precomputedResponse: IPrecomputedConfigurationResponse = JSON.parse( + unparsedPrecomputedResponse, + ); global.fetch = jest.fn(() => { return Promise.resolve({ ok: true, status: 200, - json: () => Promise.resolve(precomputedFlags), + json: () => Promise.resolve(precomputedResponse), }); }) as jest.Mock; - const storage = new MemoryOnlyConfigurationStore(); + let storage = new MemoryOnlyConfigurationStore(); + const subject: Subject = { + subjectKey: 'test-subject', + subjectAttributes: { attr1: 'value1' }, + }; + beforeEach(async () => { + storage = new MemoryOnlyConfigurationStore(); + storage.setFormat(FormatEnum.PRECOMPUTED); + }); beforeAll(async () => { const apiEndpoints = new ApiEndpoints({ @@ -84,18 +61,27 @@ describe('EppoPrecomputedClient E2E test', () => { }, }); const httpClient = new FetchHttpClient(apiEndpoints, 1000); - const precomputedFlagRequestor = new PrecomputedRequestor(httpClient, storage, 'subject-key', { - 'attribute-key': 'attribute-value', - }); + const precomputedFlagRequestor = new PrecomputedRequestor( + httpClient, + storage, + 'subject-key', + ensureContextualSubjectAttributes({ + 'attribute-key': 'attribute-value', + }), + ); await precomputedFlagRequestor.fetchAndStorePrecomputedFlags(); }); const precomputedFlagKey = 'mock-flag'; + const hashedPrecomputedFlagKey = getMD5Hash(precomputedFlagKey); + const hashedFlag2 = getMD5Hash('flag-2'); + const hashedFlag3 = getMD5Hash('flag-3'); + const mockPrecomputedFlag: PrecomputedFlag = { - flagKey: precomputedFlagKey, - variationKey: 'a', - variationValue: 'variation-a', - allocationKey: 'allocation-a', + flagKey: hashedPrecomputedFlagKey, + variationKey: encodeBase64('a'), + variationValue: encodeBase64('variation-a'), + allocationKey: encodeBase64('allocation-a'), doLog: true, variationType: VariationType.STRING, extraLogging: {}, @@ -105,8 +91,8 @@ describe('EppoPrecomputedClient E2E test', () => { let client: EppoPrecomputedClient; beforeAll(() => { - storage.setEntries({ [precomputedFlagKey]: mockPrecomputedFlag }); - client = new EppoPrecomputedClient(storage); + storage.setEntries({ [hashedPrecomputedFlagKey]: mockPrecomputedFlag }); + client = new EppoPrecomputedClient({ precomputedFlagStore: storage, subject }); }); afterAll(() => { @@ -119,26 +105,37 @@ describe('EppoPrecomputedClient E2E test', () => { }); describe('setLogger', () => { + let flagStorage: IConfigurationStore; + let subject: Subject; beforeAll(() => { - storage.setEntries({ [precomputedFlagKey]: mockPrecomputedFlag }); + flagStorage = new MemoryOnlyConfigurationStore(); + flagStorage.setEntries({ [hashedPrecomputedFlagKey]: mockPrecomputedFlag }); + subject = { + subjectKey: 'test-subject', + subjectAttributes: { attr1: 'value1' }, + }; }); it('Invokes logger for queued events', () => { const mockLogger = td.object(); - const client = new EppoPrecomputedClient(storage); + const client = new EppoPrecomputedClient({ + precomputedFlagStore: flagStorage, + subject, + }); client.getStringAssignment(precomputedFlagKey, 'default-value'); client.setAssignmentLogger(mockLogger); expect(td.explain(mockLogger.logAssignment).callCount).toEqual(1); - // Subject not available because PrecomputedFlagsRequestParameters were not provided - expect(td.explain(mockLogger.logAssignment).calls[0].args[0].subject).toEqual(''); }); it('Does not log same queued event twice', () => { const mockLogger = td.object(); - const client = new EppoPrecomputedClient(storage); + const client = new EppoPrecomputedClient({ + precomputedFlagStore: flagStorage, + subject, + }); client.getStringAssignment(precomputedFlagKey, 'default-value'); client.setAssignmentLogger(mockLogger); @@ -149,7 +146,10 @@ describe('EppoPrecomputedClient E2E test', () => { it('Does not invoke logger for events that exceed queue size', () => { const mockLogger = td.object(); - const client = new EppoPrecomputedClient(storage); + const client = new EppoPrecomputedClient({ + precomputedFlagStore: flagStorage, + subject, + }); for (let i = 0; i < MAX_EVENT_QUEUE_SIZE + 100; i++) { client.getStringAssignment(precomputedFlagKey, 'default-value'); @@ -160,7 +160,10 @@ describe('EppoPrecomputedClient E2E test', () => { }); it('returns null if getStringAssignment was called for the subject before any precomputed flags were loaded', () => { - const localClient = new EppoPrecomputedClient(new MemoryOnlyConfigurationStore()); + const localClient = new EppoPrecomputedClient({ + precomputedFlagStore: new MemoryOnlyConfigurationStore(), + subject, + }); expect(localClient.getStringAssignment(precomputedFlagKey, 'hello world')).toEqual( 'hello world', ); @@ -168,15 +171,21 @@ describe('EppoPrecomputedClient E2E test', () => { }); it('returns default value when key does not exist', async () => { - const client = new EppoPrecomputedClient(storage); + const client = new EppoPrecomputedClient({ + precomputedFlagStore: storage, + subject, + }); const nonExistentFlag = 'non-existent-flag'; expect(client.getStringAssignment(nonExistentFlag, 'default')).toBe('default'); }); it('logs variation assignment with correct metadata', () => { const mockLogger = td.object(); - storage.setEntries({ [precomputedFlagKey]: mockPrecomputedFlag }); - const client = new EppoPrecomputedClient(storage); + storage.setEntries({ [hashedPrecomputedFlagKey]: mockPrecomputedFlag }); + const client = new EppoPrecomputedClient({ + precomputedFlagStore: storage, + subject, + }); client.setAssignmentLogger(mockLogger); client.getStringAssignment(precomputedFlagKey, 'default'); @@ -185,10 +194,10 @@ describe('EppoPrecomputedClient E2E test', () => { const loggedEvent = td.explain(mockLogger.logAssignment).calls[0].args[0]; expect(loggedEvent.featureFlag).toEqual(precomputedFlagKey); - expect(loggedEvent.variation).toEqual(mockPrecomputedFlag.variationKey); - expect(loggedEvent.allocation).toEqual(mockPrecomputedFlag.allocationKey); + expect(loggedEvent.variation).toEqual(decodeBase64(mockPrecomputedFlag.variationKey ?? '')); + expect(loggedEvent.allocation).toEqual(decodeBase64(mockPrecomputedFlag.allocationKey ?? '')); expect(loggedEvent.experiment).toEqual( - `${precomputedFlagKey}-${mockPrecomputedFlag.allocationKey}`, + `${precomputedFlagKey}-${decodeBase64(mockPrecomputedFlag.allocationKey ?? '')}`, ); }); @@ -196,8 +205,11 @@ describe('EppoPrecomputedClient E2E test', () => { const mockLogger = td.object(); td.when(mockLogger.logAssignment(td.matchers.anything())).thenThrow(new Error('logging error')); - storage.setEntries({ [precomputedFlagKey]: mockPrecomputedFlag }); - const client = new EppoPrecomputedClient(storage); + storage.setEntries({ [hashedPrecomputedFlagKey]: mockPrecomputedFlag }); + const client = new EppoPrecomputedClient({ + precomputedFlagStore: storage, + subject, + }); client.setAssignmentLogger(mockLogger); const assignment = client.getStringAssignment(precomputedFlagKey, 'default'); @@ -211,8 +223,11 @@ describe('EppoPrecomputedClient E2E test', () => { beforeEach(() => { mockLogger = td.object(); - storage.setEntries({ [precomputedFlagKey]: mockPrecomputedFlag }); - client = new EppoPrecomputedClient(storage); + storage.setEntries({ [hashedPrecomputedFlagKey]: mockPrecomputedFlag }); + client = new EppoPrecomputedClient({ + precomputedFlagStore: storage, + subject, + }); client.setAssignmentLogger(mockLogger); }); @@ -236,14 +251,14 @@ describe('EppoPrecomputedClient E2E test', () => { it('logs assignment again after the lru cache is full', async () => { await storage.setEntries({ - [precomputedFlagKey]: mockPrecomputedFlag, - 'flag-2': { + [hashedPrecomputedFlagKey]: mockPrecomputedFlag, + [hashedFlag2]: { ...mockPrecomputedFlag, - variationKey: 'b', + variationKey: encodeBase64('b'), }, - 'flag-3': { + [hashedFlag3]: { ...mockPrecomputedFlag, - variationKey: 'c', + variationKey: encodeBase64('c'), }, }); @@ -279,9 +294,9 @@ describe('EppoPrecomputedClient E2E test', () => { it('logs for each unique flag', async () => { await storage.setEntries({ - [precomputedFlagKey]: mockPrecomputedFlag, - 'flag-2': mockPrecomputedFlag, - 'flag-3': mockPrecomputedFlag, + [hashedPrecomputedFlagKey]: mockPrecomputedFlag, + [hashedFlag2]: mockPrecomputedFlag, + [hashedFlag3]: mockPrecomputedFlag, }); client.useNonExpiringInMemoryAssignmentCache(); @@ -303,19 +318,19 @@ describe('EppoPrecomputedClient E2E test', () => { client.useNonExpiringInMemoryAssignmentCache(); storage.setEntries({ - [precomputedFlagKey]: { + [hashedPrecomputedFlagKey]: { ...mockPrecomputedFlag, - variationKey: 'a', - variationValue: 'variation-a', + variationKey: encodeBase64('a'), + variationValue: encodeBase64('variation-a'), }, }); client.getStringAssignment(precomputedFlagKey, 'default'); storage.setEntries({ - [precomputedFlagKey]: { + [hashedPrecomputedFlagKey]: { ...mockPrecomputedFlag, - variationKey: 'b', - variationValue: 'variation-b', + variationKey: encodeBase64('b'), + variationValue: encodeBase64('variation-b'), }, }); client.getStringAssignment(precomputedFlagKey, 'default'); @@ -326,18 +341,18 @@ describe('EppoPrecomputedClient E2E test', () => { client.useNonExpiringInMemoryAssignmentCache(); // original configuration version - storage.setEntries({ [precomputedFlagKey]: mockPrecomputedFlag }); + storage.setEntries({ [hashedPrecomputedFlagKey]: mockPrecomputedFlag }); client.getStringAssignment(precomputedFlagKey, 'default'); // log this assignment client.getStringAssignment(precomputedFlagKey, 'default'); // cache hit, don't log // change the variation storage.setEntries({ - [precomputedFlagKey]: { + [hashedPrecomputedFlagKey]: { ...mockPrecomputedFlag, - allocationKey: 'allocation-a', // same allocation key - variationKey: 'b', // but different variation - variationValue: 'variation-b', // but different variation + allocationKey: encodeBase64('allocation-a'), // same allocation key + variationKey: encodeBase64('b'), // but different variation + variationValue: encodeBase64('variation-b'), // but different variation }, }); @@ -345,18 +360,18 @@ describe('EppoPrecomputedClient E2E test', () => { client.getStringAssignment(precomputedFlagKey, 'default'); // cache hit, don't log // change the flag again, back to the original - storage.setEntries({ [precomputedFlagKey]: mockPrecomputedFlag }); + storage.setEntries({ [hashedPrecomputedFlagKey]: mockPrecomputedFlag }); client.getStringAssignment(precomputedFlagKey, 'default'); // important: log this assignment client.getStringAssignment(precomputedFlagKey, 'default'); // cache hit, don't log // change the allocation storage.setEntries({ - [precomputedFlagKey]: { + [hashedPrecomputedFlagKey]: { ...mockPrecomputedFlag, - allocationKey: 'allocation-b', // different allocation key - variationKey: 'b', // but same variation - variationValue: 'variation-b', // but same variation + allocationKey: encodeBase64('allocation-b'), // different allocation key + variationKey: encodeBase64('b'), // but same variation + variationValue: encodeBase64('variation-b'), // but same variation }, }); @@ -370,6 +385,7 @@ describe('EppoPrecomputedClient E2E test', () => { describe('Eppo Precomputed Client constructed with configuration request parameters', () => { let client: EppoPrecomputedClient; let precomputedFlagStore: IConfigurationStore; + let subject: Subject; let requestParameters: PrecomputedFlagsRequestParameters; const precomputedFlagKey = 'string-flag'; @@ -382,7 +398,7 @@ describe('EppoPrecomputedClient E2E test', () => { return Promise.resolve({ ok: true, status: 200, - json: () => Promise.resolve(precomputedFlags), + json: () => Promise.resolve(precomputedResponse), }); }) as jest.Mock; }); @@ -392,10 +408,11 @@ describe('EppoPrecomputedClient E2E test', () => { apiKey: 'dummy-key', sdkName: 'js-client-sdk-common', sdkVersion: '1.0.0', - precompute: { - subjectKey: 'test-subject', - subjectAttributes: { attr1: 'value1' }, - }, + }; + + subject = { + subjectKey: 'test-subject', + subjectAttributes: { attr1: 'value1' }, }; precomputedFlagStore = new MemoryOnlyConfigurationStore(); @@ -431,8 +448,11 @@ describe('EppoPrecomputedClient E2E test', () => { }); it('Fetches initial configuration with parameters in constructor', async () => { - client = new EppoPrecomputedClient(precomputedFlagStore); - client.setSubjectAndPrecomputedFlagsRequestParameters(requestParameters); + client = new EppoPrecomputedClient({ + precomputedFlagStore, + subject, + requestParameters, + }); // no configuration loaded let variation = client.getStringAssignment(precomputedFlagKey, 'default'); expect(variation).toBe('default'); @@ -443,8 +463,11 @@ describe('EppoPrecomputedClient E2E test', () => { }); it('Fetches initial configuration with parameters provided later', async () => { - client = new EppoPrecomputedClient(precomputedFlagStore); - client.setSubjectAndPrecomputedFlagsRequestParameters(requestParameters); + client = new EppoPrecomputedClient({ + precomputedFlagStore, + subject, + requestParameters, + }); // no configuration loaded let variation = client.getStringAssignment(precomputedFlagKey, 'default'); expect(variation).toBe('default'); @@ -464,10 +487,13 @@ describe('EppoPrecomputedClient E2E test', () => { } } - client = new EppoPrecomputedClient(new MockStore()); - client.setSubjectAndPrecomputedFlagsRequestParameters({ - ...requestParameters, - pollAfterSuccessfulInitialization: true, + client = new EppoPrecomputedClient({ + precomputedFlagStore: new MockStore(), + subject, + requestParameters: { + ...requestParameters, + pollAfterSuccessfulInitialization: true, + }, }); // no configuration loaded let variation = client.getStringAssignment(precomputedFlagKey, 'default'); @@ -493,9 +519,11 @@ describe('EppoPrecomputedClient E2E test', () => { return false; } } - - client = new EppoPrecomputedClient(new MockStore()); - client.setSubjectAndPrecomputedFlagsRequestParameters(requestParameters); + client = new EppoPrecomputedClient({ + precomputedFlagStore: new MockStore(), + subject, + requestParameters, + }); // no configuration loaded let variation = client.getStringAssignment(precomputedFlagKey, 'default'); expect(variation).toBe('default'); @@ -509,8 +537,11 @@ describe('EppoPrecomputedClient E2E test', () => { let client: EppoPrecomputedClient; beforeEach(async () => { - client = new EppoPrecomputedClient(storage); - client.setSubjectAndPrecomputedFlagsRequestParameters(requestParameters); + client = new EppoPrecomputedClient({ + precomputedFlagStore: storage, + subject, + requestParameters, + }); await client.fetchPrecomputedFlags(); }); @@ -570,7 +601,7 @@ describe('EppoPrecomputedClient E2E test', () => { ok: true, status: 200, json: () => { - return precomputedFlags; + return precomputedResponse; }, }); } @@ -581,8 +612,11 @@ describe('EppoPrecomputedClient E2E test', () => { ...requestParameters, pollAfterSuccessfulInitialization, }; - client = new EppoPrecomputedClient(precomputedFlagStore); - client.setSubjectAndPrecomputedFlagsRequestParameters(requestParameters); + client = new EppoPrecomputedClient({ + precomputedFlagStore: precomputedFlagStore, + requestParameters, + subject, + }); // no configuration loaded let variation = client.getStringAssignment(precomputedFlagKey, 'default'); expect(variation).toBe('default'); @@ -629,7 +663,7 @@ describe('EppoPrecomputedClient E2E test', () => { return Promise.resolve({ ok: true, status: 200, - json: () => Promise.resolve(precomputedFlags), + json: () => Promise.resolve(precomputedResponse), } as Response); } }); @@ -646,8 +680,11 @@ describe('EppoPrecomputedClient E2E test', () => { throwOnFailedInitialization, pollAfterFailedInitialization, }; - client = new EppoPrecomputedClient(precomputedFlagStore); - client.setSubjectAndPrecomputedFlagsRequestParameters(requestParameters); + client = new EppoPrecomputedClient({ + precomputedFlagStore: precomputedFlagStore, + subject, + requestParameters, + }); // no configuration loaded expect(client.getStringAssignment(precomputedFlagKey, 'default')).toBe('default'); @@ -673,36 +710,52 @@ describe('EppoPrecomputedClient E2E test', () => { }); describe('Obfuscated precomputed flags', () => { - let client: EppoPrecomputedClient; + let precomputedFlagStore: IConfigurationStore; + beforeEach(() => { + precomputedFlagStore = new MemoryOnlyConfigurationStore(); + }); - beforeAll(() => { - storage.setEntries({ - [getMD5Hash(precomputedFlagKey)]: { + it('returns decoded variation value', () => { + const salt = 'NaCl'; + const saltedAndHashedFlagKey = getMD5Hash(precomputedFlagKey, salt); + + precomputedFlagStore.setEntries({ + [saltedAndHashedFlagKey]: { ...mockPrecomputedFlag, - allocationKey: encodeBase64(mockPrecomputedFlag.allocationKey), - variationKey: encodeBase64(mockPrecomputedFlag.variationKey), + allocationKey: encodeBase64(mockPrecomputedFlag.allocationKey ?? ''), + variationKey: encodeBase64(mockPrecomputedFlag.variationKey ?? ''), variationValue: encodeBase64(mockPrecomputedFlag.variationValue), extraLogging: {}, }, }); - client = new EppoPrecomputedClient(storage, true); - }); + precomputedFlagStore.salt = salt; - afterAll(() => { - td.reset(); - }); + const requestParameters: PrecomputedFlagsRequestParameters = { + apiKey: 'DUMMY_API_KEY', + sdkName: 'js-precomputed-test', + sdkVersion: '100.0.1', + }; + + const client = new EppoPrecomputedClient({ + precomputedFlagStore, + subject, + }); - it('returns decoded variation value', () => { expect(client.getStringAssignment(precomputedFlagKey, 'default')).toBe( mockPrecomputedFlag.variationValue, ); + + td.reset(); }); }); it('logs variation assignment with format from precomputed flags response', () => { const mockLogger = td.object(); - storage.setEntries({ [precomputedFlagKey]: mockPrecomputedFlag }); - const client = new EppoPrecomputedClient(storage); + storage.setEntries({ [hashedPrecomputedFlagKey]: mockPrecomputedFlag }); + const client = new EppoPrecomputedClient({ + precomputedFlagStore: storage, + subject, + }); client.setAssignmentLogger(mockLogger); client.getStringAssignment(precomputedFlagKey, 'default'); @@ -721,43 +774,88 @@ describe('EppoPrecomputedClient E2E test', () => { beforeEach(() => { store = new MemoryOnlyConfigurationStore(); mockLogger = td.object(); - client = new EppoPrecomputedClient(store); - client.setAssignmentLogger(mockLogger); }); - it('returns default value and does not log when store is not initialized', () => { - client.setSubjectAndPrecomputedFlagStore('test-subject', {}, store); - expect(client.getStringAssignment('test-flag', 'default')).toBe('default'); - expect(td.explain(mockLogger.logAssignment).callCount).toEqual(0); + it('prints errors if initialized with a store that is not initialized and without requestParameters', () => { + const loggerErrorSpy = jest.spyOn(logger, 'error'); + expect(() => { + client = new EppoPrecomputedClient({ + precomputedFlagStore: store, + subject, + }); + }).not.toThrow(); + expect(loggerErrorSpy).toHaveBeenCalledTimes(2); + expect(loggerErrorSpy).toHaveBeenCalledWith( + '[Eppo SDK] EppoPrecomputedClient requires an initialized precomputedFlagStore if requestParameters are not provided', + ); + expect(loggerErrorSpy).toHaveBeenCalledWith( + '[Eppo SDK] EppoPrecomputedClient requires a precomputedFlagStore with a salt if requestParameters are not provided', + ); + loggerErrorSpy.mockRestore(); + expect(client.getStringAssignment('string-flag', 'default')).toBe('default'); + }); + + it('prints only one error if initialized with a store without a salt and without requestParameters', async () => { + const loggerErrorSpy = jest.spyOn(logger, 'error'); + await store.setEntries({ + 'test-flag': { + flagKey: 'test-flag', + variationType: VariationType.STRING, + variationKey: encodeBase64('control'), + variationValue: encodeBase64('test-value'), + allocationKey: encodeBase64('allocation-1'), + doLog: true, + extraLogging: {}, + }, + }); + expect(() => { + client = new EppoPrecomputedClient({ + precomputedFlagStore: store, + subject, + }); + }).not.toThrow(); + expect(loggerErrorSpy).toHaveBeenCalledTimes(1); + expect(loggerErrorSpy).toHaveBeenCalledWith( + '[Eppo SDK] EppoPrecomputedClient requires a precomputedFlagStore with a salt if requestParameters are not provided', + ); + loggerErrorSpy.mockRestore(); + expect(client.getStringAssignment('string-flag', 'default')).toBe('default'); }); it('returns assignment and logs subject data after store is initialized with flags', async () => { const subjectKey = 'test-subject'; - const subjectAttributes = { attr1: 'value1' }; + const subjectAttributes = ensureContextualSubjectAttributes({ attr1: 'value1' }); + store.salt = 'test-salt'; + const hashedFlagKey = getMD5Hash('test-flag', store.salt); await store.setEntries({ - 'test-flag': { - flagKey: precomputedFlagKey, + [hashedFlagKey]: { + flagKey: hashedFlagKey, variationType: VariationType.STRING, - variationKey: 'control', - variationValue: 'test-value', - allocationKey: 'allocation-1', + variationKey: encodeBase64('control'), + variationValue: encodeBase64('test-value'), + allocationKey: encodeBase64('allocation-1'), doLog: true, extraLogging: {}, }, }); - client.setSubjectAndPrecomputedFlagStore(subjectKey, subjectAttributes, store); + + client = new EppoPrecomputedClient({ + precomputedFlagStore: store, + subject, + }); + client.setAssignmentLogger(mockLogger); + expect(client.getStringAssignment('test-flag', 'default')).toBe('test-value'); expect(td.explain(mockLogger.logAssignment).callCount).toEqual(1); const loggedEvent = td.explain(mockLogger.logAssignment).calls[0].args[0]; expect(loggedEvent.subject).toEqual(subjectKey); - expect(loggedEvent.subjectAttributes).toEqual(subjectAttributes); - }); - it('returns default value and does not log when subject data is not set', () => { - expect(client.getStringAssignment('test-flag', 'default')).toBe('default'); - expect(td.explain(mockLogger.logAssignment).callCount).toEqual(0); + // Convert the ContextAttributes to a flat attribute map + expect(loggedEvent.subjectAttributes).toEqual( + ensureNonContextualSubjectAttributes(subjectAttributes), + ); }); }); }); diff --git a/src/client/eppo-precomputed-client.ts b/src/client/eppo-precomputed-client.ts index e6772076..be621008 100644 --- a/src/client/eppo-precomputed-client.ts +++ b/src/client/eppo-precomputed-client.ts @@ -1,6 +1,10 @@ import ApiEndpoints from '../api-endpoints'; import { logger } from '../application-logger'; import { IAssignmentEvent, IAssignmentLogger } from '../assignment-logger'; +import { + ensureContextualSubjectAttributes, + ensureNonContextualSubjectAttributes, +} from '../attributes'; import { AssignmentCache } from '../cache/abstract-assignment-cache'; import { LRUInMemoryAssignmentCache } from '../cache/lru-in-memory-assignment-cache'; import { NonExpiringInMemoryAssignmentCache } from '../cache/non-expiring-in-memory-cache-assignment'; @@ -16,23 +20,26 @@ import { import { decodePrecomputedFlag } from '../decoding'; import { FlagEvaluationWithoutDetails } from '../evaluator'; import FetchHttpClient from '../http-client'; -import { PrecomputedFlag, VariationType } from '../interfaces'; +import { DecodedPrecomputedFlag, PrecomputedFlag, VariationType } from '../interfaces'; import { getMD5Hash } from '../obfuscation'; import initPoller, { IPoller } from '../poller'; import PrecomputedRequestor from '../precomputed-requestor'; -import { Attributes } from '../types'; +import { Attributes, ContextAttributes } from '../types'; import { validateNotBlank } from '../validation'; import { LIB_VERSION } from '../version'; +import { checkTypeMatch } from './eppo-client'; + +export interface Subject { + subjectKey: string; + subjectAttributes: Attributes | ContextAttributes; +} + export type PrecomputedFlagsRequestParameters = { apiKey: string; sdkVersion: string; sdkName: string; baseUrl?: string; - precompute: { - subjectKey: string; - subjectAttributes: Attributes; - }; requestTimeoutMs?: number; pollingIntervalMs?: number; numInitialRequestRetries?: number; @@ -43,44 +50,51 @@ export type PrecomputedFlagsRequestParameters = { skipInitialPoll?: boolean; }; +interface EppoPrecomputedClientOptions { + precomputedFlagStore: IConfigurationStore; + subject: Subject; + requestParameters?: PrecomputedFlagsRequestParameters; +} + export default class EppoPrecomputedClient { private readonly queuedAssignmentEvents: IAssignmentEvent[] = []; private assignmentLogger?: IAssignmentLogger; private assignmentCache?: AssignmentCache; private requestPoller?: IPoller; - private precomputedFlagsRequestParameters?: PrecomputedFlagsRequestParameters; - private subjectKey?: string; - private subjectAttributes?: Attributes; - - constructor( - private precomputedFlagStore: IConfigurationStore, - private isObfuscated = false, - ) {} - - public setPrecomputedFlagsRequestParameters( - precomputedFlagsRequestParameters: PrecomputedFlagsRequestParameters, - ) { - this.precomputedFlagsRequestParameters = precomputedFlagsRequestParameters; - } - - public setSubjectAndPrecomputedFlagsRequestParameters( - precomputedFlagsRequestParameters: PrecomputedFlagsRequestParameters, - ) { - this.setPrecomputedFlagsRequestParameters(precomputedFlagsRequestParameters); - this.subjectKey = precomputedFlagsRequestParameters.precompute.subjectKey; - this.subjectAttributes = precomputedFlagsRequestParameters.precompute.subjectAttributes; - } - - public setPrecomputedFlagStore(precomputedFlagStore: IConfigurationStore) { - this.precomputedFlagStore = precomputedFlagStore; - } + private requestParameters?: PrecomputedFlagsRequestParameters; + private subject: { + subjectKey: string; + subjectAttributes: ContextAttributes; + }; + private precomputedFlagStore: IConfigurationStore; - public setIsObfuscated(isObfuscated: boolean) { - this.isObfuscated = isObfuscated; + public constructor(options: EppoPrecomputedClientOptions) { + this.precomputedFlagStore = options.precomputedFlagStore; + const { subjectKey, subjectAttributes } = options.subject; + this.subject = { + subjectKey, + subjectAttributes: ensureContextualSubjectAttributes(subjectAttributes), + }; + if (options.requestParameters) { + // Online-mode + this.requestParameters = options.requestParameters; + } else { + // Offline-mode + if (!this.precomputedFlagStore.isInitialized()) { + logger.error( + '[Eppo SDK] EppoPrecomputedClient requires an initialized precomputedFlagStore if requestParameters are not provided', + ); + } + if (!this.precomputedFlagStore.salt) { + logger.error( + '[Eppo SDK] EppoPrecomputedClient requires a precomputedFlagStore with a salt if requestParameters are not provided', + ); + } + } } public async fetchPrecomputedFlags() { - if (!this.precomputedFlagsRequestParameters) { + if (!this.requestParameters) { throw new Error('Eppo SDK unable to fetch precomputed flags without the request parameters'); } // if fetchFlagConfigurations() was previously called, stop any polling process from that call @@ -91,7 +105,6 @@ export default class EppoPrecomputedClient { sdkName, sdkVersion, baseUrl, // Default is set before passing to ApiEndpoints constructor if undefined - precompute: { subjectKey, subjectAttributes }, requestTimeoutMs = DEFAULT_REQUEST_TIMEOUT_MS, numInitialRequestRetries = DEFAULT_INITIAL_CONFIG_REQUEST_RETRIES, numPollRequestRetries = DEFAULT_POLL_CONFIG_REQUEST_RETRIES, @@ -99,9 +112,10 @@ export default class EppoPrecomputedClient { pollAfterFailedInitialization = false, throwOnFailedInitialization = false, skipInitialPoll = false, - } = this.precomputedFlagsRequestParameters; + } = this.requestParameters; + const { subjectKey, subjectAttributes } = this.subject; - let { pollingIntervalMs = DEFAULT_POLL_INTERVAL_MS } = this.precomputedFlagsRequestParameters; + let { pollingIntervalMs = DEFAULT_POLL_INTERVAL_MS } = this.requestParameters; if (pollingIntervalMs <= 0) { logger.error('pollingIntervalMs must be greater than 0. Using default'); pollingIntervalMs = DEFAULT_POLL_INTERVAL_MS; @@ -144,19 +158,6 @@ export default class EppoPrecomputedClient { } } - public setSubjectAndPrecomputedFlagStore( - subjectKey: string, - subjectAttributes: Attributes, - precomputedFlagStore: IConfigurationStore, - ) { - // Save the new subject data and precomputed flag store together because they are related - // Stop any polling process if it exists from previous subject data to protect consistency - this.requestPoller?.stop(); - this.setPrecomputedFlagStore(precomputedFlagStore); - this.subjectKey = subjectKey; - this.subjectAttributes = subjectAttributes; - } - private getPrecomputedAssignment( flagKey: string, defaultValue: T, @@ -165,33 +166,32 @@ export default class EppoPrecomputedClient { ): T { validateNotBlank(flagKey, 'Invalid argument: flagKey cannot be blank'); - const preComputedFlag = this.getPrecomputedFlag(flagKey); + const precomputedFlag = this.getPrecomputedFlag(flagKey); - if (preComputedFlag == null) { + if (precomputedFlag == null) { logger.warn(`[Eppo SDK] No assigned variation. Flag not found: ${flagKey}`); return defaultValue; } - // Check variation type - if (preComputedFlag.variationType !== expectedType) { - logger.error( - `[Eppo SDK] Type mismatch: expected ${expectedType} but flag ${flagKey} has type ${preComputedFlag.variationType}`, - ); + // Add type checking before proceeding + if (!checkTypeMatch(expectedType, precomputedFlag.variationType)) { + const errorMessage = `[Eppo SDK] Type mismatch: expected ${expectedType} but flag ${flagKey} has type ${precomputedFlag.variationType}`; + logger.error(errorMessage); return defaultValue; } const result: FlagEvaluationWithoutDetails = { flagKey, format: this.precomputedFlagStore.getFormat() ?? '', - subjectKey: this.subjectKey ?? '', - subjectAttributes: this.subjectAttributes ?? {}, + subjectKey: this.subject.subjectKey ?? '', + subjectAttributes: ensureNonContextualSubjectAttributes(this.subject.subjectAttributes ?? {}), variation: { - key: preComputedFlag.variationKey, - value: preComputedFlag.variationValue, + key: precomputedFlag.variationKey ?? '', + value: precomputedFlag.variationValue, }, - allocationKey: preComputedFlag.allocationKey, - extraLogging: preComputedFlag.extraLogging, - doLog: preComputedFlag.doLog, + allocationKey: precomputedFlag.allocationKey ?? '', + extraLogging: precomputedFlag.extraLogging ?? {}, + doLog: precomputedFlag.doLog, }; try { @@ -274,15 +274,15 @@ export default class EppoPrecomputedClient { ); } - private getPrecomputedFlag(flagKey: string): PrecomputedFlag | null { - return this.isObfuscated - ? this.getObfuscatedFlag(flagKey) - : this.precomputedFlagStore.get(flagKey); + private getPrecomputedFlag(flagKey: string): DecodedPrecomputedFlag | null { + return this.getObfuscatedFlag(flagKey); } - private getObfuscatedFlag(flagKey: string): PrecomputedFlag | null { + private getObfuscatedFlag(flagKey: string): DecodedPrecomputedFlag | null { + const salt = this.precomputedFlagStore.salt; + const saltedAndHashedFlagKey = getMD5Hash(flagKey, salt); const precomputedFlag: PrecomputedFlag | null = this.precomputedFlagStore.get( - getMD5Hash(flagKey), + saltedAndHashedFlagKey, ) as PrecomputedFlag; return precomputedFlag ? decodePrecomputedFlag(precomputedFlag) : null; } @@ -384,7 +384,7 @@ export default class EppoPrecomputedClient { private buildLoggerMetadata(): Record { return { - obfuscated: this.isObfuscated, + obfuscated: true, sdkLanguage: 'javascript', sdkLibVersion: LIB_VERSION, }; diff --git a/src/configuration-store/configuration-store-utils.ts b/src/configuration-store/configuration-store-utils.ts index a6307f97..55eea4ea 100644 --- a/src/configuration-store/configuration-store-utils.ts +++ b/src/configuration-store/configuration-store-utils.ts @@ -17,6 +17,7 @@ export async function hydrateConfigurationStore( environment: Environment; createdAt: string; format: string; + salt?: string; }, ): Promise { if (configurationStore) { @@ -26,6 +27,7 @@ export async function hydrateConfigurationStore( configurationStore.setConfigFetchedAt(new Date().toISOString()); configurationStore.setConfigPublishedAt(response.createdAt); configurationStore.setFormat(response.format); + configurationStore.salt = response.salt; } } } diff --git a/src/configuration-store/configuration-store.ts b/src/configuration-store/configuration-store.ts index 9b3fe2c1..ff43a617 100644 --- a/src/configuration-store/configuration-store.ts +++ b/src/configuration-store/configuration-store.ts @@ -38,6 +38,7 @@ export interface IConfigurationStore { setConfigPublishedAt(configPublishedAt: string): void; getFormat(): string | null; setFormat(format: string): void; + salt?: string; } export interface ISyncStore { diff --git a/src/configuration-store/memory.store.ts b/src/configuration-store/memory.store.ts index af1d3a6f..6f298240 100644 --- a/src/configuration-store/memory.store.ts +++ b/src/configuration-store/memory.store.ts @@ -35,6 +35,8 @@ export class MemoryOnlyConfigurationStore implements IConfigurationStore { private configPublishedAt: string | null = null; private environment: Environment | null = null; private format: FormatEnum | null = null; + salt?: string; + init(): Promise { this.initialized = true; return Promise.resolve(); diff --git a/src/decoding.ts b/src/decoding.ts index 47251229..fdb43e71 100644 --- a/src/decoding.ts +++ b/src/decoding.ts @@ -10,6 +10,7 @@ import { Shard, ObfuscatedSplit, PrecomputedFlag, + DecodedPrecomputedFlag, } from './interfaces'; import { decodeBase64 } from './obfuscation'; @@ -78,12 +79,12 @@ export function decodeObject(obj: Record): Record; + extraLogging?: Record; doLog: boolean; +}; + +type Base64 = string; + +export interface PrecomputedFlag extends BasePrecomputedFlag { + variationValue: Base64; +} + +export interface DecodedPrecomputedFlag extends BasePrecomputedFlag { + variationValue: Variation['value']; } export interface PrecomputedFlagsDetails { @@ -166,5 +176,5 @@ export interface PrecomputedFlagsDetails { export interface PrecomputedFlagsPayload { subject_key: string; - subject_attributes: Attributes; + subject_attributes: ContextAttributes; } diff --git a/src/obfuscation.ts b/src/obfuscation.ts index 8bcff7a4..496bb9a1 100644 --- a/src/obfuscation.ts +++ b/src/obfuscation.ts @@ -26,7 +26,7 @@ export function obfuscatePrecomputedFlags( // Encode extraLogging keys and values. const encodedExtraLogging = Object.fromEntries( - Object.entries(assignment.extraLogging).map((kvArr) => kvArr.map(encodeBase64)), + Object.entries(assignment.extraLogging ?? {}).map((kvArr) => kvArr.map(encodeBase64)), ); const hashedKey = getMD5Hash(flagKey, salt); @@ -35,8 +35,8 @@ export function obfuscatePrecomputedFlags( variationType: assignment.variationType, extraLogging: encodedExtraLogging, doLog: assignment.doLog, - allocationKey: encodeBase64(assignment.allocationKey), - variationKey: encodeBase64(assignment.variationKey), + allocationKey: encodeBase64(assignment.allocationKey ?? ''), + variationKey: encodeBase64(assignment.variationKey ?? ''), variationValue: encodeBase64(assignment.variationValue), }; }); diff --git a/src/precomputed-requestor.spec.ts b/src/precomputed-requestor.spec.ts index 1f258a7d..e2b1f047 100644 --- a/src/precomputed-requestor.spec.ts +++ b/src/precomputed-requestor.spec.ts @@ -1,4 +1,5 @@ import ApiEndpoints from './api-endpoints'; +import { ensureContextualSubjectAttributes } from './attributes'; import { IConfigurationStore } from './configuration-store/configuration-store'; import { MemoryOnlyConfigurationStore } from './configuration-store/memory.store'; import FetchHttpClient, { IHttpClient } from './http-client'; @@ -52,9 +53,9 @@ describe('PrecomputedRequestor', () => { httpClient, precomputedFlagStore, 'subject-key', - { + ensureContextualSubjectAttributes({ 'attribute-key': 'attribute-value', - }, + }), ); fetchSpy = jest.fn(() => { diff --git a/src/precomputed-requestor.ts b/src/precomputed-requestor.ts index cbd7f6d6..406c4dae 100644 --- a/src/precomputed-requestor.ts +++ b/src/precomputed-requestor.ts @@ -2,7 +2,7 @@ import { IConfigurationStore } from './configuration-store/configuration-store'; import { hydrateConfigurationStore } from './configuration-store/configuration-store-utils'; import { IHttpClient } from './http-client'; import { PrecomputedFlag, UNKNOWN_ENVIRONMENT_NAME } from './interfaces'; -import { Attributes } from './types'; +import { ContextAttributes } from './types'; // Requests AND stores precomputed flags, reuses the configuration store export default class PrecomputedFlagRequestor { @@ -10,7 +10,7 @@ export default class PrecomputedFlagRequestor { private readonly httpClient: IHttpClient, private readonly precomputedFlagStore: IConfigurationStore, private readonly subjectKey: string, - private readonly subjectAttributes: Attributes, + private readonly subjectAttributes: ContextAttributes, ) {} async fetchAndStorePrecomputedFlags(): Promise { @@ -19,7 +19,7 @@ export default class PrecomputedFlagRequestor { subject_attributes: this.subjectAttributes, }); - if (!precomputedResponse?.flags) { + if (!precomputedResponse) { return; } @@ -28,6 +28,7 @@ export default class PrecomputedFlagRequestor { environment: precomputedResponse.environment ?? { name: UNKNOWN_ENVIRONMENT_NAME }, createdAt: precomputedResponse.createdAt, format: precomputedResponse.format, + salt: precomputedResponse.salt, }); } } diff --git a/test/testHelpers.ts b/test/testHelpers.ts index ebf49798..9ac7e02e 100644 --- a/test/testHelpers.ts +++ b/test/testHelpers.ts @@ -14,6 +14,12 @@ export const MOCK_UFC_RESPONSE_FILE = `${MOCK_UFC_FILENAME}.json`; export const MOCK_FLAGS_WITH_BANDITS_RESPONSE_FILE = `bandit-flags-v1.json`; export const MOCK_BANDIT_MODELS_RESPONSE_FILE = `bandit-models-v1.json`; export const OBFUSCATED_MOCK_UFC_RESPONSE_FILE = `${MOCK_UFC_FILENAME}-obfuscated.json`; + +const TEST_CONFIGURATION_WIRE_DATA_DIR = './test/data/configuration-wire/'; +const MOCK_PRECOMPUTED_FILENAME = 'precomputed-v1'; +export const MOCK_PRECOMPUTED_WIRE_FILE = `${MOCK_PRECOMPUTED_FILENAME}.json`; +export const MOCK_DEOBFUSCATED_PRECOMPUTED_RESPONSE_FILE = `${MOCK_PRECOMPUTED_WIRE_FILE}-deobfuscated.json`; + export interface SubjectTestCase { subjectKey: string; subjectAttributes: Record; @@ -51,6 +57,10 @@ export function readMockUFCResponse( return JSON.parse(fs.readFileSync(TEST_DATA_DIR + filename, 'utf-8')); } +export function readMockConfigurationWireResponse(filename: string): string { + return fs.readFileSync(TEST_CONFIGURATION_WIRE_DATA_DIR + filename, 'utf-8'); +} + export function testCasesByFileName(testDirectory: string): Record { const testCasesWithFileName: Array = fs .readdirSync(testDirectory)