From 35de7b0d0072a4ea500db3d7fbfe82dfcc8e621c Mon Sep 17 00:00:00 2001 From: Steven Zhang Date: Wed, 22 Apr 2026 16:16:39 -0500 Subject: [PATCH] fix: migrate anonymous context namespace to general namespace --- .../__tests__/context/ensureKey.test.ts | 37 +++++++++ .../storage/getOrGenerateKey.test.ts | 76 +++++++++++++++++++ .../sdk-client/src/context/ensureKey.ts | 10 ++- .../src/storage/getOrGenerateKey.ts | 28 ++++++- .../sdk-client/src/storage/namespaceUtils.ts | 9 +-- 5 files changed, 147 insertions(+), 13 deletions(-) diff --git a/packages/shared/sdk-client/__tests__/context/ensureKey.test.ts b/packages/shared/sdk-client/__tests__/context/ensureKey.test.ts index 1140ee962c..2c210bb12b 100644 --- a/packages/shared/sdk-client/__tests__/context/ensureKey.test.ts +++ b/packages/shared/sdk-client/__tests__/context/ensureKey.test.ts @@ -102,4 +102,41 @@ describe('ensureKey', () => { const c = await ensureKey(context, mockPlatform); expect(c.key).toEqual('random1'); }); + + it('should migrate anonymous key from legacy namespace', async () => { + const stored: Record = { + LaunchDarkly_AnonymousKeys_org: 'migrated-key', + }; + (mockPlatform.storage.get as jest.Mock).mockImplementation( + (key: string) => stored[key] ?? null, + ); + (mockPlatform.storage.set as jest.Mock).mockImplementation((key: string, value: string) => { + stored[key] = value; + }); + (mockPlatform.storage.clear as jest.Mock).mockImplementation((key: string) => { + delete stored[key]; + }); + + const context: LDContext = { kind: 'org', anonymous: true }; + const c = await ensureKey(context, mockPlatform); + + expect(c.key).toEqual('migrated-key'); + expect(mockPlatform.storage.set).toHaveBeenCalledWith( + 'LaunchDarkly_ContextKeys_org', + 'migrated-key', + ); + expect(mockPlatform.storage.clear).toHaveBeenCalledWith('LaunchDarkly_AnonymousKeys_org'); + }); + + it('should use new namespace key when it already exists', async () => { + (mockPlatform.storage.get as jest.Mock).mockImplementation((key: string) => + key === 'LaunchDarkly_ContextKeys_org' ? 'new-ns-key' : undefined, + ); + + const context: LDContext = { kind: 'org', anonymous: true }; + const c = await ensureKey(context, mockPlatform); + + expect(c.key).toEqual('new-ns-key'); + expect(mockPlatform.storage.clear).not.toHaveBeenCalled(); + }); }); diff --git a/packages/shared/sdk-client/__tests__/storage/getOrGenerateKey.test.ts b/packages/shared/sdk-client/__tests__/storage/getOrGenerateKey.test.ts index 589f4e585b..4a28f3be6a 100644 --- a/packages/shared/sdk-client/__tests__/storage/getOrGenerateKey.test.ts +++ b/packages/shared/sdk-client/__tests__/storage/getOrGenerateKey.test.ts @@ -89,4 +89,80 @@ describe('getOrGenerateKey', () => { expect(k).toEqual('test-org-key-2'); }); }); + + describe('legacy key migration', () => { + it('migrates key from legacy location to new location', async () => { + const stored: Record = { + LaunchDarkly_AnonymousKeys_org: 'existing-org-key', + }; + (storage.get as jest.Mock).mockImplementation((key: string) => stored[key] ?? null); + (storage.set as jest.Mock).mockImplementation((key: string, value: string) => { + stored[key] = value; + }); + (storage.clear as jest.Mock).mockImplementation((key: string) => { + delete stored[key]; + }); + + const k = await getOrGenerateKey( + 'LaunchDarkly_ContextKeys_org', + mockPlatform, + 'LaunchDarkly_AnonymousKeys_org', + ); + + expect(k).toEqual('existing-org-key'); + expect(crypto.randomUUID).not.toHaveBeenCalled(); + expect(storage.set).toHaveBeenCalledWith('LaunchDarkly_ContextKeys_org', 'existing-org-key'); + expect(storage.clear).toHaveBeenCalledWith('LaunchDarkly_AnonymousKeys_org'); + }); + + it('does not clear legacy key when set silently fails', async () => { + (storage.get as jest.Mock).mockImplementation((key: string) => + key === 'LaunchDarkly_AnonymousKeys_org' ? 'existing-org-key' : null, + ); + // set is a no-op, simulating a silent storage failure + (storage.set as jest.Mock).mockResolvedValue(undefined); + + const k = await getOrGenerateKey( + 'LaunchDarkly_ContextKeys_org', + mockPlatform, + 'LaunchDarkly_AnonymousKeys_org', + ); + + expect(k).toEqual('existing-org-key'); + expect(storage.set).toHaveBeenCalledWith('LaunchDarkly_ContextKeys_org', 'existing-org-key'); + expect(storage.clear).not.toHaveBeenCalled(); + }); + + it('does not check legacy key when new key already exists', async () => { + (storage.get as jest.Mock).mockImplementation((key: string) => + key === 'LaunchDarkly_ContextKeys_org' ? 'new-org-key' : 'legacy-org-key', + ); + + const k = await getOrGenerateKey( + 'LaunchDarkly_ContextKeys_org', + mockPlatform, + 'LaunchDarkly_AnonymousKeys_org', + ); + + expect(k).toEqual('new-org-key'); + expect(storage.get).toHaveBeenCalledTimes(1); + expect(storage.get).toHaveBeenCalledWith('LaunchDarkly_ContextKeys_org'); + expect(storage.clear).not.toHaveBeenCalled(); + }); + + it('generates new key when neither new nor legacy key exists', async () => { + (storage.get as jest.Mock).mockResolvedValue(null); + + const k = await getOrGenerateKey( + 'LaunchDarkly_ContextKeys_org', + mockPlatform, + 'LaunchDarkly_AnonymousKeys_org', + ); + + expect(k).toEqual('test-org-key-1'); + expect(crypto.randomUUID).toHaveBeenCalled(); + expect(storage.set).toHaveBeenCalledWith('LaunchDarkly_ContextKeys_org', 'test-org-key-1'); + expect(storage.clear).not.toHaveBeenCalled(); + }); + }); }); diff --git a/packages/shared/sdk-client/src/context/ensureKey.ts b/packages/shared/sdk-client/src/context/ensureKey.ts index 9de8d27c10..86de0b6a32 100644 --- a/packages/shared/sdk-client/src/context/ensureKey.ts +++ b/packages/shared/sdk-client/src/context/ensureKey.ts @@ -10,7 +10,10 @@ import { import type { LDContext, LDContextStrict } from '../api/LDContext'; import { getOrGenerateKey } from '../storage/getOrGenerateKey'; -import { namespaceForAnonymousGeneratedContextKey } from '../storage/namespaceUtils'; +import { + namespaceForAnonymousGeneratedContextKey, + namespaceForGeneratedContextKey, +} from '../storage/namespaceUtils'; const { isLegacyUser, isMultiKind, isSingleKind } = internal; @@ -31,10 +34,11 @@ const ensureKeyCommon = async (kind: string, c: LDContextCommon, platform: Platf const { anonymous, key } = c; if (anonymous && !key) { - const storageKey = await namespaceForAnonymousGeneratedContextKey(kind); + const storageKey = await namespaceForGeneratedContextKey(kind); + const legacyStorageKey = await namespaceForAnonymousGeneratedContextKey(kind); // 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(storageKey, platform); + c.key = await getOrGenerateKey(storageKey, platform, legacyStorageKey); } }; diff --git a/packages/shared/sdk-client/src/storage/getOrGenerateKey.ts b/packages/shared/sdk-client/src/storage/getOrGenerateKey.ts index f193f99c01..ea864a9d02 100644 --- a/packages/shared/sdk-client/src/storage/getOrGenerateKey.ts +++ b/packages/shared/sdk-client/src/storage/getOrGenerateKey.ts @@ -9,14 +9,34 @@ import { namespaceForGeneratedContextKey } from './namespaceUtils'; * @param storageKey keyed storage location where the generated key should live. See {@link namespaceForGeneratedContextKey} * for related exmaples of generating a storage key and usage. * @param platform crypto and storage implementations for necessary operations + * @param legacyStorageKey optional legacy storage key to migrate from. If the key is not found + * under {@link storageKey} but exists under this legacy key, it will be migrated to the new + * location and the legacy key will be cleared. * @returns the generated key */ -export const getOrGenerateKey = async (storageKey: string, { crypto, storage }: Platform) => { +export const getOrGenerateKey = async ( + storageKey: string, + { crypto, storage }: Platform, + legacyStorageKey?: string, +) => { let generatedKey = await storage?.get(storageKey); - if (!generatedKey) { - generatedKey = crypto.randomUUID(); - await storage?.set(storageKey, generatedKey); + if (generatedKey == null) { + if (legacyStorageKey) { + generatedKey = await storage?.get(legacyStorageKey); + if (generatedKey != null) { + await storage?.set(storageKey, generatedKey); + const verified = await storage?.get(storageKey); + if (verified != null) { + await storage?.clear(legacyStorageKey); + } + } + } + + if (generatedKey == null) { + generatedKey = crypto.randomUUID(); + await storage?.set(storageKey, generatedKey); + } } return generatedKey; diff --git a/packages/shared/sdk-client/src/storage/namespaceUtils.ts b/packages/shared/sdk-client/src/storage/namespaceUtils.ts index e5a28123c4..599f15c599 100644 --- a/packages/shared/sdk-client/src/storage/namespaceUtils.ts +++ b/packages/shared/sdk-client/src/storage/namespaceUtils.ts @@ -28,12 +28,9 @@ export async function namespaceForEnvironment(crypto: Crypto, sdkKey: string): P } /** - * @deprecated prefer {@link namespaceForGeneratedContextKey}. At one time we only generated keys for - * anonymous contexts and they were namespaced in LaunchDarkly_AnonymousKeys. Eventually we started - * generating context keys for non-anonymous contexts such as for the Auto Environment Attributes - * feature and those were namespaced in LaunchDarkly_ContextKeys. This function can be removed - * when the data under the LaunchDarkly_AnonymousKeys namespace is merged with data under the - * LaunchDarkly_ContextKeys namespace. + * @deprecated Used only for migration in ensureKey. Data stored under LaunchDarkly_AnonymousKeys + * is now migrated to LaunchDarkly_ContextKeys on first access. This function can be removed once + * all clients have had the chance to run the migration. */ export async function namespaceForAnonymousGeneratedContextKey(kind: string): Promise { return concatNamespacesAndValues([