diff --git a/packages/browser/README.md b/packages/browser/README.md index 1609a7b9..4aa285c3 100644 --- a/packages/browser/README.md +++ b/packages/browser/README.md @@ -147,6 +147,41 @@ If the RUM user changes after provider initialization, call preserving explicitly configured OpenFeature properties. Nested RUM user properties are not included in the evaluation context. +## Flag-key obfuscation + +Requests to the Datadog Precompute endpoint automatically send +`X-DD-FEATURE-FLAGS-CAPABILITIES: assignment-encoding-flag-key-256-v1`. +This declares support for the `flag-key-sha256-v1` response encoding. +Datadog controls its server rollout. +Requests through `flaggingProxy` do not send this header by default. Proxy owners +can opt in through `customHeaders`. The proxy must forward the header and, for +cross-origin requests, allow it in its CORS response before enabling it. +The provider accepts both plaintext and obfuscated responses without an +application configuration change. + +Continue evaluating the original flag key. The shared core hashes it with the +response's public salt before lookup. Flag values do not change. Evaluation +details, exposure events, and RUM annotations retain the original flag key. Portable +configuration and IndexedDB storage retain the descriptor with the assignments. +Unsupported or malformed encodings are rejected, not interpreted as plaintext. + +Encoded portable snapshots use wire version 2, which older readers reject. +Plaintext and rules-only snapshots keep version 1. New readers accept both versions. +This version applies to SDK serialization, not the Precompute API response. + +New IndexedDB writes use a separate cache key namespace for both response formats. +Older SDKs cannot read any entries in the new namespace, including plaintext. +New SDKs can read legacy plaintext +entries when the new namespace has no entry. A plaintext rollout rollback replaces +the encoded entry in the new namespace. It does not update an older SDK's cache. +A snapshot containing both encoded assignments and rules uses version 2. +Older readers reject that entire snapshot, including its rules. + +Obfuscation removes readable flag-map keys. It is not encryption, authorization, +or response signing. Values, variation names, allocation names, telemetry, +and application code can still reveal a feature's purpose. Do not put sensitive +information in client-facing variant values, including JSON objects. + ## Portable configuration parsing The default entry point supports precomputed configurations without including @@ -267,9 +302,19 @@ await tracking.shutdown() The application owns manually registered hooks: clearing client hooks or removing `DatadogCoreProvider` does not shut down their resources. Unregister them and call `tracking.shutdown()` when they are no longer needed. The regular `DatadogProvider` shuts down its own tracking resources through OpenFeature's provider lifecycle. -Exposure deduplication is tied to the active `DatadogCoreProvider` configuration, so replacing the provider configuration allows exposures for the new configuration to be emitted without clearing application-managed hook state. +Precomputed assignments retain the existing exposure-reset behavior for plaintext and obfuscated responses. +On a refresh, `DatadogProvider` clears exposure deduplication when a previously loaded `createdAt` changes. +It does not clear on the first fetch without an initial configuration. +`DatadogCoreProvider` includes the configuration identity in exposure deduplication. Replacing the configuration, +including a changed `createdAt` or obfuscation salt, permits another exposure. +Reapplying the same configuration preserves deduplication. Neither provider emits an exposure until the application evaluates a flag. +`createdAt` is a configuration timestamp, not an experiment revision. This behavior does not depend on it changing on every request. + +Exposure caches retain up to 50,000 entries per scope, matching the Node provider's limit. +The memory cache removes the least recently used entry. Persistent caches remove the oldest written entries. +An evicted entry can produce another exposure. Storage failures do not prevent flag evaluation. -Refetching identical content does not invalidate deduplication when only retrieval metadata (`fetchedAt` or `etag`) changes. For rules-based configurations, the server's `createdAt` build timestamp is also excluded from the identity, matching the backend's semantic fingerprint behavior. These fields remain available on the configuration and in its portable wire representation. +For rules-based configurations, `DatadogCoreProvider` also includes the rules configuration identity. Changed rules allow new exposures without clearing application-managed hook state. Retrieval metadata (`fetchedAt` and `etag`) and the server's `createdAt` build timestamp do not change that identity. These fields remain available on the configuration and in its portable wire representation. Both the standalone exposure hook and `DatadogProvider` scope persistent exposure caches by telemetry site, client token, proxy URL, environment, application, service, and source. Scope values are hashed into the storage namespace; raw tokens are not stored in cache keys. Recreating a hook with the same scope retains deduplication, while another destination can emit its own exposures. Older unscoped cache entries are not reused, so upgrading can produce a one-time repeat exposure. Function-valued telemetry proxies use memory-only deduplication because their destination cannot be inferred reliably from the callback's identity. diff --git a/packages/browser/src/cache/chrome-storage-assignment-cache.ts b/packages/browser/src/cache/chrome-storage-assignment-cache.ts index c318fc84..aea7f88c 100644 --- a/packages/browser/src/cache/chrome-storage-assignment-cache.ts +++ b/packages/browser/src/cache/chrome-storage-assignment-cache.ts @@ -1,3 +1,4 @@ +import { addTelemetryDebug } from '@datadog/browser-core' import { type AssignmentCacheEntry, assignmentCacheKeyToString, @@ -21,7 +22,9 @@ export default class ChromeStorageAssignmentCache implements BulkReadAssignmentC set(entry: AssignmentCacheEntry): void { // "fire-and-forget" - we intentionally don't wait for the promise to resolve // noinspection JSIgnoredPromiseFromCall - this.storage.set(assignmentCacheKeyToString(entry), assignmentCacheValueToString(entry)) + this.storage.set(assignmentCacheKeyToString(entry), assignmentCacheValueToString(entry)).catch(() => { + addTelemetryDebug('Failed to persist an exposure cache entry') + }) } // eslint-disable-next-line @typescript-eslint/no-unused-vars diff --git a/packages/browser/src/cache/chrome-storage-async-map.ts b/packages/browser/src/cache/chrome-storage-async-map.ts index e3ba8d98..4c952e12 100644 --- a/packages/browser/src/cache/chrome-storage-async-map.ts +++ b/packages/browser/src/cache/chrome-storage-async-map.ts @@ -1,8 +1,11 @@ import type { AsyncMap } from '@datadog/flagging-core' +import { MAX_EXPOSURE_CACHE_ENTRIES } from './constants' /** Chrome storage-backed {@link AsyncMap}. */ export default class ChromeStorageAsyncMap implements AsyncMap { private readonly prefix: string + private keys: Set | undefined + private pendingOperation = Promise.resolve() constructor( private readonly storage: chrome.storage.StorageArea, @@ -23,6 +26,18 @@ export default class ChromeStorageAsyncMap implements AsyncMap { } async entries(): Promise<{ [p: string]: T }> { + return this.run(async () => { + const entries = await this.readEntries() + this.keys = new Set(Object.keys(entries)) + await this.trim() + for (const key of Object.keys(entries)) { + if (!this.keys.has(key)) delete entries[key] + } + return entries + }) + } + + private async readEntries(): Promise> { const entries = await this.storage.get>(null) const scopedEntries: Record = Object.create(null) for (const [key, value] of Object.entries(entries)) { @@ -32,11 +47,44 @@ export default class ChromeStorageAsyncMap implements AsyncMap { } async set(key: string, value: T) { - await this.storage.set({ [this.prefix + key]: value }) + return this.run(async () => { + this.keys ??= new Set(Object.keys(await this.readEntries())) + this.keys.delete(key) + this.keys.add(key) + // Evict before writing so a full store has space for the new entry. + await this.trim() + await this.storage.set({ [this.prefix + key]: value }) + }) } async clear() { - const keys = Object.keys(await this.entries()).map((key) => this.prefix + key) - if (keys.length) await this.storage.remove(keys) + return this.run(async () => { + const keys = Object.keys(await this.readEntries()).map((key) => this.prefix + key) + if (keys.length) await this.storage.remove(keys) + this.keys = new Set() + }) + } + + private async trim(): Promise { + const keys = this.keys! + const removed: string[] = [] + for (const key of keys) { + if (keys.size - removed.length <= MAX_EXPOSURE_CACHE_ENTRIES) break + removed.push(key) + } + if (removed.length) { + await this.storage.remove(removed.map((key) => this.prefix + key)) + for (const key of removed) keys.delete(key) + } + } + + private run(operation: () => Promise): Promise { + // Serialize writes and eviction. A failed operation must not stop later writes. + const result = this.pendingOperation.then(operation) + this.pendingOperation = result.then( + () => undefined, + () => undefined + ) + return result } } diff --git a/packages/browser/src/cache/constants.ts b/packages/browser/src/cache/constants.ts new file mode 100644 index 00000000..22f9f42f --- /dev/null +++ b/packages/browser/src/cache/constants.ts @@ -0,0 +1,2 @@ +// Match the Node provider's exposure cache limit. +export const MAX_EXPOSURE_CACHE_ENTRIES = 50_000 diff --git a/packages/browser/src/cache/indexeddb-flags-cache.ts b/packages/browser/src/cache/indexeddb-flags-cache.ts index 09f633ff..a7c1ec06 100644 --- a/packages/browser/src/cache/indexeddb-flags-cache.ts +++ b/packages/browser/src/cache/indexeddb-flags-cache.ts @@ -38,14 +38,35 @@ export class IndexedDBFlagsCache { /** Read cached config for the given context. Returns undefined on miss or any error. */ async get(context: EvaluationContext): Promise { try { - const configKey = buildConfigKey(this.clientToken, context) + const legacyKey = buildConfigKey(this.clientToken, context) + const configKey = `v2-${legacyKey}` const db = await openDB() try { const config = await new Promise((resolve, reject) => { const tx = db.transaction(STORE_NAME, 'readonly') const store = tx.objectStore(STORE_NAME) const request = store.get(configKey) - request.onsuccess = () => resolve(request.result as FlagsConfiguration | undefined) + request.onsuccess = () => { + if (request.result !== undefined) { + resolve(request.result as FlagsConfiguration) + return + } + // Read older plaintext entries on upgrade. Never expose encoded + // snapshots written by a prerelease SDK through the legacy key. + const legacy = store.get(legacyKey) + legacy.onerror = () => reject(legacy.error) + legacy.onsuccess = () => { + const config = legacy.result as FlagsConfiguration | undefined + const attributes = config?.precomputed?.response?.data?.attributes + resolve( + attributes && + (attributes.obfuscated === undefined || attributes.obfuscated === false) && + attributes.obfuscation === undefined + ? config + : undefined + ) + } + } request.onerror = () => reject(request.error) }) if (!config || typeof config !== 'object') { @@ -62,7 +83,9 @@ export class IndexedDBFlagsCache { /** Fire-and-forget persist. Never throws. */ set(config: FlagsConfiguration, context: EvaluationContext): void { - const configKey = buildConfigKey(this.clientToken, context) + // One namespace for new writes keeps plaintext rollback and encoded + // refreshes in the same slot without changing what older SDKs can read. + const configKey = `v2-${buildConfigKey(this.clientToken, context)}` openDB() .then((db) => { const tx = db.transaction(STORE_NAME, 'readwrite') diff --git a/packages/browser/src/cache/local-storage-assignment-shim.ts b/packages/browser/src/cache/local-storage-assignment-shim.ts index 280367ff..70c9e8e6 100644 --- a/packages/browser/src/cache/local-storage-assignment-shim.ts +++ b/packages/browser/src/cache/local-storage-assignment-shim.ts @@ -1,4 +1,6 @@ // noinspection JSUnusedGlobalSymbols (methods are used by common repository) + +import { MAX_EXPOSURE_CACHE_ENTRIES } from './constants' import { hasWindowLocalStorage } from './helpers' export class LocalStorageAssignmentShim { @@ -49,12 +51,28 @@ export class LocalStorageAssignmentShim { } public set(key: string, value: string): this { - return this.setCache(this.getCache().set(key, value)) + const cache = this.getCache() + cache.delete(key) + cache.set(key, value) + this.trim(cache) + return this.setCache(cache) } private getCache(): Map { const cache = window.localStorage.getItem(this.localStorageKey) - return cache ? new Map(JSON.parse(cache)) : new Map() + const entries: Map = cache ? new Map(JSON.parse(cache)) : new Map() + if (entries.size > MAX_EXPOSURE_CACHE_ENTRIES) { + this.trim(entries) + this.setCache(entries) + } + return entries + } + + private trim(cache: Map): void { + for (const key of cache.keys()) { + if (cache.size <= MAX_EXPOSURE_CACHE_ENTRIES) break + cache.delete(key) + } } private setCache(cache: Map): this { diff --git a/packages/browser/src/cache/simple-assignment-cache.ts b/packages/browser/src/cache/simple-assignment-cache.ts index fb02f38d..bfcb0f9d 100644 --- a/packages/browser/src/cache/simple-assignment-cache.ts +++ b/packages/browser/src/cache/simple-assignment-cache.ts @@ -1,47 +1,25 @@ -import { - type AbstractAssignmentCache, - type AssignmentCacheEntry, - NonExpiringInMemoryAssignmentCache, -} from '@datadog/flagging-core' +import { LRUInMemoryAssignmentCache } from '@datadog/flagging-core' +import { MAX_EXPOSURE_CACHE_ENTRIES } from './constants' import type { BulkReadAssignmentCache, BulkWriteAssignmentCache } from './hybrid-assignment-cache' -/** An {@link BulkWriteAssignmentCache} assignment cache backed by an in-memory {@link Map} */ -export default class SimpleAssignmentCache implements BulkWriteAssignmentCache, BulkReadAssignmentCache { - private readonly store: Map - private readonly cache: AbstractAssignmentCache> - +/** A bounded in-memory exposure cache, also used to serve persisted entries. */ +export default class SimpleAssignmentCache + extends LRUInMemoryAssignmentCache + implements BulkWriteAssignmentCache, BulkReadAssignmentCache +{ constructor() { - this.store = new Map() - this.cache = new NonExpiringInMemoryAssignmentCache(this.store) - } - - init(): Promise { - return Promise.resolve() - } - - set(key: AssignmentCacheEntry): void { - this.cache.set(key) - } - - has(key: AssignmentCacheEntry): boolean { - return this.cache.has(key) + super(MAX_EXPOSURE_CACHE_ENTRIES) } setEntries(entries: [string, string][]): void { - const { store } = this - // it's important to call store.set() directly here because we want to set the raw entries into the cache, bypassing - // the AbstractAssignmentCache logic, which takes an AssignmentCacheKey instead. + // Load serialized entries through the same capacity limit as new exposures. entries.forEach(([key, value]) => { - store.set(key, value) + this.delegate.set(key, value) }) } getEntries(): Promise<[string, string][]> { - return Promise.resolve(Array.from(this.cache.entries())) - } - - clear(): void { - this.cache.clear() + return Promise.resolve(Array.from(this.entries())) } } diff --git a/packages/browser/src/openfeature/provider-tracking.ts b/packages/browser/src/openfeature/provider-tracking.ts index 956fafc5..b6c1b9c4 100644 --- a/packages/browser/src/openfeature/provider-tracking.ts +++ b/packages/browser/src/openfeature/provider-tracking.ts @@ -48,10 +48,10 @@ export function createProviderTracking({ const tracking = composeDatadogTrackingHooks(...trackingHooks) return { ...tracking, + exposureCache, hooks: getTrackingContext ? tracking.hooks.map((hook) => withTrackingContext(hook, getTrackingContext)) : tracking.hooks, - exposureCache, } } diff --git a/packages/browser/src/openfeature/provider.ts b/packages/browser/src/openfeature/provider.ts index 25a7b241..6b67d9a5 100644 --- a/packages/browser/src/openfeature/provider.ts +++ b/packages/browser/src/openfeature/provider.ts @@ -161,13 +161,8 @@ export class DatadogProvider extends DatadogProviderBase { // `signal`, so we don't block OF SDK unnecessarily. this.latestContextUpdate = this.retrieveFlagsConfiguration(evaluationContext, { signal }) .then((result) => - // New configuration might require clearing exposure - // cache. One example of this is updating experiment - // boundaries: if we previously emitted exposure events for an - // experiment and the new configuration bumped experiment - // start time, we need to emit at least one new event within - // the new experiment timeframe. We do that by clearing our - // exposure + // Preserve the existing timestamp-based reset for experiment reporting. + // createdAt is a configuration timestamp, not an experiment revision. this.maybeClearExposureCache(result.config, { signal }).then( () => result, // Ignore exposure cache errors. They should not prevent us from using the latest configuration. diff --git a/packages/browser/src/transport/fetchConfiguration.ts b/packages/browser/src/transport/fetchConfiguration.ts index 4a15c26c..00b35234 100644 --- a/packages/browser/src/transport/fetchConfiguration.ts +++ b/packages/browser/src/transport/fetchConfiguration.ts @@ -1,6 +1,11 @@ -import { type FlagsConfiguration, parsePrecomputedConfigurationResponse } from '@datadog/flagging-core' +import { + type FlagsConfiguration, + parsePrecomputedConfigurationResponse, + SUPPORTED_FLAGS_CAPABILITIES, +} from '@datadog/flagging-core' import { timeStampNow } from '@datadog/js-core/time' import type { EvaluationContext } from '@openfeature/web-sdk' +import { ParseError } from '@openfeature/web-sdk' import type { FlaggingInitConfiguration } from '../domain/configuration' import { buildEndpointHost } from './endpoint' @@ -8,6 +13,7 @@ const sourcePayload = { sdk_name: 'browser', sdk_version: __BUILD_ENV__SDK_VERSION__, } +const capabilitiesHeader = [...SUPPORTED_FLAGS_CAPABILITIES].sort().join(',') type JSONAPIError = { errors: { @@ -101,12 +107,15 @@ export function buildConfigurationHeaders( export async function fetchPrecomputedConfiguration( options: PrecomputedConfigurationFetchOptions ): Promise { - const url = buildConfigurationUrl(options, 'precomputed') + const requestOptions: ConfigurationRequestOptions = options + const url = buildConfigurationUrl(requestOptions, 'precomputed') const fetchedAt = timeStampNow() const defaultHeaders = buildConfigurationHeaders( options, { 'Content-Type': 'application/vnd.api+json', + // Customer proxies can opt in through customHeaders after allowing it in CORS. + ...(!requestOptions.flaggingProxy && { 'X-DD-FEATURE-FLAGS-CAPABILITIES': capabilitiesHeader }), }, 'precomputed' ) @@ -160,12 +169,16 @@ export function createFlagsConfigurationFetcher(initConfiguration: FlaggingInitC // Validate the endpoint while building the provider, preserving the existing constructor behavior. buildConfigurationUrl(initConfiguration, 'precomputed') return async (context: EvaluationContext, { signal }: { signal?: AbortSignal } = {}): Promise => { - return fetchPrecomputedConfiguration({ + const configuration = await fetchPrecomputedConfiguration({ ...initConfiguration, env: initConfiguration.env || '', context, fetch: initConfiguration.flagConfigurationFetch, signal, }) + // Use the provider's existing context-matched cache fallback. Do not replace + // a valid snapshot with a malformed response or unsupported encoding. + if (configuration.precomputedError) throw new ParseError(configuration.precomputedError) + return configuration } } diff --git a/packages/browser/test/cache/assignment-cache-factory.spec.ts b/packages/browser/test/cache/assignment-cache-factory.spec.ts index 193adb6a..8e5e54c0 100644 --- a/packages/browser/test/cache/assignment-cache-factory.spec.ts +++ b/packages/browser/test/cache/assignment-cache-factory.spec.ts @@ -38,7 +38,7 @@ describe('AssignmentCacheFactory', () => { }) }) - it('should create a hybrid cache if chrome storage is available', () => { + it('should create a hybrid cache if chrome storage is available', async () => { const cache = assignmentCacheFactory({ chromeStorage: mockChromeStorage, storageKeySuffix: 'foo', @@ -52,6 +52,7 @@ describe('AssignmentCacheFactory', () => { variant: { key: 'qux' }, } cache.set(exposureEvent) + await cache.init() expect(Object.keys(fakeStore)).toHaveLength(1) }) diff --git a/packages/browser/test/cache/exposure-cache-capacity.spec.ts b/packages/browser/test/cache/exposure-cache-capacity.spec.ts new file mode 100644 index 00000000..756e77c0 --- /dev/null +++ b/packages/browser/test/cache/exposure-cache-capacity.spec.ts @@ -0,0 +1,101 @@ +import { assignmentCacheKeyToString, assignmentCacheValueToString, type ExposureEvent } from '@datadog/flagging-core' +import ChromeStorageAsyncMap from '../../src/cache/chrome-storage-async-map' +import { MAX_EXPOSURE_CACHE_ENTRIES } from '../../src/cache/constants' +import { LocalStorageAssignmentShim } from '../../src/cache/local-storage-assignment-shim' +import SimpleAssignmentCache from '../../src/cache/simple-assignment-cache' + +const exposure = (id: string): ExposureEvent => ({ + subject: { id, attributes: {} }, + flag: { key: 'flag' }, + allocation: { key: 'allocation' }, + variant: { key: 'control' }, +}) +const entries = (count = MAX_EXPOSURE_CACHE_ENTRIES): [string, string][] => + Array.from({ length: count }, (_, index) => [`key-${index}`, 'value']) + +describe('exposure cache capacity', () => { + beforeEach(() => localStorage.clear()) + + it('uses the Node provider limit and evicts the least recently used memory entry', async () => { + expect(MAX_EXPOSURE_CACHE_ENTRIES).toBe(50_000) + const cache = new SimpleAssignmentCache() + const hot = exposure('hot') + cache.set(hot) + cache.setEntries(entries(MAX_EXPOSURE_CACHE_ENTRIES - 1)) + expect(cache.has(hot)).toBe(true) + cache.set(exposure('new')) + const stored = new Map(await cache.getEntries()) + expect(stored.size).toBe(MAX_EXPOSURE_CACHE_ENTRIES) + expect(stored.has('key-0')).toBe(false) + expect(cache.has(hot)).toBe(true) + expect(cache.has(exposure('new'))).toBe(true) + }) + + it('bounds imported memory entries and permits a new exposure after eviction', () => { + const cache = new SimpleAssignmentCache() + const old = exposure('old') + cache.setEntries([[assignmentCacheKeyToString(old), assignmentCacheValueToString(old)], ...entries()]) + expect(cache.has(old)).toBe(false) + cache.set(old) + expect(cache.has(old)).toBe(true) + }) + + it('bounds localStorage on load and on write without changing another namespace', () => { + localStorage.setItem('datadog-assignment-bounded', JSON.stringify(entries(MAX_EXPOSURE_CACHE_ENTRIES + 1))) + localStorage.setItem('datadog-assignment-other', 'preserve') + const cache = new LocalStorageAssignmentShim('bounded') + expect(cache.has('key-0')).toBe(false) + cache.set('key-1', 'updated') + cache.set('new', 'value') + const stored = new Map(JSON.parse(localStorage.getItem('datadog-assignment-bounded')!)) + expect(stored.size).toBe(MAX_EXPOSURE_CACHE_ENTRIES) + expect(stored.has('key-2')).toBe(false) + expect(stored.get('key-1')).toBe('updated') + expect(new LocalStorageAssignmentShim('bounded').has('new')).toBe(true) + expect(localStorage.getItem('datadog-assignment-other')).toBe('preserve') + }) + + function chromeCache(count: number) { + const stored: Record = Object.fromEntries( + entries(count).map(([key, value]) => [`bounded:${key}`, value]) + ) + stored['other:preserve'] = 'value' + const storage = { + get: jest.fn(async () => ({ ...stored })), + set: jest.fn(async (items: Record) => { + Object.assign(stored, items) + }), + remove: jest.fn(async (keys: string[]) => { + for (const key of keys) delete stored[key] + }), + } + const cache = new ChromeStorageAsyncMap(storage as unknown as chrome.storage.StorageArea, 'bounded') + return { stored, storage, cache } + } + + it('bounds Chrome storage on load and serializes concurrent writes and eviction', async () => { + const { stored, cache } = chromeCache(MAX_EXPOSURE_CACHE_ENTRIES + 1) + expect(Object.keys(await cache.entries())).toHaveLength(MAX_EXPOSURE_CACHE_ENTRIES) + expect(stored['bounded:key-0']).toBeUndefined() + await Promise.all([cache.set('key-1', 'updated'), cache.set('new-1', 'value'), cache.set('new-2', 'value')]) + expect(Object.keys(stored)).toHaveLength(MAX_EXPOSURE_CACHE_ENTRIES + 1) + expect(stored['bounded:key-1']).toBe('updated') + expect(stored['bounded:key-2']).toBeUndefined() + expect(stored['bounded:key-3']).toBeUndefined() + expect(stored['bounded:new-1']).toBe('value') + expect(stored['bounded:new-2']).toBe('value') + expect(stored['other:preserve']).toBe('value') + }) + + it('continues after a failed Chrome write and clears after queued writes', async () => { + const { stored, storage, cache } = chromeCache(0) + storage.set.mockRejectedValueOnce(new Error('quota')) + await expect(cache.set('failed', 'value')).rejects.toThrow('quota') + await cache.set('next', 'value') + expect(stored['bounded:next']).toBe('value') + const pendingWrite = cache.set('pending', 'value') + await cache.clear() + await pendingWrite + expect(stored).toEqual({ 'other:preserve': 'value' }) + }) +}) diff --git a/packages/browser/test/cache/exposure-cache.spec.ts b/packages/browser/test/cache/exposure-cache.spec.ts index 583c3ded..bf528ef9 100644 --- a/packages/browser/test/cache/exposure-cache.spec.ts +++ b/packages/browser/test/cache/exposure-cache.spec.ts @@ -59,6 +59,7 @@ describe('exposure cache storage boundaries', () => { const second = createCache('scope-b') first.set(exposure) second.set(exposure) + await Promise.all([first.init(), second.init()]) const storageKeys = Object.keys(kind === 'chrome' ? stored : localStorage).filter((key) => key !== 'unrelated') const entrySuffix = kind === 'chrome' ? `:${assignmentCacheKeyToString(exposure)}` : '' diff --git a/packages/browser/test/cache/indexeddb-flags-cache.spec.ts b/packages/browser/test/cache/indexeddb-flags-cache.spec.ts index ed814976..0c641e8c 100644 --- a/packages/browser/test/cache/indexeddb-flags-cache.spec.ts +++ b/packages/browser/test/cache/indexeddb-flags-cache.spec.ts @@ -4,7 +4,7 @@ if (typeof globalThis.structuredClone === 'undefined') { } import 'fake-indexeddb/auto' -import type { FlagsConfiguration } from '@datadog/flagging-core' +import { buildStorageKeySuffix, type FlagsConfiguration, getMD5Hash } from '@datadog/flagging-core' import type { TimeStamp } from '@datadog/js-core/time' import { IDBFactory } from 'fake-indexeddb' import { IndexedDBFlagsCache } from '../../src/cache/indexeddb-flags-cache' @@ -34,6 +34,23 @@ const testConfig: FlagsConfiguration = { } const context = { targetingKey: 'user-123' } +const legacyKey = `flags-config-${buildStorageKeySuffix('test-client-token')}-${getMD5Hash(JSON.stringify(context))}` +const currentKey = `v2-${legacyKey}` +const encodedConfig: FlagsConfiguration = { + precomputed: { + ...testConfig.precomputed!, + response: { + data: { + attributes: { + ...testConfig.precomputed!.response.data.attributes, + obfuscated: true, + obfuscation: { scheme: 'flag-key-sha256-v1', salt: '0'.repeat(32) }, + flags: { ['a'.repeat(64)]: testConfig.precomputed!.response.data.attributes.flags['test-flag'] }, + }, + }, + }, + }, +} describe('IndexedDBFlagsCache', () => { let cache: IndexedDBFlagsCache @@ -66,7 +83,7 @@ describe('IndexedDBFlagsCache', () => { // Write a string directly — not a FlagsConfiguration object const db = await openTestDB() const tx = db.transaction('configurations', 'readwrite') - tx.objectStore('configurations').put('not an object', 'flags-config') + tx.objectStore('configurations').put('not an object', currentKey) await transactionComplete(tx) db.close() @@ -78,7 +95,7 @@ describe('IndexedDBFlagsCache', () => { // Write an object that has no precomputed field const db = await openTestDB() const tx = db.transaction('configurations', 'readwrite') - tx.objectStore('configurations').put({ version: 1 }, 'flags-config') + tx.objectStore('configurations').put({ version: 1 }, currentKey) await transactionComplete(tx) db.close() @@ -148,6 +165,41 @@ describe('IndexedDBFlagsCache', () => { }) }) + describe('older SDK compatibility', () => { + it('reads legacy plaintext on upgrade without overwriting it with encoded keys', async () => { + await writeEntry(legacyKey, testConfig) + expect(await cache.get(context)).toEqual(testConfig) + + cache.set(encodedConfig, context) + await flushAsync() + expect(await cache.get(context)).toEqual(encodedConfig) + expect(await readEntry(currentKey)).toEqual(encodedConfig) + expect(await readEntry(legacyKey)).toEqual(testConfig) + }) + + it('never writes encoded snapshots where an older SDK can read them', async () => { + cache.set(encodedConfig, context) + await flushAsync() + expect(await readEntry(currentKey)).toEqual(encodedConfig) + expect(await readEntry(legacyKey)).toBeUndefined() + }) + + it('replaces encoded snapshots with plaintext on rollout rollback', async () => { + cache.set(encodedConfig, context) + await flushAsync() + cache.set(testConfig, context) + await flushAsync() + expect(await cache.get(context)).toEqual(testConfig) + expect(await readEntry(currentKey)).toEqual(testConfig) + expect(await readEntry(legacyKey)).toBeUndefined() + }) + + it('does not restore encoded snapshots from the legacy namespace', async () => { + await writeEntry(legacyKey, encodedConfig) + expect(await cache.get(context)).toBeUndefined() + }) + }) + describe('client token isolation', () => { it('should not share data between caches with different client tokens', async () => { const cacheA = new IndexedDBFlagsCache('token-aaa') @@ -234,6 +286,27 @@ function flushAsync(): Promise { return new Promise((resolve) => setTimeout(resolve, 50)) } +async function writeEntry(key: string, value: FlagsConfiguration): Promise { + const db = await openTestDB() + const tx = db.transaction('configurations', 'readwrite') + tx.objectStore('configurations').put(value, key) + await transactionComplete(tx) + db.close() +} + +async function readEntry(key: string): Promise { + const db = await openTestDB() + try { + return await new Promise((resolve, reject) => { + const request = db.transaction('configurations', 'readonly').objectStore('configurations').get(key) + request.onsuccess = () => resolve(request.result) + request.onerror = () => reject(request.error) + }) + } finally { + db.close() + } +} + // Helpers to directly open the test DB for setup/verification function openTestDB(): Promise { return new Promise((resolve, reject) => { diff --git a/packages/browser/test/openfeature/core-tracking.spec.ts b/packages/browser/test/openfeature/core-tracking.spec.ts index df07a663..900ad719 100644 --- a/packages/browser/test/openfeature/core-tracking.spec.ts +++ b/packages/browser/test/openfeature/core-tracking.spec.ts @@ -1,10 +1,5 @@ import { getGlobalObject, INTAKE_SITE_STAGING } from '@datadog/browser-core' -import { - assignmentCacheKeyToString, - assignmentCacheValueToString, - type ExposureEvent, - type FlagsConfiguration, -} from '@datadog/flagging-core' +import type { FlagsConfiguration } from '@datadog/flagging-core' import { timeStampNow } from '@datadog/js-core/time' import { OpenFeature } from '@openfeature/web-sdk' import type { DDRum } from '../../src/openfeature/rumIntegration' @@ -208,7 +203,7 @@ describe('DatadogCoreProvider tracking', () => { expect(fetchMock.mock.calls.some(([url]) => url.toString().includes('exposures'))).toBe(false) }) - it('emits exposures again when the core provider configuration identity changes', async () => { + it('emits another exposure when the precomputed configuration timestamp changes', async () => { const { trackingHooks } = createExposureOnlyTracking() await trackingHooks.initialize() @@ -222,6 +217,7 @@ describe('DatadogCoreProvider tracking', () => { jest.advanceTimersByTime(31_000) provider.setConfiguration(precomputedConfigurationWithCreatedAt('2026-07-07T00:00:00.000Z')) + expect(fetchMock.mock.calls.filter(([url]) => url.toString().includes('exposures'))).toHaveLength(1) client.getStringValue('static-flag', 'default') jest.advanceTimersByTime(31_000) @@ -344,53 +340,83 @@ describe('DatadogCoreProvider tracking', () => { expect(fetchMock.mock.calls.filter(([url]) => url.toString().includes('exposures'))).toHaveLength(2) }) - it('does not let legacy exposure cache entries suppress core provider exposures', async () => { - const staleExposure: ExposureEvent = { - allocation: { key: 'static-allocation' }, - flag: { key: 'static-flag' }, - variant: { key: 'static-variation' }, - subject: { id: 'static-user', attributes: { plan: 'free' } }, - } - const staleEntries = { - [assignmentCacheKeyToString(staleExposure)]: assignmentCacheValueToString(staleExposure), - } - let notifyReadStarted!: () => void - const readStarted = new Promise((resolve) => { - notifyReadStarted = resolve - }) - let resolveInitialRead!: (entries: Record) => void - const storage = { - get: jest.fn( - () => - new Promise>((resolve) => { - resolveInitialRead = resolve - notifyReadStarted() - }) - ), - set: jest.fn().mockResolvedValue(undefined), - clear: jest.fn().mockResolvedValue(undefined), - } as unknown as chrome.storage.StorageArea + it('does not let persisted marker-less exposure entries suppress core provider exposures', async () => { + const persisted: Record = {} Object.defineProperty(globalThis, 'chrome', { configurable: true, - value: { storage: { local: storage } }, + value: { + storage: { + local: { + get: jest.fn(async () => ({ ...persisted })), + set: jest.fn(async (items: Record) => { + Object.assign(persisted, items) + }), + remove: jest.fn(async (keys: string[]) => { + for (const storageKey of keys) delete persisted[storageKey] + }), + }, + }, + }, }) - const { trackingHooks } = createExposureOnlyTracking() - const trackingInitialization = trackingHooks.initialize() - await readStarted - resolveInitialRead(staleEntries) - await trackingInitialization + // Persist an entry in the format written without a core configuration marker. + const legacy = createExposureOnlyTracking().trackingHooks + await legacy.initialize() + legacy.hooks[0].after!( + { flagKey: 'static-flag', context: { targetingKey: 'static-user', plan: 'free' } } as never, + { + flagKey: 'static-flag', + value: 'static-value', + variant: 'static-variation', + flagMetadata: { allocationKey: 'static-allocation', doLog: true }, + } as never + ) + jest.advanceTimersByTime(31_000) + await legacy.shutdown() + expect(Object.keys(persisted)).toHaveLength(1) + const { trackingHooks } = createExposureOnlyTracking() + await trackingHooks.initialize() const provider = new DatadogCoreProvider() provider.setConfiguration(precomputedConfiguration) await OpenFeature.setProviderAndWait(DOMAIN, provider, { targetingKey: 'static-user', plan: 'free' }) - const client = OpenFeature.getClient(DOMAIN) client.addHooks(...trackingHooks.hooks) client.getStringValue('static-flag', 'default') jest.advanceTimersByTime(31_000) - expect(fetchMock.mock.calls.filter(([url]) => url.toString().includes('exposures'))).toHaveLength(1) + expect(fetchMock.mock.calls.filter(([url]) => url.toString().includes('exposures'))).toHaveLength(2) + }) + + it('keeps rules identities for contexts outside a mixed precomputed snapshot', async () => { + const { trackingHooks } = createExposureOnlyTracking() + await trackingHooks.initialize() + const rules = rulesConfiguration.rules! + const provider = new DatadogCoreProvider() + provider.setConfiguration({ ...precomputedConfiguration, rules }) + await OpenFeature.setProviderAndWait(DOMAIN, provider, { targetingKey: 'rules-user', country: 'US' }) + const client = OpenFeature.getClient(DOMAIN) + client.addHooks(...trackingHooks.hooks) + + const first = client.getBooleanDetails('test-flag', false) + expect(first.reason).not.toBe('ERROR') + jest.advanceTimersByTime(31_000) + + provider.setConfiguration({ + ...precomputedConfiguration, + rules: { + ...rules, + response: { + ...rules.response, + flags: { ...rules.response.flags, 'another-flag': rules.response.flags['test-flag']! }, + }, + }, + }) + const changed = client.getBooleanDetails('test-flag', false) + expect(changed.variant).toBe(first.variant) + expect(changed.flagMetadata.__dd_core_configuration_id).not.toBe(first.flagMetadata.__dd_core_configuration_id) + jest.advanceTimersByTime(31_000) + expect(fetchMock.mock.calls.filter(([url]) => url.toString().includes('exposures'))).toHaveLength(2) }) function precomputedConfigurationWithCreatedAt(createdAt: string): FlagsConfiguration { diff --git a/packages/browser/test/openfeature/exposures.spec.ts b/packages/browser/test/openfeature/exposures.spec.ts index ecaea69d..0415a8d6 100644 --- a/packages/browser/test/openfeature/exposures.spec.ts +++ b/packages/browser/test/openfeature/exposures.spec.ts @@ -785,14 +785,15 @@ describe('Exposures End-to-End', () => { // Verify first exposure was logged expect(getExposuresCalls()).toHaveLength(1) - // Fetch new configuration with different createdAt (cache should be cleared) + // Fetch the same assignment with a changed configuration timestamp. await provider.onContextChange({}, { targetingKey: 'test-user-123', customAttribute: 'test-value' }) - // Evaluate same flag - should log again because cache was cleared + // Receiving configuration does not itself emit an exposure. + expect(getExposuresCalls()).toHaveLength(1) + // The next evaluation can emit another exposure despite identical assignment IDs. client.getStringValue('string-flag', 'default') triggerBatch() - // Should have 2 exposure calls (cache was cleared) expect(getExposuresCalls()).toHaveLength(2) }) diff --git a/packages/browser/test/openfeature/obfuscation.spec.ts b/packages/browser/test/openfeature/obfuscation.spec.ts new file mode 100644 index 00000000..7e257ed1 --- /dev/null +++ b/packages/browser/test/openfeature/obfuscation.spec.ts @@ -0,0 +1,532 @@ +import { createHash } from 'node:crypto' +import { getGlobalObject } from '@datadog/browser-core' +import { OpenFeature, ProviderStatus } from '@openfeature/web-sdk' +import HybridAssignmentCache from '../../src/cache/hybrid-assignment-cache' +import { DatadogProvider } from '../../src/openfeature/provider' +import type { DDRum } from '../../src/openfeature/rumIntegration' +import { + configurationFromString, + configurationToString, + createDatadogExposureLoggingHook, + DatadogCoreProvider, +} from '../../src/rules-based' +import { fetchPrecomputedConfiguration } from '../../src/transport/fetchConfiguration' + +const context = { targetingKey: 'athlete-123' } +const key = 'new-route-planner' +const salt = '000102030405060708090a0b0c0d0e0f' +const options = { + clientToken: 'test-token', + env: 'test', + enableExposureLogging: false, + enableFlagEvaluationTracking: false, + enableRumFeatureFlagTracking: false, +} +const logger = { debug: jest.fn(), info: jest.fn(), warn: jest.fn(), error: jest.fn() } + +function response(publicSalt: string | undefined = salt, value = true, createdAt = '2026-09-30T00:00:00Z') { + const lookupKey = publicSalt + ? createHash('sha256') + .update('datadog.feature-flags.flag-key.v1\0') + .update(Buffer.from(publicSalt, 'hex')) + .update(key) + .digest('hex') + : key + return { + data: { + attributes: { + createdAt, + obfuscated: Boolean(publicSalt), + obfuscation: publicSalt ? { scheme: 'flag-key-sha256-v1', salt: publicSalt } : undefined, + flags: { + [lookupKey]: { + variationType: 'boolean', + variationValue: value, + variationKey: 'variant-1', + allocationKey: 'allocation-1', + reason: 'TARGETING_MATCH', + doLog: true, + serialId: 123, + }, + }, + }, + }, + } +} + +function fetchResponse(payload: unknown) { + return { ok: true, headers: new Headers(), json: async () => payload } +} + +describe('browser flag-key obfuscation', () => { + const originalFetch = global.fetch + let fetchMock: jest.Mock + + beforeEach(async () => { + await OpenFeature.clearProviders() + await OpenFeature.clearContext() + OpenFeature.clearHooks() + OpenFeature.clearHandlers() + localStorage.clear() + fetchMock = jest.fn().mockResolvedValue(fetchResponse(response())) + global.fetch = fetchMock + }) + + afterEach(async () => { + await OpenFeature.clearProviders() + delete getGlobalObject<{ DD_RUM?: DDRum }>().DD_RUM + global.fetch = originalFetch + jest.useRealTimers() + }) + + it('advertises support and resolves original keys through the online provider', async () => { + await OpenFeature.setProviderAndWait(new DatadogProvider(options), context) + const details = OpenFeature.getClient().getBooleanDetails(key, false) + expect(details).toMatchObject({ + flagKey: key, + value: true, + variant: 'variant-1', + reason: 'TARGETING_MATCH', + flagMetadata: { allocationKey: 'allocation-1', doLog: true, __dd_split_serial_id: 123 }, + }) + const request = fetchMock.mock.calls[0][1] + expect(request.headers['X-DD-FEATURE-FLAGS-CAPABILITIES']).toBe('assignment-encoding-flag-key-256-v1') + expect(JSON.parse(request.body).data.attributes).not.toHaveProperty('supported_capabilities') + expect(JSON.parse(request.body).data.attributes).toMatchObject({ + source: { sdk_name: 'browser', sdk_version: '1.0.0-test' }, + }) + }) + + it('supports the portable fetch, serialization, and core-provider path', async () => { + const configuration = await fetchPrecomputedConfiguration({ ...options, context }) + const provider = new DatadogCoreProvider() + provider.setConfiguration(configurationFromString(configurationToString(configuration))) + await OpenFeature.setProviderAndWait(provider, context) + expect(OpenFeature.getClient().getBooleanDetails(key, false)).toMatchObject({ flagKey: key, value: true }) + }) + + it('switches between salts and plaintext without changing application calls', async () => { + const provider = new DatadogProvider(options) + await provider.initialize(context) + for (const publicSalt of ['f'.repeat(32), '', salt]) { + fetchMock.mockResolvedValue(fetchResponse(response(publicSalt))) + await provider.onContextChange(context, context) + expect(provider.resolveBooleanEvaluation(key, false, context, logger).value).toBe(true) + } + await provider.onClose() + }) + + it.each(['', salt])('accepts new flags and future fields on refresh (salt: %s)', async (publicSalt) => { + const provider = new DatadogProvider(options) + await provider.initialize(context) + const payload = response(publicSalt) + const attributes = payload.data.attributes + const assignment = Object.values(attributes.flags)[0] + const lookupKey = (name: string) => + publicSalt + ? createHash('sha256') + .update('datadog.feature-flags.flag-key.v1\0') + .update(Buffer.from(publicSalt, 'hex')) + .update(name) + .digest('hex') + : name + attributes.flags[lookupKey('new-flag')] = { ...assignment, variationValue: false } + attributes.flags[lookupKey('future-type')] = { ...assignment, variationType: 'future-type' } + Object.assign(attributes, { futureField: { enabled: true } }) + Object.assign(assignment, { futureAssignmentField: 123 }) + fetchMock.mockResolvedValue(fetchResponse(payload)) + await provider.onContextChange(context, context) + + expect(provider.status).toBe(ProviderStatus.READY) + expect(provider.resolveBooleanEvaluation(key, false, context, logger).value).toBe(true) + expect(provider.resolveBooleanEvaluation('new-flag', true, context, logger).value).toBe(false) + expect(provider.resolveBooleanEvaluation('future-type', false, context, logger)).toMatchObject({ + value: false, + errorCode: 'PARSE_ERROR', + }) + await provider.onClose() + }) + + it('keeps a matching previous snapshot when an encoding is unsupported', async () => { + const provider = new DatadogProvider(options) + await provider.initialize(context) + const invalid = response() + invalid.data.attributes.obfuscation!.scheme = 'future-encoding' + fetchMock.mockResolvedValue(fetchResponse(invalid)) + await provider.onContextChange(context, context) + expect(provider.status).toBe(ProviderStatus.STALE) + expect(provider.resolveBooleanEvaluation(key, false, context, logger).value).toBe(true) + await expect(provider.onContextChange(context, { targetingKey: 'different-athlete' })).rejects.toThrow( + 'Unsupported precomputed flag-key obfuscation scheme' + ) + expect(provider.status).toBe(ProviderStatus.ERROR) + await provider.onClose() + }) + + it('rejects unsupported encoding on a cold start', async () => { + const invalid = response() + invalid.data.attributes.obfuscation!.scheme = 'future-encoding' + fetchMock.mockResolvedValue(fetchResponse(invalid)) + const provider = new DatadogProvider(options) + await expect(provider.initialize(context)).rejects.toMatchObject({ code: 'PARSE_ERROR' }) + await provider.onClose() + }) + + it.each(['createdAt', 'flags'])('keeps a matching snapshot when plaintext %s is malformed', async (field) => { + const provider = new DatadogProvider(options) + fetchMock.mockResolvedValue(fetchResponse(response(''))) + await provider.initialize(context) + const invalid = response('') + Object.assign(invalid.data.attributes, { [field]: null }) + fetchMock.mockResolvedValue(fetchResponse(invalid)) + await provider.onContextChange(context, context) + expect(provider.status).toBe(ProviderStatus.STALE) + expect(provider.resolveBooleanEvaluation(key, false, context, logger).value).toBe(true) + await provider.onClose() + }) + + it.each(['createdAt', 'flags'])('rejects malformed plaintext %s without a usable cache', async (field) => { + const invalid = response('') + Object.assign(invalid.data.attributes, { [field]: null }) + fetchMock.mockResolvedValue(fetchResponse(invalid)) + const provider = new DatadogProvider(options) + await expect(provider.initialize(context)).rejects.toMatchObject({ code: 'PARSE_ERROR' }) + expect(provider.status).toBe(ProviderStatus.ERROR) + await provider.onClose() + }) + + it('keeps original flag names in exposures, evaluation events, and RUM', async () => { + jest.useFakeTimers() + const rum = jest.fn() + getGlobalObject<{ DD_RUM?: DDRum }>().DD_RUM = { addFeatureFlagEvaluation: rum } + fetchMock.mockImplementation(async (url: string) => + url.includes('precompute-assignments') ? fetchResponse(response()) : { ok: true, status: 200 } + ) + await OpenFeature.setProviderAndWait( + new DatadogProvider({ + ...options, + enableExposureLogging: true, + enableFlagEvaluationTracking: true, + enableRumFeatureFlagTracking: true, + flagEvaluationTrackingInterval: 1000, + }), + context + ) + OpenFeature.getClient().getBooleanValue(key, false) + OpenFeature.getClient().getBooleanValue(key, false) + jest.advanceTimersByTime(31_000) + + expect(rum).toHaveBeenCalledWith(key, 'variant-1') + for (const channel of ['exposures', 'flagevaluation']) { + const requests = fetchMock.mock.calls.filter(([url]) => String(url).includes(channel)) + const events = requests.flatMap(([, request]) => + (request.body as string) + .trim() + .split('\n') + .map((line) => JSON.parse(line)) + ) + expect(events).toHaveLength(1) + expect(events[0]).toMatchObject({ + flag: { key }, + allocation: { key: 'allocation-1' }, + variant: { key: 'variant-1' }, + }) + } + }) + + it.each([ + ['online', ''], + ['online', salt], + ['portable', ''], + ['portable', salt], + ])('preserves %s exposure resets with initial salt "%s"', async (kind, initialSalt) => { + jest.useFakeTimers() + let payload = response(initialSalt) + fetchMock.mockImplementation(async (url: string) => + url.includes('precompute-assignments') ? fetchResponse(payload) : { ok: true, status: 200 } + ) + let tracking: ReturnType | undefined + let provider: DatadogProvider | DatadogCoreProvider + const client = OpenFeature.getClient() + const install = async () => { + if (kind === 'online') { + provider = new DatadogProvider({ ...options, enableExposureLogging: true }) + } else { + tracking = createDatadogExposureLoggingHook(options) + await tracking.initialize() + const core = new DatadogCoreProvider() + core.setConfiguration(await fetchPrecomputedConfiguration({ ...options, context })) + provider = core + } + await OpenFeature.setProviderAndWait(provider, context) + if (tracking) client.addHooks(...tracking.hooks) + } + const refresh = async () => { + if (provider instanceof DatadogCoreProvider) { + const configuration = await fetchPrecomputedConfiguration({ ...options, context }) + provider.setConfiguration(configurationFromString(configurationToString(configuration))) + } else { + await provider.onContextChange(context, context) + } + } + const exposureEvents = () => + fetchMock.mock.calls + .filter(([url]) => String(url).includes('exposures')) + .flatMap(([, request]) => + (request.body as string) + .trim() + .split('\n') + .map((line) => JSON.parse(line)) + ) + + await install() + const evaluate = () => { + expect(client.getBooleanValue(key, false)).toBe(true) + jest.advanceTimersByTime(31_000) + } + evaluate() + evaluate() + expect(exposureEvents()).toHaveLength(1) + + // createdAt may stay unchanged across requests. Reusing the same snapshot + // must preserve deduplication, including a portable serialization round trip. + await refresh() + evaluate() + expect(exposureEvents()).toHaveLength(1) + + // Timestamp changes permit another exposure with identical assignment IDs. + // This also applies to an older timestamp; it is not a monotonic revision. + for (const [index, createdAt] of ['2026-09-30T00:01:00Z', '2026-09-29T00:00:00Z'].entries()) { + payload = response(initialSalt, true, createdAt) + await refresh() + jest.advanceTimersByTime(31_000) + expect(exposureEvents()).toHaveLength(index + 1) + evaluate() + evaluate() + expect(exposureEvents()).toHaveLength(index + 2) + } + + // Cover new salts and both rollout directions without changing assignments. + for (const [index, publicSalt] of ['f'.repeat(32), '', salt].entries()) { + payload = response(publicSalt, true, `2026-09-30T00:0${index + 2}:00Z`) + await refresh() + evaluate() + expect(exposureEvents()).toHaveLength(index + 4) + } + + // Assignment identity changes still produce exposures with a stable timestamp. + const assignment = Object.values(payload.data.attributes.flags)[0] + for (const [index, change] of [ + { variationKey: 'variant-2' }, + { allocationKey: 'allocation-2' }, + { serialId: 124 }, + ].entries()) { + Object.assign(assignment, change) + await refresh() + evaluate() + expect(exposureEvents()).toHaveLength(index + 7) + } + expect(exposureEvents()[8]).toMatchObject({ + flag: { key }, + allocation: { key: 'allocation-2' }, + variant: { key: 'variant-2' }, + serial_id: 124, + }) + + // Recreate the provider and hooks without clearing persisted deduplication. + client.clearHooks() + await tracking?.shutdown() + await OpenFeature.clearProviders() + await install() + evaluate() + expect(exposureEvents()).toHaveLength(9) + client.clearHooks() + await tracking?.shutdown() + }) + + it.each(['', salt])('preserves first-load behavior with initial salt "%s"', async (publicSalt) => { + jest.useFakeTimers() + let payload = response(publicSalt) + fetchMock.mockImplementation(async (url: string) => + url.includes('precompute-assignments') ? fetchResponse(payload) : { ok: true, status: 200 } + ) + const providerOptions = { ...options, enableExposureLogging: true } + const exposures = () => fetchMock.mock.calls.filter(([url]) => String(url).includes('exposures')) + await OpenFeature.setProviderAndWait(new DatadogProvider(providerOptions), context) + OpenFeature.getClient().getBooleanValue(key, false) + jest.advanceTimersByTime(31_000) + expect(exposures()).toHaveLength(1) + await OpenFeature.clearProviders() + + // Without an initial configuration, first fetch must retain persisted deduplication, + // even when the server timestamp has changed since the previous page load. + payload = response(publicSalt, true, '2026-09-30T00:01:00Z') + await OpenFeature.setProviderAndWait(new DatadogProvider(providerOptions), context) + OpenFeature.getClient().getBooleanValue(key, false) + jest.advanceTimersByTime(31_000) + expect(exposures()).toHaveLength(1) + await OpenFeature.clearProviders() + + // An explicitly supplied initial configuration restores the normal comparison. + const initialFlagsConfiguration = await fetchPrecomputedConfiguration({ ...options, context }) + payload = response(publicSalt, true, '2026-09-30T00:02:00Z') + await OpenFeature.setProviderAndWait( + new DatadogProvider({ ...providerOptions, initialFlagsConfiguration }), + context + ) + jest.advanceTimersByTime(31_000) + expect(exposures()).toHaveLength(1) + OpenFeature.getClient().getBooleanValue(key, false) + jest.advanceTimersByTime(31_000) + expect(exposures()).toHaveLength(2) + }) + + it.each(['', salt])('keeps configuration usable when clearing exposures fails, salt "%s"', async (publicSalt) => { + let payload = response(publicSalt) + fetchMock.mockImplementation(async (url: string) => + url.includes('precompute-assignments') ? fetchResponse(payload) : { ok: true, status: 200 } + ) + const provider = new DatadogProvider({ ...options, enableExposureLogging: true }) + const clear = jest + .spyOn(HybridAssignmentCache.prototype, 'clear') + .mockRejectedValue(new Error('storage unavailable')) + try { + await provider.initialize(context) + expect(clear).not.toHaveBeenCalled() + payload = response(publicSalt, false, '2026-09-30T00:01:00Z') + await provider.onContextChange(context, context) + expect(clear).toHaveBeenCalledTimes(1) + expect(provider.status).toBe(ProviderStatus.READY) + expect(provider.resolveBooleanEvaluation(key, true, context, logger).value).toBe(false) + } finally { + clear.mockRestore() + await provider.onClose() + } + }) + + it.each(['', salt])('waits for exposure clearing before publishing configuration, salt "%s"', async (publicSalt) => { + let payload = response(publicSalt) + fetchMock.mockImplementation(async (url: string) => + url.includes('precompute-assignments') ? fetchResponse(payload) : { ok: true, status: 200 } + ) + const provider = new DatadogProvider({ ...options, enableExposureLogging: true }) + await provider.initialize(context) + let finishClear!: () => void + let startClear!: () => void + const clearingStarted = new Promise((resolve) => { + startClear = resolve + }) + const clearingFinished = new Promise((resolve) => { + finishClear = resolve + }) + const clear = jest.spyOn(HybridAssignmentCache.prototype, 'clear').mockImplementation(() => { + startClear() + return clearingFinished + }) + let refreshed = false + payload = response(publicSalt, false, '2026-09-30T00:01:00Z') + const refresh = provider.onContextChange(context, context).then(() => { + refreshed = true + }) + try { + await clearingStarted + // Drain the update chain so an unawaited clear cannot pass this assertion. + await new Promise((resolve) => setTimeout(resolve, 0)) + expect(refreshed).toBe(false) + expect(provider.resolveBooleanEvaluation(key, false, context, logger).value).toBe(true) + finishClear() + await refresh + expect(provider.status).toBe(ProviderStatus.READY) + expect(provider.resolveBooleanEvaluation(key, true, context, logger).value).toBe(false) + } finally { + finishClear() + await refresh + clear.mockRestore() + await provider.onClose() + } + }) + + it.each(['', salt])('refreshes without an exposure cache when logging is disabled, salt "%s"', async (publicSalt) => { + fetchMock.mockResolvedValue(fetchResponse(response(publicSalt))) + const provider = new DatadogProvider(options) + const clear = jest.spyOn(HybridAssignmentCache.prototype, 'clear') + try { + await provider.initialize(context) + fetchMock.mockResolvedValue(fetchResponse(response(publicSalt, false, '2026-09-30T00:01:00Z'))) + await provider.onContextChange(context, context) + expect(provider.status).toBe(ProviderStatus.READY) + expect(provider.resolveBooleanEvaluation(key, true, context, logger).value).toBe(false) + expect(clear).not.toHaveBeenCalled() + expect(localStorage.length).toBe(0) + } finally { + clear.mockRestore() + await provider.onClose() + } + }) + + it('does not serialize assignment values on repeated portable-provider evaluations', async () => { + const configuration = await fetchPrecomputedConfiguration({ ...options, context }) + const flag = Object.values(configuration.precomputed!.response.data.attributes.flags)[0] + flag.variationType = 'object' + flag.variationValue = { large: 'x'.repeat(6600) } + const provider = new DatadogCoreProvider() + provider.setConfiguration(configuration) + await provider.initialize(context) + const stringify = jest.spyOn(JSON, 'stringify') + try { + for (let index = 0; index < 10; index++) { + const details = provider.resolveObjectEvaluation(key, {}, context, logger) + expect(details.value).toBe(flag.variationValue) + } + expect(stringify).not.toHaveBeenCalled() + } finally { + stringify.mockRestore() + } + }) + + it('loads persisted exposure identities before reporting ready', async () => { + jest.useFakeTimers() + let resolveRead: ((entries: Record) => void) | undefined + const readStarted = new Promise((started) => { + Object.defineProperty(globalThis, 'chrome', { + configurable: true, + value: { + storage: { + local: { + get: jest.fn( + () => + new Promise>((resolve) => { + resolveRead = resolve + started() + }) + ), + set: jest.fn().mockResolvedValue(undefined), + remove: jest.fn().mockResolvedValue(undefined), + }, + }, + }, + }) + }) + try { + const provider = new DatadogProvider({ ...options, enableExposureLogging: true }) + let ready = false + const initialized = OpenFeature.setProviderAndWait(provider, context).then(() => { + ready = true + }) + await readStarted + await jest.advanceTimersByTimeAsync(100) + expect(fetchMock.mock.calls.some(([url]) => String(url).includes('precompute-assignments'))).toBe(true) + expect(ready).toBe(false) + + resolveRead!({}) + await initialized + OpenFeature.getClient().getBooleanValue(key, false) + jest.advanceTimersByTime(31_000) + expect(fetchMock.mock.calls.filter(([url]) => String(url).includes('exposures'))).toHaveLength(1) + } finally { + // Let provider shutdown finish if an assertion failed before the read resolved. + resolveRead?.({}) + Reflect.deleteProperty(globalThis, 'chrome') + } + }) +}) diff --git a/packages/browser/test/openfeature/provider-persistence.spec.ts b/packages/browser/test/openfeature/provider-persistence.spec.ts index bce95994..b8b1144c 100644 --- a/packages/browser/test/openfeature/provider-persistence.spec.ts +++ b/packages/browser/test/openfeature/provider-persistence.spec.ts @@ -56,6 +56,36 @@ describe('DatadogProvider IndexedDB persistence', () => { }) describe('persists flags on successful fetch', () => { + it('restores the obfuscation descriptor and map together after a restart', async () => { + const context = { targetingKey: 'obfuscated-user' } + const digest = 'a60479237ef2f69175bbe0bd581966d1583766941815dc1d414c883767795190' + const payload = { + data: { + attributes: { + createdAt: '2026-09-30T00:00:00Z', + obfuscated: true, + obfuscation: { scheme: 'flag-key-sha256-v1', salt: '000102030405060708090a0b0c0d0e0f' }, + flags: { [digest]: precomputedResponse.data.attributes.flags['boolean-flag'] }, + }, + }, + } + fetchMock.mockResolvedValue({ ok: true, json: async () => payload }) + const provider = new DatadogProvider(options) + await provider.initialize(context) + await flushAsync() + await provider.onClose() + + const stored = await new IndexedDBFlagsCache(options.clientToken).get(context) + expect(stored?.precomputed?.response).toEqual(payload) + global.fetch = failingFetchMock() + const restarted = new DatadogProvider(options) + await restarted.initialize(context) + expect(restarted.status).toBe(ProviderStatus.STALE) + const logger = { debug: jest.fn(), info: jest.fn(), warn: jest.fn(), error: jest.fn() } + expect(restarted.resolveBooleanEvaluation('new-route-planner', false, context, logger).value).toBe(true) + await restarted.onClose() + }) + it('should persist flags to IndexedDB after initialize', async () => { const provider = new DatadogProvider(options) const context = { targetingKey: 'user-1' } diff --git a/packages/browser/test/openfeature/provider.spec.ts b/packages/browser/test/openfeature/provider.spec.ts index 8c517478..c6eb55cd 100644 --- a/packages/browser/test/openfeature/provider.spec.ts +++ b/packages/browser/test/openfeature/provider.spec.ts @@ -281,6 +281,7 @@ describe('DatadogProvider', () => { // Verify headers were set correctly expect(requestOptions.headers).toEqual({ 'Content-Type': 'application/vnd.api+json', + 'X-DD-FEATURE-FLAGS-CAPABILITIES': 'assignment-encoding-flag-key-256-v1', 'dd-client-token': options.clientToken, 'dd-application-id': options.applicationId, }) @@ -605,6 +606,7 @@ describe('DatadogProvider', () => { method: 'POST', headers: { 'Content-Type': 'application/vnd.api+json', + 'X-DD-FEATURE-FLAGS-CAPABILITIES': 'assignment-encoding-flag-key-256-v1', 'dd-client-token': options.clientToken, 'dd-application-id': options.applicationId, }, diff --git a/packages/browser/test/transport/fetchConfiguration.spec.ts b/packages/browser/test/transport/fetchConfiguration.spec.ts index 32493628..7052ca62 100644 --- a/packages/browser/test/transport/fetchConfiguration.spec.ts +++ b/packages/browser/test/transport/fetchConfiguration.spec.ts @@ -194,6 +194,30 @@ describe('createFlagsConfigurationFetcher', () => { }) describe('request headers', () => { + it('should declare capabilities to the Datadog edge', async () => { + const fetcher = createFlagsConfigurationFetcher({ ...baseConfig, site: 'datadoghq.com' }) + + await fetcher(mockContext) + + expect(mockFetch.mock.calls[0][1].headers).toMatchObject({ + 'X-DD-FEATURE-FLAGS-CAPABILITIES': 'assignment-encoding-flag-key-256-v1', + }) + }) + + it('should not add a preflighted capabilities header to proxy requests unless configured', async () => { + const proxy = { ...baseConfig, flaggingProxy: 'https://proxy.example.com' } + await createFlagsConfigurationFetcher(proxy)(mockContext) + await createFlagsConfigurationFetcher({ + ...proxy, + customHeaders: { 'X-DD-FEATURE-FLAGS-CAPABILITIES': 'assignment-encoding-flag-key-256-v1' }, + })(mockContext) + + expect(mockFetch.mock.calls[0][1].headers).not.toHaveProperty('X-DD-FEATURE-FLAGS-CAPABILITIES') + expect(mockFetch.mock.calls[1][1].headers).toMatchObject({ + 'X-DD-FEATURE-FLAGS-CAPABILITIES': 'assignment-encoding-flag-key-256-v1', + }) + }) + it('should include default headers when not overwriting', async () => { const config = { ...baseConfig, flaggingProxy: 'https://proxy.example.com' } const fetcher = createFlagsConfigurationFetcher(config) @@ -212,7 +236,7 @@ describe('createFlagsConfigurationFetcher', () => { ) }) - it('should exclude dd headers when overwriteRequestHeaders is true', async () => { + it('should exclude authentication headers when overwriteRequestHeaders is true', async () => { const config = { ...baseConfig, flaggingProxy: 'https://proxy.example.com', diff --git a/packages/core/src/configuration/configuration.ts b/packages/core/src/configuration/configuration.ts index 4b740f32..048aed05 100644 --- a/packages/core/src/configuration/configuration.ts +++ b/packages/core/src/configuration/configuration.ts @@ -1,5 +1,6 @@ import type { EvaluationContext, FlagValueType, JsonValue, ResolutionReason } from '@openfeature/core' import type { TimeStamp } from '../time' +import type { FlagKeyObfuscation } from './flag-key-obfuscation' import type { PreparedRulesResponse } from './prepared-rules-response' /** @@ -50,6 +51,10 @@ export type PrecomputedConfigurationResponse = { attributes: { /** When configuration was generated. */ createdAt: string + /** Absent or false for legacy plaintext assignments. */ + obfuscated?: boolean + /** Required when obfuscated is true; retained with the map in caches. */ + obfuscation?: FlagKeyObfuscation flags: Record } } diff --git a/packages/core/src/configuration/flag-key-obfuscation.ts b/packages/core/src/configuration/flag-key-obfuscation.ts new file mode 100644 index 00000000..717be2a4 --- /dev/null +++ b/packages/core/src/configuration/flag-key-obfuscation.ts @@ -0,0 +1,86 @@ +import { sha256Hex } from '../evaluation/sha256' +import { encodeUtf8 } from '../utf8' + +const SCHEME = 'flag-key-sha256-v1' +const DOMAIN = /* @__PURE__ */ encodeUtf8('datadog.feature-flags.flag-key.v1\0') +const MAX_CACHED_KEYS = 1024 +const lookupCaches = new WeakMap }>() + +/** Capabilities supported by the precomputed parser and evaluator. @internal */ +export const SUPPORTED_FLAGS_CAPABILITIES = ['assignment-encoding-flag-key-256-v1'] as const + +/** Public metadata stored with the encoded assignment map. @internal */ +export type FlagKeyObfuscation = { + scheme: typeof SCHEME + salt: string +} + +/** Validate the response's encoding before parsing or looking up assignments. */ +export function readFlagKeyObfuscation( + obfuscated: unknown, + obfuscation: unknown +): { encoding?: FlagKeyObfuscation } | { error: string } { + if ((obfuscated === undefined || obfuscated === false) && obfuscation === undefined) { + return {} + } + if (obfuscated !== true || typeof obfuscation !== 'object' || obfuscation === null || Array.isArray(obfuscation)) { + return { error: 'Invalid precomputed flag-key obfuscation metadata' } + } + const descriptor = obfuscation as Record + if (descriptor.scheme !== SCHEME) { + return { error: 'Unsupported precomputed flag-key obfuscation scheme' } + } + if (!isLowercaseHex(descriptor.salt, 16)) { + return { error: 'Precomputed flag-key salt must contain 32 lowercase hexadecimal characters' } + } + // Keep descriptor identity stable so repeated lookups share a bounded cache. + return { encoding: descriptor as FlagKeyObfuscation } +} + +/** Hash only the lookup key. Values and telemetry retain their original meaning. */ +export function encodePrecomputedFlagKey( + key: string, + encoding: FlagKeyObfuscation +): { key: string } | { error: string } { + let cache = lookupCaches.get(encoding) + if (!cache || cache.salt !== encoding.salt) { + cache = { salt: encoding.salt, keys: new Map() } + lookupCaches.set(encoding, cache) + } + const cached = cache.keys.get(key) + if (cached !== undefined) return { key: cached } + + // TextEncoder replaces invalid surrogates. Reject them instead of aliasing + // a different flag whose name contains the Unicode replacement character. + if (!isWellFormedUnicode(key)) return { error: 'Flag key must contain valid Unicode' } + + const keyBytes = encodeUtf8(key) + const input = new Uint8Array(DOMAIN.length + 16 + keyBytes.length) + input.set(DOMAIN) + for (let index = 0; index < 16; index++) { + input[DOMAIN.length + index] = Number.parseInt(encoding.salt.slice(index * 2, index * 2 + 2), 16) + } + input.set(keyBytes, DOMAIN.length + 16) + const digest = sha256Hex(input) + if (cache.keys.size >= MAX_CACHED_KEYS) cache.keys.clear() + cache.keys.set(key, digest) + return { key: digest } +} + +/** Check the exact byte length and alphabet of a hex-encoded wire value. */ +export function isLowercaseHex(value: unknown, bytes: number): value is string { + return typeof value === 'string' && value.length === bytes * 2 && /^[0-9a-f]+$/.test(value) +} + +function isWellFormedUnicode(value: string): boolean { + for (let index = 0; index < value.length; index++) { + const codeUnit = value.charCodeAt(index) + if (codeUnit >= 0xd800 && codeUnit <= 0xdbff) { + const next = value.charCodeAt(++index) + if (!(next >= 0xdc00 && next <= 0xdfff)) return false + } else if (codeUnit >= 0xdc00 && codeUnit <= 0xdfff) { + return false + } + } + return true +} diff --git a/packages/core/src/configuration/index.ts b/packages/core/src/configuration/index.ts index 1fa86452..fb5c1331 100644 --- a/packages/core/src/configuration/index.ts +++ b/packages/core/src/configuration/index.ts @@ -1,6 +1,7 @@ export * from './configuration' export * from './exposureEvent' export * from './exposureEvent.types' +export { SUPPORTED_FLAGS_CAPABILITIES } from './flag-key-obfuscation' export * from './flagEvaluationAggregator' export * from './flagEvaluationEvent' export * from './flagEvaluationEvent.types' diff --git a/packages/core/src/configuration/precomputed-wire.ts b/packages/core/src/configuration/precomputed-wire.ts index bfdf3264..d60a1135 100644 --- a/packages/core/src/configuration/precomputed-wire.ts +++ b/packages/core/src/configuration/precomputed-wire.ts @@ -49,6 +49,8 @@ export function precomputedConfigurationToWire(configuration: FlagsConfiguration if (!configuration.precomputed) return wire const { context, response, fetchedAt, etag } = configuration.precomputed + // Older readers reject version 2 instead of treating hashed keys as plaintext. + if (response.data.attributes.obfuscated === true) wire.version = 2 wire.precomputed = { context, response: JSON.stringify(response), diff --git a/packages/core/src/configuration/wire-types.ts b/packages/core/src/configuration/wire-types.ts index 5b6f928e..763fef49 100644 --- a/packages/core/src/configuration/wire-types.ts +++ b/packages/core/src/configuration/wire-types.ts @@ -6,7 +6,7 @@ export type FlagsConfigurationWire = string export const INVALID_CONFIGURATION_WIRE_ERROR = 'Invalid flags configuration wire format' export type ConfigurationWireContents = { - version: 1 + version: 1 | 2 precomputed?: { context?: EvaluationContext response: string @@ -27,7 +27,12 @@ export function parseConfigurationWire(wire: FlagsConfigurationWire): Configurat } catch { return undefined } - if (typeof serialized !== 'object' || serialized === null || !('version' in serialized) || serialized.version !== 1) { + if ( + typeof serialized !== 'object' || + serialized === null || + !('version' in serialized) || + (serialized.version !== 1 && serialized.version !== 2) + ) { return undefined } return serialized as ConfigurationWireContents diff --git a/packages/core/src/configuration/wire-validation.ts b/packages/core/src/configuration/wire-validation.ts index 6ee0b379..c0efcf24 100644 --- a/packages/core/src/configuration/wire-validation.ts +++ b/packages/core/src/configuration/wire-validation.ts @@ -1,5 +1,6 @@ import type { EvaluationContext } from '@openfeature/core' import type { PrecomputedConfigurationResponse, PrecomputedFlag } from './configuration' +import { isLowercaseHex, readFlagKeyObfuscation } from './flag-key-obfuscation' type WireEntry = { response: string @@ -33,7 +34,9 @@ export function parsePrecomputedConfigurationResponse( if (!isRecord(value.data.attributes)) { return { error: 'Precomputed configuration response is missing attributes' } } - const { createdAt, flags } = value.data.attributes + const { createdAt, flags, obfuscated, obfuscation } = value.data.attributes + const encoding = readFlagKeyObfuscation(obfuscated, obfuscation) + if ('error' in encoding) return encoding if (typeof createdAt !== 'string' && (typeof createdAt !== 'number' || !Number.isFinite(createdAt))) { return { error: 'Precomputed configuration createdAt is invalid' } } @@ -41,6 +44,9 @@ export function parsePrecomputedConfigurationResponse( const flagErrors: Array<[string, string]> = [] for (const [key, flag] of Object.entries(flags)) { + if (encoding.encoding && !isLowercaseHex(key, 32)) { + return { error: 'Obfuscated flag keys must contain 64 lowercase hexadecimal characters' } + } if (!isPrecomputedFlag(flag)) { flagErrors.push([key, 'Invalid precomputed flag configuration']) } diff --git a/packages/core/src/configuration/wire.test.ts b/packages/core/src/configuration/wire.test.ts index 038596e7..b1252121 100644 --- a/packages/core/src/configuration/wire.test.ts +++ b/packages/core/src/configuration/wire.test.ts @@ -74,6 +74,43 @@ describe('configuration wire', () => { expect(restored).toEqual(configuration) }) + it.each([false, true])('versions encoded portable snapshots (with rules: %s)', (includeRules) => { + const precomputed = configuration.precomputed! + const encoded: FlagsConfiguration = { + precomputed: { + ...precomputed, + response: { + data: { + attributes: { + ...precomputed.response.data.attributes, + obfuscated: true, + obfuscation: { scheme: 'flag-key-sha256-v1', salt: '0'.repeat(32) }, + flags: { ['a'.repeat(64)]: precomputed.response.data.attributes.flags['my-flag'] }, + }, + }, + }, + }, + ...(includeRules + ? configurationFromString(JSON.stringify({ version: 1, rules: { response: rulesResponse } })) + : {}), + } + const wire = configurationToString(encoded) + expect(JSON.parse(wire).version).toBe(2) + expect(configurationFromString(wire)).toEqual(encoded) + expect(configurationFromRulesString(wire).rules).toEqual(encoded.rules) + + const precomputedWire = configurationToPrecomputedString(encoded) + expect(JSON.parse(precomputedWire).version).toBe(2) + expect(configurationFromPrecomputedString(precomputedWire)).toEqual({ precomputed: encoded.precomputed }) + }) + + it('keeps plaintext and rules-only portable snapshots on version 1', () => { + expect(JSON.parse(configurationToString(configuration)).version).toBe(1) + expect(JSON.parse(configurationToPrecomputedString(configuration)).version).toBe(1) + const rules = configurationFromString(JSON.stringify({ version: 1, rules: { response: rulesResponse } })) + expect(JSON.parse(configurationToString(rules)).version).toBe(1) + }) + it('keeps flags readable after a round-trip', () => { const restored = configurationFromString(configurationToString(configuration)) @@ -117,7 +154,7 @@ describe('configuration wire', () => { }) it('retains a configuration error for an unknown version', () => { - expect(configurationFromString(JSON.stringify({ version: 2 }))).toEqual({ + expect(configurationFromString(JSON.stringify({ version: 3 }))).toEqual({ configurationError: 'Invalid flags configuration wire format', }) }) diff --git a/packages/core/src/evaluation/precomputed-evaluation.ts b/packages/core/src/evaluation/precomputed-evaluation.ts index 8f820587..5439fd1c 100644 --- a/packages/core/src/evaluation/precomputed-evaluation.ts +++ b/packages/core/src/evaluation/precomputed-evaluation.ts @@ -6,6 +6,7 @@ import { type PrecomputedConfiguration, type PrecomputedFlagMetadata, } from '../configuration' +import { encodePrecomputedFlagKey, readFlagKeyObfuscation } from '../configuration/flag-key-obfuscation' import { getOwnProperty } from './getOwnProperty' export function evaluatePrecomputedConfiguration( @@ -80,7 +81,22 @@ function evaluatePrecomputedFlag( flagKey: string, defaultValue: FlagTypeToValue ): ResolutionDetails> { - const flagError = precomputed.flagErrors ? getOwnProperty(precomputed.flagErrors, flagKey) : undefined + const attributes = precomputed.response.data.attributes + // Initial configurations and persistent caches can bypass the wire parser. + const encoding = readFlagKeyObfuscation(attributes.obfuscated, attributes.obfuscation) + if ('error' in encoding) { + return { + value: defaultValue, + reason: 'ERROR', + errorCode: 'PARSE_ERROR' as ErrorCode, + errorMessage: encoding.error, + } + } + const lookup = encoding.encoding ? encodePrecomputedFlagKey(flagKey, encoding.encoding) : { key: flagKey } + if ('error' in lookup) { + return { value: defaultValue, reason: 'ERROR', errorCode: 'FLAG_NOT_FOUND' as ErrorCode } + } + const flagError = precomputed.flagErrors ? getOwnProperty(precomputed.flagErrors, lookup.key) : undefined if (flagError) { return { value: defaultValue, @@ -90,7 +106,7 @@ function evaluatePrecomputedFlag( } } - const flag = getOwnProperty(precomputed.response.data.attributes.flags, flagKey) + const flag = getOwnProperty(attributes.flags, lookup.key) if (!flag) { return { value: defaultValue, diff --git a/packages/core/test/evaluation/flag-key-obfuscation.spec.ts b/packages/core/test/evaluation/flag-key-obfuscation.spec.ts new file mode 100644 index 00000000..be80f972 --- /dev/null +++ b/packages/core/test/evaluation/flag-key-obfuscation.spec.ts @@ -0,0 +1,262 @@ +import { createHash } from 'node:crypto' +import type { FlagValueType } from '@openfeature/core' +import { + configurationFromString, + configurationToString, + type FlagsConfiguration, + parsePrecomputedConfigurationResponse, +} from '../../src/configuration' +import { encodePrecomputedFlagKey, type FlagKeyObfuscation } from '../../src/configuration/flag-key-obfuscation' +import { evaluatePrecomputedConfiguration } from '../../src/evaluation/precomputed-evaluation' +import * as sha256 from '../../src/evaluation/sha256' + +const salt = '000102030405060708090a0b0c0d0e0f' +const context = { targetingKey: 'athlete-123' } +const assignment = { + allocationKey: 'allocation-123', + variationKey: 'variation-456', + variationType: 'boolean', + variationValue: true, + reason: 'TARGETING_MATCH', + doLog: true, + serialId: 123, +} +const descriptor = { scheme: 'flag-key-sha256-v1', salt } + +// These vectors also run in the Rust edge encoder. Expected digests are not +// generated by the SDK implementation under test. +const vectors = [ + ['new-route-planner', 'a60479237ef2f69175bbe0bd581966d1583766941815dc1d414c883767795190'], + ['Flag', 'adca75d2141c51b0c0f084c1058edfb8e6e763f91aaf54ca06326d9847bd9586'], + ['flag', '9817872c144b018abd77e3915bd77e2c27f4f534dccff8ffaca27361e3a5e1ee'], + [' flag ', 'b1a5f851cc82a3fdf03a461d72a2241584dcf455df1d384e02a937901b3bc80b'], + ['café', '3bfa8c3c17c1b61035b98ecf14007cdf5cba5ce61a48e8cfb65f8106541892d3'], + ['cafe\u0301', '1e8b7ec5e8028a1ec96b38dd37f6ccea041c0a90358904af40ac5810c23c8765'], + ['🚲/旗', '94b611e0d3b26b52f6ad66013d1c72f8a92ea109390049759e75d3c8d3aa4dab'], + ['a\0b', 'bac134d201be5e7f28fc7019248f0809c2a013b137e866446ee71add2bc344c7'], + ['', '072d985b427f536ad0a11b2d4c5e0f7e6f0ff6d23e779e082f75599ce3fe3eba'], +] + +function response(flags: Record, encoding: Record = {}) { + return { data: { attributes: { createdAt: '2026-09-30T00:00:00Z', format: 'PRECOMPUTED', ...encoding, flags } } } +} + +function decode(value: unknown): FlagsConfiguration { + return configurationFromString( + JSON.stringify({ version: 1, precomputed: { response: JSON.stringify(value), context } }) + ) +} + +function hash(key: string, publicSalt = salt): string { + return createHash('sha256') + .update('datadog.feature-flags.flag-key.v1\0') + .update(Buffer.from(publicSalt, 'hex')) + .update(key) + .digest('hex') +} + +describe('precomputed flag-key obfuscation', () => { + afterEach(() => jest.restoreAllMocks()) + + it('caches repeated lookups separately for concurrent configurations', () => { + const configurations = [salt, 'f'.repeat(32)].map((publicSalt) => + decode( + response( + { [hash('flag', publicSalt)]: assignment }, + { obfuscated: true, obfuscation: { ...descriptor, salt: publicSalt } } + ) + ) + ) + const digest = jest.spyOn(sha256, 'sha256Hex') + for (let iteration = 0; iteration < 3; iteration++) { + for (const configuration of configurations) { + expect(evaluatePrecomputedConfiguration(configuration, 'boolean', 'flag', false, context).value).toBe(true) + } + } + expect(digest).toHaveBeenCalledTimes(2) + }) + + it('does not reuse a lookup hash after a descriptor salt changes', () => { + const encoding: FlagKeyObfuscation = { scheme: 'flag-key-sha256-v1', salt } + for (const publicSalt of [salt, 'f'.repeat(32), salt]) { + encoding.salt = publicSalt + expect(encodePrecomputedFlagKey('flag', encoding)).toEqual({ key: hash('flag', publicSalt) }) + } + }) + + it('bounds the lookup cache without changing evaluation results', () => { + const encoding: FlagKeyObfuscation = { scheme: 'flag-key-sha256-v1', salt } + const digest = jest.spyOn(sha256, 'sha256Hex') + for (let index = 0; index <= 1024; index++) { + expect(encodePrecomputedFlagKey(`flag-${index}`, encoding)).toEqual({ key: hash(`flag-${index}`) }) + } + expect(encodePrecomputedFlagKey('flag-1024', encoding)).toEqual({ key: hash('flag-1024') }) + expect(digest).toHaveBeenCalledTimes(1025) + expect(encodePrecomputedFlagKey('flag-0', encoding)).toEqual({ key: hash('flag-0') }) + expect(digest).toHaveBeenCalledTimes(1026) + }) + + it.each([false, true])('accepts new flag keys and unknown fields (obfuscated: %s)', (obfuscated) => { + const lookupKey = (key: string) => (obfuscated ? hash(key) : key) + const payload = response( + { + [lookupKey('existing-flag')]: assignment, + [lookupKey('new-flag')]: { ...assignment, futureAssignmentField: { enabled: true } }, + [lookupKey('unsupported-flag')]: { ...assignment, variationType: 'future-type' }, + }, + { + futureResponseField: ['new metadata'], + ...(obfuscated ? { obfuscated: true, obfuscation: { ...descriptor, futureEncodingField: true } } : {}), + } + ) + const configuration = decode({ ...payload, futureEnvelopeField: true }) + expect(configuration.precomputedError).toBeUndefined() + for (const key of ['existing-flag', 'new-flag']) { + expect(evaluatePrecomputedConfiguration(configuration, 'boolean', key, false, context).value).toBe(true) + } + expect( + evaluatePrecomputedConfiguration(configuration, 'boolean', 'unsupported-flag', false, context) + ).toMatchObject({ + errorCode: 'PARSE_ERROR', + }) + }) + + it.each(vectors)('matches the edge digest for %j', (key, digest) => { + const plain = decode(response({ [key]: assignment })) + const encoded = decode(response({ [digest]: assignment }, { obfuscated: true, obfuscation: descriptor })) + expect(evaluatePrecomputedConfiguration(encoded, 'boolean', key, false, context)).toEqual( + evaluatePrecomputedConfiguration(plain, 'boolean', key, false, context) + ) + }) + + it.each([ + ['boolean', false, true], + ['string', 'default', 'visible-value'], + ['number', -1, 12.5], + ['object', {}, { visible: ['nested', 42] }], + ['BOOLEAN', false, true], + ['STRING', 'default', 'visible-value'], + ['INTEGER', -1, 42], + ['NUMERIC', -1, 12.5], + ['JSON', {}, { visible: ['nested', 42] }], + ])('preserves the complete %s evaluation result', (variationType, defaultValue, variationValue) => { + const key = 'flag' + const flag = { ...assignment, variationType, variationValue } + const type = typeof defaultValue as FlagValueType + const plain = decode(response({ [key]: flag })) + const encoded = decode(response({ [hash(key)]: flag }, { obfuscated: true, obfuscation: descriptor })) + expect(evaluatePrecomputedConfiguration(encoded, type, key, defaultValue, context)).toEqual( + evaluatePrecomputedConfiguration(plain, type, key, defaultValue, context) + ) + }) + + it('preserves missing-flag and type-mismatch defaults', () => { + const plain = decode(response({ flag: assignment })) + const encoded = decode(response({ [hash('flag')]: assignment }, { obfuscated: true, obfuscation: descriptor })) + for (const key of ['flag', 'missing']) { + expect(evaluatePrecomputedConfiguration(encoded, 'string', key, 'default', context)).toEqual( + evaluatePrecomputedConfiguration(plain, 'string', key, 'default', context) + ) + } + }) + + it('uses the hashed key for per-flag parse errors', () => { + const encoded = decode( + response( + { [hash('invalid')]: { ...assignment, variationValue: 'not-a-boolean' }, [hash('valid')]: assignment }, + { obfuscated: true, obfuscation: descriptor } + ) + ) + expect(evaluatePrecomputedConfiguration(encoded, 'boolean', 'invalid', false, context)).toMatchObject({ + value: false, + errorCode: 'PARSE_ERROR', + }) + expect(evaluatePrecomputedConfiguration(encoded, 'boolean', 'valid', false, context).value).toBe(true) + }) + + it('preserves the encoding through portable configuration round trips', () => { + const original = decode(response({ [hash('flag')]: assignment }, { obfuscated: true, obfuscation: descriptor })) + const restored = configurationFromString(configurationToString(original)) + expect(restored).toEqual(original) + expect(evaluatePrecomputedConfiguration(restored, 'boolean', 'flag', false, context).value).toBe(true) + expect(Object.keys(restored.precomputed!.response.data.attributes.flags)).toEqual([hash('flag')]) + }) + + it('keeps each response salt scoped to its own assignments', () => { + for (const publicSalt of [salt, 'f'.repeat(32), salt]) { + const encoded = decode( + response( + { [hash('flag', publicSalt)]: assignment }, + { obfuscated: true, obfuscation: { ...descriptor, salt: publicSalt } } + ) + ) + expect(evaluatePrecomputedConfiguration(encoded, 'boolean', 'flag', false, context).value).toBe(true) + expect( + evaluatePrecomputedConfiguration(encoded, 'boolean', 'flag', false, { targetingKey: 'other' }) + ).toMatchObject({ + errorCode: 'INVALID_CONTEXT', + }) + } + }) + + it.each([{}, { obfuscated: false }])('keeps legacy payloads unchanged: %j', (encoding) => { + const plain = decode(response({ flag: assignment }, encoding)) + expect(evaluatePrecomputedConfiguration(plain, 'boolean', 'flag', false, context).value).toBe(true) + }) + + it.each([ + { obfuscated: true }, + { obfuscated: 'true', obfuscation: descriptor }, + { obfuscated: null }, + { obfuscated: false, obfuscation: descriptor }, + { obfuscation: descriptor }, + { obfuscated: true, obfuscation: null }, + { obfuscated: true, obfuscation: [] }, + { obfuscated: true, obfuscation: { ...descriptor, scheme: 'flag-key-sha256-v2' } }, + ...['', '0'.repeat(30), '0'.repeat(34), 'G'.repeat(32), salt.toUpperCase(), salt + '\n', 42].map((salt) => ({ + obfuscated: true, + obfuscation: { ...descriptor, salt }, + })), + ])('rejects malformed or unsupported encoding metadata: %j', (encoding) => { + const payload = response({ flag: assignment }, encoding) + expect(parsePrecomputedConfigurationResponse(payload)).toHaveProperty('error') + expect(evaluatePrecomputedConfiguration(decode(payload), 'boolean', 'flag', false, context)).toMatchObject({ + value: false, + errorCode: 'PARSE_ERROR', + }) + // Initial configurations and IndexedDB values can bypass the wire parser. + const direct = { precomputed: { response: payload, context } } as FlagsConfiguration + expect(evaluatePrecomputedConfiguration(direct, 'boolean', 'flag', false, context)).toMatchObject({ + value: false, + errorCode: 'PARSE_ERROR', + }) + }) + + it.each(['plaintext-key', 'a'.repeat(63), 'a'.repeat(65), 'A'.repeat(64), 'a'.repeat(64) + '\n'])( + 'rejects a malformed encoded map key: %j', + (key) => { + expect( + parsePrecomputedConfigurationResponse( + response({ [key]: assignment }, { obfuscated: true, obfuscation: descriptor }) + ) + ).toHaveProperty('error') + } + ) + + it('never retries a missed hashed lookup as plaintext', () => { + const digest = hash('flag') + const encoded = decode(response({ [digest]: assignment }, { obfuscated: true, obfuscation: descriptor })) + expect(evaluatePrecomputedConfiguration(encoded, 'boolean', digest, false, context)).toMatchObject({ + value: false, + errorCode: 'FLAG_NOT_FOUND', + }) + }) + + it.each(['\ud800', '\udc00', 'a\ud800b'])('does not alias invalid Unicode to a replacement character: %j', (key) => { + const encoded = decode(response({ [hash(key)]: assignment }, { obfuscated: true, obfuscation: descriptor })) + const plain = decode(response({ flag: assignment })) + expect(evaluatePrecomputedConfiguration(encoded, 'boolean', key, false, context)).toEqual( + evaluatePrecomputedConfiguration(plain, 'boolean', key, false, context) + ) + }) +}) diff --git a/test-app/obfuscation.html b/test-app/obfuscation.html new file mode 100644 index 00000000..185afc6b --- /dev/null +++ b/test-app/obfuscation.html @@ -0,0 +1,11 @@ + + + + + Datadog flag-key obfuscation browser test + + +

+    
+  
+
diff --git a/test-app/src/obfuscation.ts b/test-app/src/obfuscation.ts
new file mode 100644
index 00000000..36ef52a6
--- /dev/null
+++ b/test-app/src/obfuscation.ts
@@ -0,0 +1,43 @@
+import { DatadogProvider } from '@datadog/openfeature-browser'
+import { OpenFeature } from '@openfeature/web-sdk'
+import { reportSuccess } from './smoke'
+
+async function run() {
+  const provider = new DatadogProvider({
+    clientToken: 'obfuscation-smoke-token',
+    env: 'test',
+    flaggingProxy: new URL('/assignments', location.href).toString(),
+    customHeaders: { 'X-DD-FEATURE-FLAGS-CAPABILITIES': 'assignment-encoding-flag-key-256-v1' },
+    enableExposureLogging: false,
+    enableFlagEvaluationTracking: false,
+    enableRumFeatureFlagTracking: false,
+    ...(new URLSearchParams(location.search).has('offline')
+      ? {
+          flagConfigurationFetch: async () => {
+            throw new Error('Offline test')
+          },
+        }
+      : {}),
+  })
+  await OpenFeature.setProviderAndWait(provider, { targetingKey: 'browser-smoke-subject' })
+  const client = OpenFeature.getClient()
+  const details = client.getBooleanDetails('new-route-planner', false)
+  reportSuccess({
+    values: [
+      details.value,
+      client.getStringValue('café', 'default'),
+      client.getNumberValue('number-flag', -1),
+      client.getObjectValue('object-flag', {}),
+    ],
+    flagKey: details.flagKey,
+    variant: details.variant,
+    reason: details.reason,
+    missing: client.getBooleanDetails('missing-flag', false).errorCode,
+    mismatch: client.getStringDetails('new-route-planner', 'default').errorCode,
+    status: provider.status,
+  })
+}
+
+run().catch((error: unknown) => {
+  Object.assign(globalThis, { __OPENFEATURE_SMOKE_ERROR__: String(error) })
+})
diff --git a/test-app/tests/smoke.spec.ts b/test-app/tests/smoke.spec.ts
index f1d984f4..07b1b993 100644
--- a/test-app/tests/smoke.spec.ts
+++ b/test-app/tests/smoke.spec.ts
@@ -1,4 +1,5 @@
 import { execFileSync } from 'node:child_process'
+import { createHash } from 'node:crypto'
 import { createRequire } from 'node:module'
 import { expect, type Page, test } from '@playwright/test'
 import type { SmokeResult as FetchSmokeResult } from '../src/smokeResult'
@@ -108,6 +109,140 @@ test('executes the packed precomputed entrypoint in Chromium', async ({ page })
   })
 })
 
+// Use Node's independent SHA-256 implementation to produce the edge wire format.
+function assignmentPayload(salt?: string, booleanValue = true) {
+  const flags = [
+    ['new-route-planner', 'boolean', booleanValue],
+    ['café', 'string', 'visible-value'],
+    ['number-flag', 'number', 12.5],
+    ['object-flag', 'object', { nested: true }],
+  ] as const
+  return {
+    data: {
+      attributes: {
+        createdAt: '2026-09-30T00:00:00Z',
+        obfuscated: salt !== undefined,
+        ...(salt ? { obfuscation: { scheme: 'flag-key-sha256-v1', salt } } : {}),
+        flags: Object.fromEntries(
+          flags.map(([key, variationType, variationValue]) => [
+            salt
+              ? createHash('sha256')
+                  .update('datadog.feature-flags.flag-key.v1\0')
+                  .update(Buffer.from(salt, 'hex'))
+                  .update(key)
+                  .digest('hex')
+              : key,
+            {
+              variationType,
+              variationValue,
+              allocationKey: 'allocation',
+              variationKey: 'on',
+              reason: 'TARGETING_MATCH',
+              doLog: true,
+            },
+          ])
+        ),
+      },
+    },
+  }
+}
+
+const expectedObfuscationResult = {
+  values: [true, 'visible-value', 12.5, { nested: true }],
+  flagKey: 'new-route-planner',
+  variant: 'on',
+  reason: 'TARGETING_MATCH',
+  missing: 'FLAG_NOT_FOUND',
+  mismatch: 'TYPE_MISMATCH',
+  status: 'READY',
+}
+
+test('negotiates obfuscation, rotates salts, and restores hashed assignments from IndexedDB', async ({ page }) => {
+  let salt = '000102030405060708090a0b0c0d0e0f'
+  let booleanValue = true
+  const expectStoredSalt = async () => {
+    await expect
+      .poll(() =>
+        page.evaluate(
+          () =>
+            new Promise((resolve, reject) => {
+              const request = indexedDB.open('dd-flagging')
+              request.onerror = () => reject(request.error)
+              request.onsuccess = () => {
+                const db = request.result
+                if (!db.objectStoreNames.contains('configurations')) {
+                  db.close()
+                  resolve([])
+                  return
+                }
+                const read = db.transaction('configurations').objectStore('configurations').getAll()
+                read.onerror = () => {
+                  db.close()
+                  reject(read.error)
+                }
+                read.onsuccess = () => {
+                  const salts = read.result.map(
+                    (entry) => entry.precomputed?.response.data.attributes.obfuscation?.salt
+                  )
+                  db.close()
+                  resolve(salts)
+                }
+              }
+            })
+        )
+      )
+      .toEqual([salt])
+  }
+  const requests: Record[] = []
+  await page.route('**/assignments?*', async (route) => {
+    expect(route.request().headers()['x-dd-feature-flags-capabilities']).toBe('assignment-encoding-flag-key-256-v1')
+    requests.push(route.request().postDataJSON())
+    await route.fulfill({ json: assignmentPayload(salt, booleanValue) })
+  })
+  expect(await runSmoke(page, '/obfuscation.html')).toEqual(expectedObfuscationResult)
+  await expectStoredSalt()
+  salt = 'f'.repeat(32)
+  booleanValue = false
+  const refreshedResult = {
+    ...expectedObfuscationResult,
+    values: [false, ...expectedObfuscationResult.values.slice(1)],
+  }
+  expect(await runSmoke(page, '/obfuscation.html')).toEqual(refreshedResult)
+  expect(requests).toHaveLength(2)
+  for (const request of requests) {
+    expect(request).not.toHaveProperty('data.attributes.supported_capabilities')
+    expect(request).toMatchObject({
+      data: {
+        attributes: {
+          source: { sdk_name: 'browser', sdk_version: expectedSdkVersion },
+        },
+      },
+    })
+  }
+  await expectStoredSalt()
+  expect(await runSmoke(page, '/obfuscation.html?offline=1')).toEqual({
+    ...refreshedResult,
+    status: 'STALE',
+  })
+  expect(requests).toHaveLength(2)
+})
+
+test('keeps plaintext responses compatible when rollout is disabled', async ({ page }) => {
+  await page.route('**/assignments?*', (route) => route.fulfill({ json: assignmentPayload() }))
+  expect(await runSmoke(page, '/obfuscation.html')).toEqual(expectedObfuscationResult)
+})
+
+test('evaluates obfuscated keys without native text encoders or Web Crypto', async ({ page }) => {
+  await page.addInitScript(() => {
+    Object.assign(globalThis, { TextEncoder: undefined, TextDecoder: undefined })
+    Object.defineProperty(globalThis.crypto, 'subtle', { value: undefined })
+  })
+  await page.route('**/assignments?*', (route) =>
+    route.fulfill({ json: assignmentPayload('000102030405060708090a0b0c0d0e0f') })
+  )
+  expect(await runSmoke(page, '/obfuscation.html')).toEqual(expectedObfuscationResult)
+})
+
 for (const scenario of [
   { path: '/provider.html', provider: 'datadog', rules: false },
   { path: '/core-provider.html', provider: 'datadog-core', rules: true },
diff --git a/test-app/vite.config.ts b/test-app/vite.config.ts
index fb3c9344..c445fa1f 100644
--- a/test-app/vite.config.ts
+++ b/test-app/vite.config.ts
@@ -11,6 +11,7 @@ export default defineConfig({
         coreProvider: fileURLToPath(new URL('./core-provider.html', import.meta.url)),
         protobuf: fileURLToPath(new URL('./protobuf.html', import.meta.url)),
         precomputed: fileURLToPath(new URL('./precomputed.html', import.meta.url)),
+        obfuscation: fileURLToPath(new URL('./obfuscation.html', import.meta.url)),
         trackingBaseline: fileURLToPath(new URL('./tracking-baseline.html', import.meta.url)),
         trackingExposure: fileURLToPath(new URL('./tracking-exposure.html', import.meta.url)),
         trackingEvaluation: fileURLToPath(new URL('./tracking-evaluation.html', import.meta.url)),