diff --git a/packages/shared/common/src/Context.ts b/packages/shared/common/src/Context.ts index d371aaaf1..60560a32e 100644 --- a/packages/shared/common/src/Context.ts +++ b/packages/shared/common/src/Context.ts @@ -1,8 +1,14 @@ /* eslint-disable no-underscore-dangle */ // eslint-disable-next-line max-classes-per-file -import { LDContextCommon, LDMultiKindContext, LDSingleKindContext, LDUser } from './api/context'; -import { LDContext } from './api/context/LDContext'; +import type { + LDContext, + LDContextCommon, + LDMultiKindContext, + LDSingleKindContext, + LDUser, +} from './api'; import AttributeReference from './AttributeReference'; +import { isLegacyUser, isMultiKind, isSingleKind } from './internal/context'; import { TypeValidators } from './validators'; // The general strategy for the context is to transform the passed in context @@ -38,39 +44,6 @@ function encodeKey(key: string): string { return key; } -/** - * Check if a context is a single kind context. - * @param context - * @returns true if the context is a single kind context. - */ -function isSingleKind(context: LDContext): context is LDSingleKindContext { - if ('kind' in context) { - return TypeValidators.String.is(context.kind) && context.kind !== 'multi'; - } - return false; -} - -/** - * Check if a context is a multi-kind context. - * @param context - * @returns true if it is a multi-kind context. - */ -function isMultiKind(context: LDContext): context is LDMultiKindContext { - if ('kind' in context) { - return TypeValidators.String.is(context.kind) && context.kind === 'multi'; - } - return false; -} - -/** - * Check if a context is a legacy user context. - * @param context - * @returns true if it is a legacy user context. - */ -function isLegacyUser(context: LDContext): context is LDUser { - return !('kind' in context) || context.kind === null || context.kind === undefined; -} - /** * Check if the given value is a LDContextCommon. * @param kindOrContext diff --git a/packages/shared/common/src/internal/context/index.ts b/packages/shared/common/src/internal/context/index.ts new file mode 100644 index 000000000..f8d2ddd4e --- /dev/null +++ b/packages/shared/common/src/internal/context/index.ts @@ -0,0 +1,39 @@ +/** + * Internal use only. These functions should only be used as part of the initial validation of + * the LDContext object. Thereafter, the Context object should be used. + */ +import type { LDContext, LDMultiKindContext, LDSingleKindContext, LDUser } from '../../api'; +import { TypeValidators } from '../../validators'; + +/** + * Check if a context is a single kind context. + * @param context + * @returns true if the context is a single kind context. + */ +export function isSingleKind(context: LDContext): context is LDSingleKindContext { + if ('kind' in context) { + return TypeValidators.String.is(context.kind) && context.kind !== 'multi'; + } + return false; +} + +/** + * Check if a context is a multi-kind context. + * @param context + * @returns true if it is a multi-kind context. + */ +export function isMultiKind(context: LDContext): context is LDMultiKindContext { + if ('kind' in context) { + return TypeValidators.String.is(context.kind) && context.kind === 'multi'; + } + return false; +} + +/** + * Check if a context is a legacy user context. + * @param context + * @returns true if it is a legacy user context. + */ +export function isLegacyUser(context: LDContext): context is LDUser { + return !('kind' in context) || context.kind === null || context.kind === undefined; +} diff --git a/packages/shared/common/src/internal/diagnostics/DiagnosticsManager.test.ts b/packages/shared/common/src/internal/diagnostics/DiagnosticsManager.test.ts index 2816dde10..c275484aa 100644 --- a/packages/shared/common/src/internal/diagnostics/DiagnosticsManager.test.ts +++ b/packages/shared/common/src/internal/diagnostics/DiagnosticsManager.test.ts @@ -16,6 +16,7 @@ describe('given a diagnostics manager', () => { }); beforeEach(() => { + basicPlatform.crypto.randomUUID.mockReturnValueOnce('random1').mockReturnValueOnce('random2'); manager = new DiagnosticsManager('my-sdk-key', basicPlatform, { test1: 'value1' }); }); diff --git a/packages/shared/common/src/internal/index.ts b/packages/shared/common/src/internal/index.ts index db6af8042..ae8a03362 100644 --- a/packages/shared/common/src/internal/index.ts +++ b/packages/shared/common/src/internal/index.ts @@ -2,3 +2,4 @@ export * from './diagnostics'; export * from './evaluation'; export * from './events'; export * from './stream'; +export * from './context'; diff --git a/packages/shared/mocks/src/hasher.ts b/packages/shared/mocks/src/hasher.ts index 0d3b363de..999f8fbee 100644 --- a/packages/shared/mocks/src/hasher.ts +++ b/packages/shared/mocks/src/hasher.ts @@ -16,10 +16,10 @@ export const crypto: Crypto = { // Not used for this test. throw new Error(`Function not implemented.${algorithm}${key}`); }, - randomUUID(): string { + randomUUID: jest.fn(() => { counter += 1; // Will provide a unique value for tests. // Very much not a UUID of course. return `${counter}`; - }, + }), }; diff --git a/packages/shared/sdk-client/src/LDClientImpl.test.ts b/packages/shared/sdk-client/src/LDClientImpl.test.ts index bbc396a0b..57524bf9c 100644 --- a/packages/shared/sdk-client/src/LDClientImpl.test.ts +++ b/packages/shared/sdk-client/src/LDClientImpl.test.ts @@ -19,6 +19,7 @@ jest.mock('@launchdarkly/js-sdk-common', () => { }; }); +const { crypto } = basicPlatform; const testSdkKey = 'test-sdk-key'; const context: LDContext = { kind: 'org', key: 'Testy Pizza' }; let ldc: LDClientImpl; @@ -28,6 +29,7 @@ describe('sdk-client object', () => { beforeEach(() => { defaultPutResponse = clone(mockResponseJson); setupMockStreamingProcessor(false, defaultPutResponse); + crypto.randomUUID.mockReturnValueOnce('random1'); ldc = new LDClientImpl(testSdkKey, basicPlatform, { logger, sendEvents: false }); jest @@ -133,6 +135,20 @@ describe('sdk-client object', () => { }); }); + test('identify anonymous', async () => { + defaultPutResponse['dev-test-flag'].value = false; + const carContext: LDContext = { kind: 'car', anonymous: true, key: '' }; + + await ldc.identify(carContext); + const c = ldc.getContext(); + const all = ldc.allFlags(); + + expect(c!.key).toEqual('random1'); + expect(all).toMatchObject({ + 'dev-test-flag': false, + }); + }); + test('identify error invalid context', async () => { // @ts-ignore const carContext: LDContext = { kind: 'car', key: undefined }; diff --git a/packages/shared/sdk-client/src/LDClientImpl.ts b/packages/shared/sdk-client/src/LDClientImpl.ts index cfa3a7179..7f76ab946 100644 --- a/packages/shared/sdk-client/src/LDClientImpl.ts +++ b/packages/shared/sdk-client/src/LDClientImpl.ts @@ -24,7 +24,7 @@ import createDiagnosticsManager from './diagnostics/createDiagnosticsManager'; import createEventProcessor from './events/createEventProcessor'; import EventFactory from './events/EventFactory'; import { DeleteFlag, Flags, PatchFlag } from './types'; -import { calculateFlagChanges } from './utils'; +import { calculateFlagChanges, ensureKey } from './utils'; const { createErrorEvaluationDetail, createSuccessEvaluationDetail, ClientMessages, ErrorKinds } = internal; @@ -231,7 +231,9 @@ export default class LDClientImpl implements LDClient { } // TODO: implement secure mode - async identify(context: LDContext, _hash?: string): Promise { + async identify(pristineContext: LDContext, _hash?: string): Promise { + const context = await ensureKey(pristineContext, this.platform); + const checkedContext = Context.fromLDContext(context); if (!checkedContext.valid) { const error = new Error('Context was unspecified or had no key'); diff --git a/packages/shared/sdk-client/src/utils/calculateFlagChanges.ts b/packages/shared/sdk-client/src/utils/calculateFlagChanges.ts new file mode 100644 index 000000000..9b77370d3 --- /dev/null +++ b/packages/shared/sdk-client/src/utils/calculateFlagChanges.ts @@ -0,0 +1,25 @@ +import { fastDeepEqual } from '@launchdarkly/js-sdk-common'; + +import { Flags } from '../types'; + +// eslint-disable-next-line import/prefer-default-export +export default function calculateFlagChanges(flags: Flags, incomingFlags: Flags) { + const changedKeys: string[] = []; + + // flag deleted or updated + Object.entries(flags).forEach(([k, f]) => { + const incoming = incomingFlags[k]; + if (!incoming || !fastDeepEqual(f, incoming)) { + changedKeys.push(k); + } + }); + + // flag added + Object.keys(incomingFlags).forEach((k) => { + if (!flags[k]) { + changedKeys.push(k); + } + }); + + return changedKeys; +} diff --git a/packages/shared/sdk-client/src/utils/ensureKey.test.ts b/packages/shared/sdk-client/src/utils/ensureKey.test.ts new file mode 100644 index 000000000..141bd5a51 --- /dev/null +++ b/packages/shared/sdk-client/src/utils/ensureKey.test.ts @@ -0,0 +1,116 @@ +import type { + LDContext, + LDContextCommon, + LDMultiKindContext, + LDUser, +} from '@launchdarkly/js-sdk-common'; +import { basicPlatform } from '@launchdarkly/private-js-mocks'; + +import ensureKey, { addNamespace, getOrGenerateKey } from './ensureKey'; + +const { crypto, storage } = basicPlatform; +describe('ensureKey', () => { + beforeEach(() => { + crypto.randomUUID.mockReturnValueOnce('random1').mockReturnValueOnce('random2'); + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + test('addNamespace', async () => { + const nsKey = addNamespace('org'); + expect(nsKey).toEqual('LaunchDarkly_AnonKeys_org'); + }); + + test('getOrGenerateKey create new key', async () => { + const key = await getOrGenerateKey('org', basicPlatform); + + expect(key).toEqual('random1'); + expect(crypto.randomUUID).toHaveBeenCalled(); + expect(storage.get).toHaveBeenCalledWith('LaunchDarkly_AnonKeys_org'); + expect(storage.set).toHaveBeenCalledWith('LaunchDarkly_AnonKeys_org', 'random1'); + }); + + test('getOrGenerateKey existing key', async () => { + storage.get.mockImplementation((nsKind: string) => + nsKind === 'LaunchDarkly_AnonKeys_org' ? 'random1' : undefined, + ); + + const key = await getOrGenerateKey('org', basicPlatform); + + expect(key).toEqual('random1'); + expect(crypto.randomUUID).not.toHaveBeenCalled(); + expect(storage.get).toHaveBeenCalledWith('LaunchDarkly_AnonKeys_org'); + expect(storage.set).not.toHaveBeenCalled(); + }); + + test('ensureKey should not override anonymous key if specified', async () => { + const context: LDContext = { kind: 'org', anonymous: true, key: 'Testy Pizza' }; + const c = await ensureKey(context, basicPlatform); + + expect(c.key).toEqual('Testy Pizza'); + }); + + test('ensureKey non-anonymous single context should be unchanged', async () => { + const context: LDContext = { kind: 'org', key: 'Testy Pizza' }; + const c = await ensureKey(context, basicPlatform); + + expect(c.key).toEqual('Testy Pizza'); + expect(c.anonymous).toBeFalsy(); + }); + + test('ensureKey non-anonymous contexts in multi should be unchanged', async () => { + const context: LDContext = { + kind: 'multi', + user: { key: 'userKey' }, + org: { key: 'orgKey' }, + }; + + const c = (await ensureKey(context, basicPlatform)) as LDMultiKindContext; + + expect((c.user as LDContextCommon).key).toEqual('userKey'); + expect((c.org as LDContextCommon).key).toEqual('orgKey'); + }); + + test('ensureKey should create key for single anonymous context', async () => { + const context: LDContext = { kind: 'org', anonymous: true, key: '' }; + const c = await ensureKey(context, basicPlatform); + expect(c.key).toEqual('random1'); + }); + + test('ensureKey should create key for an anonymous context in multi', async () => { + const context: LDContext = { + kind: 'multi', + user: { anonymous: true, key: '' }, + org: { key: 'orgKey' }, + }; + + const c = (await ensureKey(context, basicPlatform)) as LDMultiKindContext; + + expect((c.user as LDContextCommon).key).toEqual('random1'); + expect((c.org as LDContextCommon).key).toEqual('orgKey'); + }); + + test('ensureKey should create key for all anonymous contexts in multi', async () => { + const context: LDContext = { + kind: 'multi', + user: { anonymous: true, key: '' }, + org: { anonymous: true, key: '' }, + }; + + const c = (await ensureKey(context, basicPlatform)) as LDMultiKindContext; + + expect((c.user as LDContextCommon).key).toEqual('random1'); + expect((c.org as LDContextCommon).key).toEqual('random2'); + }); + + test('ensureKey should create key for anonymous legacy user', async () => { + const context: LDUser = { + anonymous: true, + key: '', + }; + const c = await ensureKey(context, basicPlatform); + expect(c.key).toEqual('random1'); + }); +}); diff --git a/packages/shared/sdk-client/src/utils/ensureKey.ts b/packages/shared/sdk-client/src/utils/ensureKey.ts new file mode 100644 index 000000000..c293b42b5 --- /dev/null +++ b/packages/shared/sdk-client/src/utils/ensureKey.ts @@ -0,0 +1,87 @@ +import { + clone, + internal, + LDContext, + LDContextCommon, + LDMultiKindContext, + LDSingleKindContext, + LDUser, + Platform, +} from '@launchdarkly/js-sdk-common'; + +const { isLegacyUser, isMultiKind, isSingleKind } = internal; + +export const addNamespace = (s: string) => `LaunchDarkly_AnonKeys_${s}`; + +export const getOrGenerateKey = async (kind: string, { crypto, storage }: Platform) => { + const nsKind = addNamespace(kind); + let contextKey = await storage?.get(nsKind); + + if (!contextKey) { + contextKey = crypto.randomUUID(); + await storage?.set(nsKind, contextKey); + } + + return contextKey; +}; + +/** + * This is the root ensureKey function. All other ensureKey functions reduce to this. + * + * - ensureKeyCommon // private root function + * - ensureKeySingle + * - ensureKeyMulti + * - ensureKeyLegacy + * - ensureKey // exported for external use + * + * @param kind The LDContext kind + * @param c The LDContext object + * @param platform Platform containing crypto and storage needed for storing and querying keys. + */ +const ensureKeyCommon = async (kind: string, c: LDContextCommon, platform: Platform) => { + const { anonymous, key } = c; + + if (anonymous && !key) { + // This mutates a cloned copy of the original context from ensureyKey so this is safe. + // eslint-disable-next-line no-param-reassign + c.key = await getOrGenerateKey(kind, platform); + } +}; + +const ensureKeySingle = async (c: LDSingleKindContext, platform: Platform) => { + await ensureKeyCommon(c.kind, c, platform); +}; + +const ensureKeyMulti = async (multiContext: LDMultiKindContext, platform: Platform) => { + const { kind, ...singleContexts } = multiContext; + + return Promise.all( + Object.entries(singleContexts).map(([k, c]) => + ensureKeyCommon(k, c as LDContextCommon, platform), + ), + ); +}; + +const ensureKeyLegacy = async (c: LDUser, platform: Platform) => { + await ensureKeyCommon('user', c, platform); +}; + +const ensureKey = async (context: LDContext, platform: Platform) => { + const cloned = clone(context); + + if (isSingleKind(cloned)) { + await ensureKeySingle(cloned as LDSingleKindContext, platform); + } + + if (isMultiKind(cloned)) { + await ensureKeyMulti(cloned as LDMultiKindContext, platform); + } + + if (isLegacyUser(cloned)) { + await ensureKeyLegacy(cloned as LDUser, platform); + } + + return cloned; +}; + +export default ensureKey; diff --git a/packages/shared/sdk-client/src/utils/index.ts b/packages/shared/sdk-client/src/utils/index.ts index 9b443b96b..f203d0985 100644 --- a/packages/shared/sdk-client/src/utils/index.ts +++ b/packages/shared/sdk-client/src/utils/index.ts @@ -1,25 +1,4 @@ -import { fastDeepEqual } from '@launchdarkly/js-sdk-common'; +import calculateFlagChanges from './calculateFlagChanges'; +import ensureKey from './ensureKey'; -import { Flags } from '../types'; - -// eslint-disable-next-line import/prefer-default-export -export function calculateFlagChanges(flags: Flags, incomingFlags: Flags) { - const changedKeys: string[] = []; - - // flag deleted or updated - Object.entries(flags).forEach(([k, f]) => { - const incoming = incomingFlags[k]; - if (!incoming || !fastDeepEqual(f, incoming)) { - changedKeys.push(k); - } - }); - - // flag added - Object.keys(incomingFlags).forEach((k) => { - if (!flags[k]) { - changedKeys.push(k); - } - }); - - return changedKeys; -} +export { calculateFlagChanges, ensureKey };