From b006247fc2a7454dc6ead3407822c93d1d1180c4 Mon Sep 17 00:00:00 2001 From: Blake Date: Tue, 7 Jul 2026 13:17:00 -0400 Subject: [PATCH 1/8] feat(flags): add ConfigurationWire parse + precomputed decode (FFL-2686, FFL-2687) Internal, pure-JS building blocks for offline init (kept un-exported until the exports step): - configurationFromString / configurationToString for the ConfigurationWire v1 envelope, with a ParsedFlagsConfiguration type (distinct from the enable() FlagsConfiguration). Lenient parse: returns {} on malformed input or an unsupported version; accepts a known set of versions. - decodePrecomputedFlags: maps a precomputed CDN response to the existing FlagCacheEntry map. Injects key from the flag map key; value = typed variationValue (integer/float -> JS number, variationType string preserved); derives the string variationValue (JSON for objects, lowercase booleans) for Android exposure round-trip; validates variationType and omits unknowns; throws UnsupportedConfigurationError on obfuscated payloads. Unit tests cover parse/round-trip, unsupported version/invalid JSON, and decode across all variation types incl. obfuscation and mismatch handling. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../__tests__/precomputed.test.ts | 180 ++++++++++++++++++ .../configuration/__tests__/wire.test.ts | 119 ++++++++++++ .../core/src/flags/configuration/index.ts | 23 +++ .../src/flags/configuration/precomputed.ts | 135 +++++++++++++ .../core/src/flags/configuration/types.ts | 119 ++++++++++++ packages/core/src/flags/configuration/wire.ts | 78 ++++++++ 6 files changed, 654 insertions(+) create mode 100644 packages/core/src/flags/configuration/__tests__/precomputed.test.ts create mode 100644 packages/core/src/flags/configuration/__tests__/wire.test.ts create mode 100644 packages/core/src/flags/configuration/index.ts create mode 100644 packages/core/src/flags/configuration/precomputed.ts create mode 100644 packages/core/src/flags/configuration/types.ts create mode 100644 packages/core/src/flags/configuration/wire.ts diff --git a/packages/core/src/flags/configuration/__tests__/precomputed.test.ts b/packages/core/src/flags/configuration/__tests__/precomputed.test.ts new file mode 100644 index 000000000..cce1fa183 --- /dev/null +++ b/packages/core/src/flags/configuration/__tests__/precomputed.test.ts @@ -0,0 +1,180 @@ +/* + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. + * This product includes software developed at Datadog (https://www.datadoghq.com/). + * Copyright 2016-Present Datadog, Inc. + */ + +import { InternalLog } from '../../../InternalLog'; +import { + UnsupportedConfigurationError, + decodePrecomputedFlags +} from '../precomputed'; +import type { + PrecomputedConfigurationResponse, + PrecomputedFlag +} from '../types'; + +jest.mock('../../../InternalLog', () => { + return { + InternalLog: { log: jest.fn() }, + DATADOG_MESSAGE_PREFIX: 'DATADOG:' + }; +}); + +const flag = (overrides: Partial): PrecomputedFlag => ({ + variationType: 'boolean', + variationValue: true, + variationKey: 'true', + allocationKey: 'alloc-1', + reason: 'STATIC', + doLog: false, + extraLogging: {}, + ...overrides +}); + +const responseWith = ( + flags: Record, + obfuscated = false +): PrecomputedConfigurationResponse => ({ + data: { + id: '2', + type: 'precomputed-assignments', + attributes: { + obfuscated, + createdAt: '2026-07-06T23:01:56.822171460Z', + format: 'PRECOMPUTED', + environment: { name: 'Staging' }, + flags + } + } +}); + +describe('decodePrecomputedFlags', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('maps each variation type to a FlagCacheEntry with the correct value + string form', () => { + const cache = decodePrecomputedFlags( + responseWith({ + bool: flag({ + variationType: 'boolean', + variationValue: false, + variationKey: 'false' + }), + str: flag({ + variationType: 'string', + variationValue: 'hello', + variationKey: 'Hello' + }), + num: flag({ + variationType: 'number', + variationValue: 42, + variationKey: '42' + }), + int: flag({ + variationType: 'integer', + variationValue: 7, + variationKey: '7' + }), + flt: flag({ + variationType: 'float', + variationValue: 1.5, + variationKey: '1.5' + }), + obj: flag({ + variationType: 'object', + variationValue: { greeting: 'hi' }, + variationKey: 'Greeting' + }) + }) + ); + + expect(cache.bool).toEqual({ + key: 'bool', + value: false, + allocationKey: 'alloc-1', + variationKey: 'false', + variationType: 'boolean', + variationValue: 'false', + reason: 'STATIC', + doLog: false, + extraLogging: {} + }); + expect(cache.str.value).toBe('hello'); + expect(cache.str.variationValue).toBe('hello'); + expect(cache.num.value).toBe(42); + expect(cache.num.variationValue).toBe('42'); + // integer/float keep their wire variationType but decode to a JS number. + expect(cache.int.value).toBe(7); + expect(cache.int.variationType).toBe('integer'); + expect(cache.int.variationValue).toBe('7'); + expect(cache.flt.value).toBe(1.5); + expect(cache.flt.variationType).toBe('float'); + expect(cache.flt.variationValue).toBe('1.5'); + // objects are JSON-encoded for the string form; value stays an object. + expect(cache.obj.value).toEqual({ greeting: 'hi' }); + expect(cache.obj.variationValue).toBe('{"greeting":"hi"}'); + }); + + it('uses the flag map key as the entry key', () => { + const cache = decodePrecomputedFlags( + responseWith({ 'my-feature': flag({}) }) + ); + + expect(cache['my-feature'].key).toBe('my-feature'); + }); + + it('defaults missing extraLogging to an empty object', () => { + const cache = decodePrecomputedFlags( + responseWith({ f: flag({ extraLogging: undefined }) }) + ); + + expect(cache.f.extraLogging).toEqual({}); + }); + + it('tolerates a null serialId', () => { + const cache = decodePrecomputedFlags( + responseWith({ f: flag({ serialId: null }) }) + ); + + expect(cache.f.key).toBe('f'); + }); + + it('omits flags with an unsupported variation type and logs a warning', () => { + const cache = decodePrecomputedFlags( + responseWith({ + good: flag({}), + bad: flag({ variationType: 'timestamp' }) + }) + ); + + expect(cache.good).toBeDefined(); + expect(cache.bad).toBeUndefined(); + expect(InternalLog.log).toHaveBeenCalled(); + }); + + it('omits flags whose value does not match their variation type', () => { + const cache = decodePrecomputedFlags( + responseWith({ + mismatched: flag({ + variationType: 'number', + variationValue: 'not-a-number' + }) + }) + ); + + expect(cache.mismatched).toBeUndefined(); + expect(InternalLog.log).toHaveBeenCalled(); + }); + + it('throws UnsupportedConfigurationError for an obfuscated response', () => { + expect(() => + decodePrecomputedFlags(responseWith({ f: flag({}) }, true)) + ).toThrow(UnsupportedConfigurationError); + }); + + it('returns an empty map when there are no flags', () => { + expect(decodePrecomputedFlags(responseWith({}))).toEqual({}); + }); +}); diff --git a/packages/core/src/flags/configuration/__tests__/wire.test.ts b/packages/core/src/flags/configuration/__tests__/wire.test.ts new file mode 100644 index 000000000..199faad68 --- /dev/null +++ b/packages/core/src/flags/configuration/__tests__/wire.test.ts @@ -0,0 +1,119 @@ +/* + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. + * This product includes software developed at Datadog (https://www.datadoghq.com/). + * Copyright 2016-Present Datadog, Inc. + */ + +import type { ParsedFlagsConfiguration } from '../types'; +import { configurationFromString, configurationToString } from '../wire'; + +const buildResponse = () => ({ + data: { + id: '2', + type: 'precomputed-assignments', + attributes: { + obfuscated: false, + createdAt: '2026-07-06T23:01:56.822171460Z', + format: 'PRECOMPUTED', + environment: { name: 'Staging' }, + flags: { + 'a-flag': { + variationType: 'boolean', + variationValue: true, + variationKey: 'true', + allocationKey: 'alloc-1', + reason: 'STATIC', + doLog: false, + extraLogging: {} + } + } + } + } +}); + +const buildWire = (overrides: Record = {}) => + JSON.stringify({ + version: 1, + precomputed: { + response: JSON.stringify(buildResponse()), + context: { targetingKey: 'user-1', country: 'US' }, + fetchedAt: 1748449320785 + }, + ...overrides + }); + +describe('configurationFromString', () => { + it('parses a valid v1 wire with a precomputed branch', () => { + const config = configurationFromString(buildWire()); + + expect(config.precomputed).toBeDefined(); + expect(config.precomputed?.context).toEqual({ + targetingKey: 'user-1', + country: 'US' + }); + expect(config.precomputed?.fetchedAt).toBe(1748449320785); + // The inner `response` string is parsed into an object. + expect( + config.precomputed?.response.data.attributes.flags['a-flag'] + .variationValue + ).toBe(true); + }); + + it('returns an empty config for an unsupported version', () => { + const wire = JSON.stringify({ + version: 2, + precomputed: { response: JSON.stringify(buildResponse()) } + }); + + expect(configurationFromString(wire)).toEqual({}); + }); + + it('returns an empty config for invalid JSON', () => { + expect(configurationFromString('not json')).toEqual({}); + }); + + it('returns an empty config when the inner response is invalid JSON', () => { + const wire = JSON.stringify({ + version: 1, + precomputed: { response: '{ not json' } + }); + + expect(configurationFromString(wire)).toEqual({}); + }); + + it('returns a config with no precomputed branch when none is present', () => { + const wire = JSON.stringify({ version: 1 }); + + expect(configurationFromString(wire)).toEqual({}); + }); + + it('does not populate a precomputed branch from a server-only wire (MVP)', () => { + const wire = JSON.stringify({ + version: 1, + server: { response: '{}' } + }); + + const config = configurationFromString(wire); + expect(config.precomputed).toBeUndefined(); + }); +}); + +describe('configurationToString round-trip', () => { + it('round-trips a precomputed configuration', () => { + const original = configurationFromString(buildWire()); + + const restored = configurationFromString( + configurationToString(original) + ); + + expect(restored).toEqual(original); + }); + + it('serializes an empty configuration to a v1 wire', () => { + const empty: ParsedFlagsConfiguration = {}; + + expect(configurationToString(empty)).toBe( + JSON.stringify({ version: 1 }) + ); + }); +}); diff --git a/packages/core/src/flags/configuration/index.ts b/packages/core/src/flags/configuration/index.ts new file mode 100644 index 000000000..3890eefc3 --- /dev/null +++ b/packages/core/src/flags/configuration/index.ts @@ -0,0 +1,23 @@ +/* + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. + * This product includes software developed at Datadog (https://www.datadoghq.com/). + * Copyright 2016-Present Datadog, Inc. + */ + +// Internal module boundary for portable-configuration handling. Intentionally NOT +// re-exported from the package's public entry point (`packages/core/src/index.tsx`) +// until the exports step (FFL-2690). Keeping the surface behind this boundary makes a +// future "port -> depend on a shared core" swap contained. + +export { configurationFromString, configurationToString } from './wire'; +export { + decodePrecomputedFlags, + UnsupportedConfigurationError +} from './precomputed'; +export type { + ParsedFlagsConfiguration, + ParsedPrecomputedConfiguration, + PrecomputedConfigurationResponse, + PrecomputedFlag, + WireEvaluationContext +} from './types'; diff --git a/packages/core/src/flags/configuration/precomputed.ts b/packages/core/src/flags/configuration/precomputed.ts new file mode 100644 index 000000000..7aebb8110 --- /dev/null +++ b/packages/core/src/flags/configuration/precomputed.ts @@ -0,0 +1,135 @@ +/* + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. + * This product includes software developed at Datadog (https://www.datadoghq.com/). + * Copyright 2016-Present Datadog, Inc. + */ + +import { InternalLog } from '../../InternalLog'; +import { SdkVerbosity } from '../../config/types/SdkVerbosity'; +import type { FlagCacheEntry } from '../internal'; + +import type { + PrecomputedConfigurationResponse, + PrecomputedFlag +} from './types'; +import { SUPPORTED_VARIATION_TYPES } from './types'; + +/** + * Thrown when a configuration cannot be supported by this SDK (e.g. an obfuscated + * precomputed payload). Callers translate this into a provider error state rather + * than silently serving wrong data. + */ +export class UnsupportedConfigurationError extends Error { + constructor(message: string) { + super(message); + this.name = 'UnsupportedConfigurationError'; + } +} + +/** + * Decode a precomputed CDN response into the `FlagCacheEntry` map that `FlagsClient` + * caches and evaluates against — the same shape the native CDN fetch returns today. + * + * The mapping is ~1:1. Two transforms are applied per flag: + * - `value` is the typed `variationValue` as-is (`integer`/`float` are JS `number`s); + * - a string `variationValue` is derived (JSON for objects, `"true"/"false"` for + * booleans, `String(...)` otherwise) because native Android exposure tracking + * rebuilds the flag from the string form. + * + * @throws {UnsupportedConfigurationError} if the response is obfuscated. + */ +export const decodePrecomputedFlags = ( + response: PrecomputedConfigurationResponse +): Record => { + const attributes = response?.data?.attributes; + + if (attributes?.obfuscated) { + // Obfuscated payloads would need key de-hashing / value decoding that this SDK + // does not implement. Fail predictably instead of mis-mapping hashed keys. + throw new UnsupportedConfigurationError( + 'Obfuscated precomputed configurations are not supported.' + ); + } + + const flags = attributes?.flags ?? {}; + const cache: Record = {}; + + for (const [key, flag] of Object.entries(flags)) { + const entry = toFlagCacheEntry(key, flag); + if (entry) { + cache[key] = entry; + } + } + + return cache; +}; + +const toFlagCacheEntry = ( + key: string, + flag: PrecomputedFlag +): FlagCacheEntry | null => { + const { variationType, variationValue } = flag; + + if (!SUPPORTED_VARIATION_TYPES.has(variationType)) { + InternalLog.log( + `Flag "${key}" has unsupported variation type "${variationType}". Omitting it from the configuration.`, + SdkVerbosity.WARN + ); + return null; + } + + if (!valueMatchesVariationType(variationValue, variationType)) { + InternalLog.log( + `Flag "${key}" value does not match its variation type "${variationType}". Omitting it from the configuration.`, + SdkVerbosity.WARN + ); + return null; + } + + return { + key, + value: variationValue, + allocationKey: flag.allocationKey, + variationKey: flag.variationKey, + variationType, + variationValue: stringifyValue(variationValue), + reason: flag.reason, + doLog: flag.doLog, + extraLogging: flag.extraLogging ?? {} + }; +}; + +const valueMatchesVariationType = ( + value: unknown, + variationType: string +): boolean => { + switch (variationType) { + case 'boolean': + return typeof value === 'boolean'; + case 'string': + return typeof value === 'string'; + case 'number': + case 'integer': + case 'float': + return typeof value === 'number'; + case 'object': + return typeof value === 'object' && value !== null; + default: + return false; + } +}; + +/** + * Derive the string form of a flag value expected by native Android exposure tracking. + * Objects/arrays are JSON-encoded; everything else uses `String(...)`, which yields + * lowercase `"true"/"false"` for booleans. + */ +const stringifyValue = (value: unknown): string => { + if (value === null) { + return 'null'; + } + if (typeof value === 'object') { + return JSON.stringify(value); + } + return String(value); +}; diff --git a/packages/core/src/flags/configuration/types.ts b/packages/core/src/flags/configuration/types.ts new file mode 100644 index 000000000..898419072 --- /dev/null +++ b/packages/core/src/flags/configuration/types.ts @@ -0,0 +1,119 @@ +/* + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. + * This product includes software developed at Datadog (https://www.datadoghq.com/). + * Copyright 2016-Present Datadog, Inc. + */ + +/** + * The set of `ConfigurationWire` versions this SDK can parse. A known set (rather + * than a single hardcoded value) leaves room for the future `server`/rules format + * to bump the version without forcing a parser change. + */ +export const SUPPORTED_WIRE_VERSIONS: ReadonlySet = new Set([1]); + +/** + * The flag `variationType`s emitted by the precomputed CDN response. + * + * `integer` and `float` are distinct on the wire but both map to a JavaScript + * `number` when decoded (JS has no int/float distinction). The original string is + * preserved on the {@link FlagCacheEntry} so native exposure tracking round-trips. + */ +export const SUPPORTED_VARIATION_TYPES: ReadonlySet = new Set([ + 'boolean', + 'string', + 'number', + 'integer', + 'float', + 'object' +]); + +/** + * The context an evaluation is performed against, as it appears **on the wire**. + * + * This is the OpenFeature-shaped context: a flat object with an optional + * `targetingKey` and arbitrary sibling attributes. It is intentionally different + * from the SDK's internal `EvaluationContext` (`{ targetingKey, attributes }`); + * callers must normalize before comparing the two. + */ +export type WireEvaluationContext = { + targetingKey?: string; +} & Record; + +/** + * A single precomputed flag assignment as it appears inside the CDN response. + * + * `variationValue` is the already-typed value (e.g. the boolean `false`, the number + * `1.5`, or a JSON object), not a string. + */ +export interface PrecomputedFlag { + variationType: string; + variationValue: unknown; + variationKey: string; + allocationKey: string; + reason: string; + doLog: boolean; + extraLogging?: Record; + serialId?: number | null; +} + +/** + * The precomputed assignments payload returned by the CDN (the JSON that is encoded + * as the `precomputed.response` string on the wire). + */ +export interface PrecomputedConfigurationResponse { + data: { + id?: string; + type?: string; + attributes: { + obfuscated?: boolean; + createdAt?: string; + format?: string; + environment?: { name?: string }; + flags: Record; + }; + }; +} + +/** + * In-memory precomputed configuration: the parsed CDN response plus the metadata + * that travelled alongside it on the wire. + */ +export interface ParsedPrecomputedConfiguration { + /** The parsed CDN response (decoded from the wire's `response` string). */ + response: PrecomputedConfigurationResponse; + /** The evaluation context the assignments were computed for, if any. */ + context?: WireEvaluationContext; + /** Milliseconds since the Unix epoch when the configuration was fetched. */ + fetchedAt?: number; +} + +/** + * The in-memory configuration the SDK operates on, parsed from a `ConfigurationWire` + * string via {@link configurationFromString}. + * + * Named distinctly from the `enable()` options type (`FlagsConfiguration`) to avoid a + * collision. Modelled with an optional `precomputed` branch and reserved space for a + * future `server` (rules/UFC) branch so the type is additive. + */ +export interface ParsedFlagsConfiguration { + precomputed?: ParsedPrecomputedConfiguration; + // server?: ParsedServerConfiguration; // future rules/UFC branch — not implemented +} + +/** + * The serialized `ConfigurationWire` envelope (version 1). Internal to this module; + * the only public entry/exit points are {@link configurationFromString} / + * {@link configurationToString}. + */ +export interface ConfigurationWire { + version: number; + precomputed?: { + response: string; + context?: WireEvaluationContext; + fetchedAt?: number; + }; + server?: { + response: string; + fetchedAt?: number; + }; +} diff --git a/packages/core/src/flags/configuration/wire.ts b/packages/core/src/flags/configuration/wire.ts new file mode 100644 index 000000000..8b41449b2 --- /dev/null +++ b/packages/core/src/flags/configuration/wire.ts @@ -0,0 +1,78 @@ +/* + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. + * This product includes software developed at Datadog (https://www.datadoghq.com/). + * Copyright 2016-Present Datadog, Inc. + */ + +import type { + ConfigurationWire, + ParsedFlagsConfiguration, + PrecomputedConfigurationResponse +} from './types'; +import { SUPPORTED_WIRE_VERSIONS } from './types'; + +/** + * Parse a portable `ConfigurationWire` string into an in-memory + * {@link ParsedFlagsConfiguration}. + * + * Parsing is **lenient**: an empty configuration (`{}`) is returned for malformed + * input or an unsupported wire version rather than throwing. Predictable failure is + * surfaced later, at the `setConfiguration`/provider layer, from an empty/absent + * configuration. + * + * @param wire A `ConfigurationWire` string (as produced by {@link configurationToString}). + */ +export const configurationFromString = ( + wire: string +): ParsedFlagsConfiguration => { + try { + const parsed: ConfigurationWire = JSON.parse(wire); + + if (!SUPPORTED_WIRE_VERSIONS.has(parsed?.version)) { + return {}; + } + + const configuration: ParsedFlagsConfiguration = {}; + + if (parsed.precomputed) { + const response: PrecomputedConfigurationResponse = JSON.parse( + parsed.precomputed.response + ); + + configuration.precomputed = { + response, + context: parsed.precomputed.context, + fetchedAt: parsed.precomputed.fetchedAt + }; + } + + // The `server` (rules/UFC) branch is intentionally not parsed here — it is + // reserved for future work. Leaving it unhandled keeps this MVP precomputed-only. + + return configuration; + } catch { + return {}; + } +}; + +/** + * Serialize an in-memory {@link ParsedFlagsConfiguration} back into a portable + * `ConfigurationWire` string that {@link configurationFromString} can read. + * + * The serialized format is unspecified/opaque and may change between versions. + */ +export const configurationToString = ( + configuration: ParsedFlagsConfiguration +): string => { + const wire: ConfigurationWire = { version: 1 }; + + if (configuration.precomputed) { + wire.precomputed = { + response: JSON.stringify(configuration.precomputed.response), + context: configuration.precomputed.context, + fetchedAt: configuration.precomputed.fetchedAt + }; + } + + return JSON.stringify(wire); +}; From ed08147ded68485c501c3f05ca9cf9293d125b12 Mon Sep 17 00:00:00 2001 From: Blake Date: Tue, 7 Jul 2026 14:10:42 -0400 Subject: [PATCH 2/8] fix(flags): harden precomputed decode against __proto__ keys and array object values Review follow-ups on PR1: - H1: accumulate decoded flags in a Map and materialize with Object.fromEntries, so a flag keyed "__proto__" is stored as data instead of hitting the Object.prototype setter (which silently dropped it and reassigned the proto). - H2: reject array values for object-typed flags (object flags are a JSON object at the root). Arrays are omitted plus logged, like other type mismatches. Adds regression tests for both. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../__tests__/precomputed.test.ts | 27 +++++++++++++++++++ .../src/flags/configuration/precomputed.ts | 17 +++++++++--- 2 files changed, 40 insertions(+), 4 deletions(-) diff --git a/packages/core/src/flags/configuration/__tests__/precomputed.test.ts b/packages/core/src/flags/configuration/__tests__/precomputed.test.ts index cce1fa183..5a0b2ac26 100644 --- a/packages/core/src/flags/configuration/__tests__/precomputed.test.ts +++ b/packages/core/src/flags/configuration/__tests__/precomputed.test.ts @@ -177,4 +177,31 @@ describe('decodePrecomputedFlags', () => { it('returns an empty map when there are no flags', () => { expect(decodePrecomputedFlags(responseWith({}))).toEqual({}); }); + + it('rejects an array value for an object flag', () => { + const cache = decodePrecomputedFlags( + responseWith({ + arr: flag({ + variationType: 'object', + variationValue: [1, 2, 3] + }) + }) + ); + + expect(cache.arr).toBeUndefined(); + expect(InternalLog.log).toHaveBeenCalled(); + }); + + it('stores a flag keyed "__proto__" as data without polluting the prototype', () => { + // Computed key mirrors how JSON.parse yields an own "__proto__" property. + const cache = decodePrecomputedFlags( + responseWith({ ['__proto__']: flag({ variationValue: true }) }) + ); + + // Stored as an own data property, not via the Object.prototype setter. + expect(Object.getPrototypeOf(cache)).toBe(Object.prototype); + expect(Object.keys(cache)).toContain('__proto__'); + // No global prototype pollution. + expect(({} as Record).variationType).toBeUndefined(); + }); }); diff --git a/packages/core/src/flags/configuration/precomputed.ts b/packages/core/src/flags/configuration/precomputed.ts index 7aebb8110..270c28d54 100644 --- a/packages/core/src/flags/configuration/precomputed.ts +++ b/packages/core/src/flags/configuration/precomputed.ts @@ -52,16 +52,20 @@ export const decodePrecomputedFlags = ( } const flags = attributes?.flags ?? {}; - const cache: Record = {}; + // Accumulate in a Map so a pathological flag keyed "__proto__" is stored as data + // instead of hitting the `Object.prototype` "__proto__" setter (which a plain + // `obj[key] = ...` assignment would). `Object.fromEntries` then materializes own + // properties without invoking inherited setters. + const cache = new Map(); for (const [key, flag] of Object.entries(flags)) { const entry = toFlagCacheEntry(key, flag); if (entry) { - cache[key] = entry; + cache.set(key, entry); } } - return cache; + return Object.fromEntries(cache); }; const toFlagCacheEntry = ( @@ -113,7 +117,12 @@ const valueMatchesVariationType = ( case 'float': return typeof value === 'number'; case 'object': - return typeof value === 'object' && value !== null; + // Object flags are a JSON object at the root; arrays are not valid values. + return ( + typeof value === 'object' && + value !== null && + !Array.isArray(value) + ); default: return false; } From a9956b9005a738abf9474a257529dde9712bdae4 Mon Sep 17 00:00:00 2001 From: Blake Date: Tue, 7 Jul 2026 14:36:53 -0400 Subject: [PATCH 3/8] refactor(flags): tighten numeric validation, document serialId/metadata (review M/L) Remaining PR1 review follow-ups: - M1: integer flags require a whole number; number/float require a finite value (reject NaN/Infinity) so native parsers round-trip. - M2: document that serialId is intentionally not propagated (no FlagCacheEntry slot; native snapshot omits it too). - L3: comment that only flags (and obfuscated) are load-bearing; the rest of the response attributes are optional/ignored on purpose. - L1/L5: broaden round-trip fixture (number + nested object flags) and add tests for fractional integer, non-finite number, and a structurally broken response. L2 (distinguishing parse-failure from valid-but-empty) is intentionally left to PR2 (FFL-2688). L4 (configurationToString vs the reference's bug) is correct as-is. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../__tests__/precomputed.test.ts | 29 +++++++++++++++++++ .../configuration/__tests__/wire.test.ts | 18 ++++++++++++ .../src/flags/configuration/precomputed.ts | 11 +++++-- .../core/src/flags/configuration/types.ts | 3 ++ 4 files changed, 59 insertions(+), 2 deletions(-) diff --git a/packages/core/src/flags/configuration/__tests__/precomputed.test.ts b/packages/core/src/flags/configuration/__tests__/precomputed.test.ts index 5a0b2ac26..f6c3b0f28 100644 --- a/packages/core/src/flags/configuration/__tests__/precomputed.test.ts +++ b/packages/core/src/flags/configuration/__tests__/precomputed.test.ts @@ -178,6 +178,35 @@ describe('decodePrecomputedFlags', () => { expect(decodePrecomputedFlags(responseWith({}))).toEqual({}); }); + it('omits an integer flag with a fractional value', () => { + const cache = decodePrecomputedFlags( + responseWith({ + frac: flag({ variationType: 'integer', variationValue: 7.9 }) + }) + ); + + expect(cache.frac).toBeUndefined(); + expect(InternalLog.log).toHaveBeenCalled(); + }); + + it('omits a number flag whose value is not finite', () => { + const cache = decodePrecomputedFlags( + responseWith({ + inf: flag({ variationType: 'number', variationValue: Infinity }) + }) + ); + + expect(cache.inf).toBeUndefined(); + }); + + it('returns an empty map for a structurally broken response', () => { + expect( + decodePrecomputedFlags( + ({} as unknown) as Parameters[0] + ) + ).toEqual({}); + }); + it('rejects an array value for an object flag', () => { const cache = decodePrecomputedFlags( responseWith({ diff --git a/packages/core/src/flags/configuration/__tests__/wire.test.ts b/packages/core/src/flags/configuration/__tests__/wire.test.ts index 199faad68..1e6c9da8a 100644 --- a/packages/core/src/flags/configuration/__tests__/wire.test.ts +++ b/packages/core/src/flags/configuration/__tests__/wire.test.ts @@ -25,6 +25,24 @@ const buildResponse = () => ({ reason: 'STATIC', doLog: false, extraLogging: {} + }, + 'num-flag': { + variationType: 'number', + variationValue: 1.5, + variationKey: '1.5', + allocationKey: 'alloc-2', + reason: 'STATIC', + doLog: true, + extraLogging: {} + }, + 'obj-flag': { + variationType: 'object', + variationValue: { nested: { a: 1 }, list: [1, 2] }, + variationKey: 'obj', + allocationKey: 'alloc-3', + reason: 'TARGETING_MATCH', + doLog: false, + extraLogging: { extra: 'x' } } } } diff --git a/packages/core/src/flags/configuration/precomputed.ts b/packages/core/src/flags/configuration/precomputed.ts index 270c28d54..5ab10bb59 100644 --- a/packages/core/src/flags/configuration/precomputed.ts +++ b/packages/core/src/flags/configuration/precomputed.ts @@ -90,6 +90,9 @@ const toFlagCacheEntry = ( return null; } + // `serialId` is intentionally not propagated: `FlagCacheEntry` has no slot for it + // and the native CDN-fetched snapshot omits it too, so dropping it keeps + // offline/online parity. return { key, value: variationValue, @@ -113,9 +116,13 @@ const valueMatchesVariationType = ( case 'string': return typeof value === 'string'; case 'number': - case 'integer': case 'float': - return typeof value === 'number'; + // Reject NaN/Infinity: native parsers can't round-trip them. + return typeof value === 'number' && Number.isFinite(value); + case 'integer': + // A fractional value under an integer flag would be truncated/mis-parsed + // natively, so require a whole number. + return Number.isInteger(value); case 'object': // Object flags are a JSON object at the root; arrays are not valid values. return ( diff --git a/packages/core/src/flags/configuration/types.ts b/packages/core/src/flags/configuration/types.ts index 898419072..b099812b1 100644 --- a/packages/core/src/flags/configuration/types.ts +++ b/packages/core/src/flags/configuration/types.ts @@ -65,6 +65,9 @@ export interface PrecomputedConfigurationResponse { id?: string; type?: string; attributes: { + // Only `flags` is load-bearing (and `obfuscated`, which gates support). The + // remaining fields are metadata the decoder ignores; typed loosely/optional + // on purpose so payload variation across environments doesn't matter. obfuscated?: boolean; createdAt?: string; format?: string; From 5d2634a1d645e1e5493fd1ac46a1add32b481a82 Mon Sep 17 00:00:00 2001 From: Blake Date: Wed, 8 Jul 2026 09:32:40 -0400 Subject: [PATCH 4/8] refactor(flags): pin ConfigurationWire version to literal 1 (PR1 review) Model the wire version as the literal type `version: 1` instead of `number`, and drop the SUPPORTED_WIRE_VERSIONS Set in favor of a direct `parsed.version !== 1` check. The literal type is now the single, type-checked source for the supported version (configurationToString's `{ version: 1 }` is validated against it), so no separate constant is needed. A future incompatible wire shape should be modeled as a versioned discriminated union rather than a widened number. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/core/src/flags/configuration/types.ts | 9 +-------- packages/core/src/flags/configuration/wire.ts | 7 ++++--- 2 files changed, 5 insertions(+), 11 deletions(-) diff --git a/packages/core/src/flags/configuration/types.ts b/packages/core/src/flags/configuration/types.ts index b099812b1..6d13741ed 100644 --- a/packages/core/src/flags/configuration/types.ts +++ b/packages/core/src/flags/configuration/types.ts @@ -4,13 +4,6 @@ * Copyright 2016-Present Datadog, Inc. */ -/** - * The set of `ConfigurationWire` versions this SDK can parse. A known set (rather - * than a single hardcoded value) leaves room for the future `server`/rules format - * to bump the version without forcing a parser change. - */ -export const SUPPORTED_WIRE_VERSIONS: ReadonlySet = new Set([1]); - /** * The flag `variationType`s emitted by the precomputed CDN response. * @@ -109,7 +102,7 @@ export interface ParsedFlagsConfiguration { * {@link configurationToString}. */ export interface ConfigurationWire { - version: number; + version: 1; precomputed?: { response: string; context?: WireEvaluationContext; diff --git a/packages/core/src/flags/configuration/wire.ts b/packages/core/src/flags/configuration/wire.ts index 8b41449b2..0f91125e4 100644 --- a/packages/core/src/flags/configuration/wire.ts +++ b/packages/core/src/flags/configuration/wire.ts @@ -9,7 +9,6 @@ import type { ParsedFlagsConfiguration, PrecomputedConfigurationResponse } from './types'; -import { SUPPORTED_WIRE_VERSIONS } from './types'; /** * Parse a portable `ConfigurationWire` string into an in-memory @@ -26,9 +25,11 @@ export const configurationFromString = ( wire: string ): ParsedFlagsConfiguration => { try { - const parsed: ConfigurationWire = JSON.parse(wire); + const parsed: Partial = JSON.parse(wire); - if (!SUPPORTED_WIRE_VERSIONS.has(parsed?.version)) { + // Only version 1 is supported. Any other version (or none) is treated as + // an unusable empty configuration rather than throwing. + if (parsed?.version !== 1) { return {}; } From a2c2182a7cb25a7a6b0faa07e512240cc09b7bac Mon Sep 17 00:00:00 2001 From: Blake Date: Wed, 8 Jul 2026 09:51:04 -0400 Subject: [PATCH 5/8] refactor(flags): remove speculative server wire branch (PR1 review) Drop the unimplemented `server` (rules/UFC) branch from ConfigurationWire and ParsedFlagsConfiguration, along with its doc/parse comments and the server-only test. It was speculative future scope (YAGNI) with no reader. The remaining 'no precomputed branch yields empty config' behavior is still covered by the adjacent empty-wire test. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/flags/configuration/__tests__/wire.test.ts | 10 ---------- packages/core/src/flags/configuration/types.ts | 8 +------- packages/core/src/flags/configuration/wire.ts | 3 --- 3 files changed, 1 insertion(+), 20 deletions(-) diff --git a/packages/core/src/flags/configuration/__tests__/wire.test.ts b/packages/core/src/flags/configuration/__tests__/wire.test.ts index 1e6c9da8a..23f1d359b 100644 --- a/packages/core/src/flags/configuration/__tests__/wire.test.ts +++ b/packages/core/src/flags/configuration/__tests__/wire.test.ts @@ -104,16 +104,6 @@ describe('configurationFromString', () => { expect(configurationFromString(wire)).toEqual({}); }); - - it('does not populate a precomputed branch from a server-only wire (MVP)', () => { - const wire = JSON.stringify({ - version: 1, - server: { response: '{}' } - }); - - const config = configurationFromString(wire); - expect(config.precomputed).toBeUndefined(); - }); }); describe('configurationToString round-trip', () => { diff --git a/packages/core/src/flags/configuration/types.ts b/packages/core/src/flags/configuration/types.ts index 6d13741ed..969abfcca 100644 --- a/packages/core/src/flags/configuration/types.ts +++ b/packages/core/src/flags/configuration/types.ts @@ -88,12 +88,10 @@ export interface ParsedPrecomputedConfiguration { * string via {@link configurationFromString}. * * Named distinctly from the `enable()` options type (`FlagsConfiguration`) to avoid a - * collision. Modelled with an optional `precomputed` branch and reserved space for a - * future `server` (rules/UFC) branch so the type is additive. + * collision. */ export interface ParsedFlagsConfiguration { precomputed?: ParsedPrecomputedConfiguration; - // server?: ParsedServerConfiguration; // future rules/UFC branch — not implemented } /** @@ -108,8 +106,4 @@ export interface ConfigurationWire { context?: WireEvaluationContext; fetchedAt?: number; }; - server?: { - response: string; - fetchedAt?: number; - }; } diff --git a/packages/core/src/flags/configuration/wire.ts b/packages/core/src/flags/configuration/wire.ts index 0f91125e4..128ae0136 100644 --- a/packages/core/src/flags/configuration/wire.ts +++ b/packages/core/src/flags/configuration/wire.ts @@ -47,9 +47,6 @@ export const configurationFromString = ( }; } - // The `server` (rules/UFC) branch is intentionally not parsed here — it is - // reserved for future work. Leaving it unhandled keeps this MVP precomputed-only. - return configuration; } catch { return {}; From fdc86cb11cb4f80480b7d8bcd22cad54f7008fdc Mon Sep 17 00:00:00 2001 From: Blake Date: Wed, 8 Jul 2026 10:14:34 -0400 Subject: [PATCH 6/8] refactor(flags): harden precomputed decode and log wire parse failures (PR1 review) - Guard toFlagCacheEntry against non-object flag entries (e.g. a null value) so one malformed flag is skipped with a warning instead of throwing and aborting the whole decode. - Validate the forwarded metadata fields (allocationKey, variationKey, reason, doLog, extraLogging) and omit the flag when their types are wrong, so a corrupt payload cannot propagate bad data into evaluation/tracking. - Destructure the flag once and build the cache entry from the locals for a cleaner return object. - Log an InternalLog WARN when configurationFromString fails to parse, instead of swallowing the error silently. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../__tests__/precomputed.test.ts | 26 ++++++++ .../configuration/__tests__/wire.test.ts | 15 ++++- .../src/flags/configuration/precomputed.ts | 59 ++++++++++++++++--- packages/core/src/flags/configuration/wire.ts | 11 +++- 4 files changed, 100 insertions(+), 11 deletions(-) diff --git a/packages/core/src/flags/configuration/__tests__/precomputed.test.ts b/packages/core/src/flags/configuration/__tests__/precomputed.test.ts index f6c3b0f28..6a4afb7ed 100644 --- a/packages/core/src/flags/configuration/__tests__/precomputed.test.ts +++ b/packages/core/src/flags/configuration/__tests__/precomputed.test.ts @@ -168,6 +168,32 @@ describe('decodePrecomputedFlags', () => { expect(InternalLog.log).toHaveBeenCalled(); }); + it('omits a non-object flag entry and keeps the valid ones', () => { + const cache = decodePrecomputedFlags( + responseWith({ + good: flag({}), + bad: (null as unknown) as PrecomputedFlag + }) + ); + + expect(cache.good).toBeDefined(); + expect(cache.bad).toBeUndefined(); + expect(InternalLog.log).toHaveBeenCalled(); + }); + + it('omits a flag with malformed metadata field types', () => { + const cache = decodePrecomputedFlags( + responseWith({ + badReason: flag({ reason: (42 as unknown) as string }), + badDoLog: flag({ doLog: ('yes' as unknown) as boolean }) + }) + ); + + expect(cache.badReason).toBeUndefined(); + expect(cache.badDoLog).toBeUndefined(); + expect(InternalLog.log).toHaveBeenCalled(); + }); + it('throws UnsupportedConfigurationError for an obfuscated response', () => { expect(() => decodePrecomputedFlags(responseWith({ f: flag({}) }, true)) diff --git a/packages/core/src/flags/configuration/__tests__/wire.test.ts b/packages/core/src/flags/configuration/__tests__/wire.test.ts index 23f1d359b..0ae2f056d 100644 --- a/packages/core/src/flags/configuration/__tests__/wire.test.ts +++ b/packages/core/src/flags/configuration/__tests__/wire.test.ts @@ -4,9 +4,21 @@ * Copyright 2016-Present Datadog, Inc. */ +import { InternalLog } from '../../../InternalLog'; import type { ParsedFlagsConfiguration } from '../types'; import { configurationFromString, configurationToString } from '../wire'; +jest.mock('../../../InternalLog', () => { + return { + InternalLog: { log: jest.fn() }, + DATADOG_MESSAGE_PREFIX: 'DATADOG:' + }; +}); + +beforeEach(() => { + jest.clearAllMocks(); +}); + const buildResponse = () => ({ data: { id: '2', @@ -86,8 +98,9 @@ describe('configurationFromString', () => { expect(configurationFromString(wire)).toEqual({}); }); - it('returns an empty config for invalid JSON', () => { + it('returns an empty config and logs a warning for invalid JSON', () => { expect(configurationFromString('not json')).toEqual({}); + expect(InternalLog.log).toHaveBeenCalled(); }); it('returns an empty config when the inner response is invalid JSON', () => { diff --git a/packages/core/src/flags/configuration/precomputed.ts b/packages/core/src/flags/configuration/precomputed.ts index 5ab10bb59..8951ed1aa 100644 --- a/packages/core/src/flags/configuration/precomputed.ts +++ b/packages/core/src/flags/configuration/precomputed.ts @@ -70,13 +70,37 @@ export const decodePrecomputedFlags = ( const toFlagCacheEntry = ( key: string, - flag: PrecomputedFlag + flag: unknown ): FlagCacheEntry | null => { - const { variationType, variationValue } = flag; + // A malformed payload can carry a non-object flag (e.g. `flags: { "k": null }`). + // Skip the bad entry with a warning instead of throwing and aborting decoding of + // the whole configuration. + if (typeof flag !== 'object' || flag === null) { + InternalLog.log( + `Flag "${key}" is not an object. Omitting it from the configuration.`, + SdkVerbosity.WARN + ); + return null; + } + + const { + variationType, + variationValue, + variationKey, + allocationKey, + reason, + doLog, + extraLogging + } = flag as Partial; - if (!SUPPORTED_VARIATION_TYPES.has(variationType)) { + if ( + typeof variationType !== 'string' || + !SUPPORTED_VARIATION_TYPES.has(variationType) + ) { InternalLog.log( - `Flag "${key}" has unsupported variation type "${variationType}". Omitting it from the configuration.`, + `Flag "${key}" has an unsupported variation type "${String( + variationType + )}". Omitting it from the configuration.`, SdkVerbosity.WARN ); return null; @@ -90,19 +114,36 @@ const toFlagCacheEntry = ( return null; } + // The remaining fields feed evaluation and native exposure tracking. A corrupt + // payload could carry wrong types here, so validate before forwarding them. + if ( + typeof allocationKey !== 'string' || + typeof variationKey !== 'string' || + typeof reason !== 'string' || + typeof doLog !== 'boolean' || + (extraLogging !== undefined && + (typeof extraLogging !== 'object' || extraLogging === null)) + ) { + InternalLog.log( + `Flag "${key}" has malformed metadata. Omitting it from the configuration.`, + SdkVerbosity.WARN + ); + return null; + } + // `serialId` is intentionally not propagated: `FlagCacheEntry` has no slot for it // and the native CDN-fetched snapshot omits it too, so dropping it keeps // offline/online parity. return { key, value: variationValue, - allocationKey: flag.allocationKey, - variationKey: flag.variationKey, + allocationKey, + variationKey, variationType, variationValue: stringifyValue(variationValue), - reason: flag.reason, - doLog: flag.doLog, - extraLogging: flag.extraLogging ?? {} + reason, + doLog, + extraLogging: extraLogging ?? {} }; }; diff --git a/packages/core/src/flags/configuration/wire.ts b/packages/core/src/flags/configuration/wire.ts index 128ae0136..d8691349b 100644 --- a/packages/core/src/flags/configuration/wire.ts +++ b/packages/core/src/flags/configuration/wire.ts @@ -4,6 +4,9 @@ * Copyright 2016-Present Datadog, Inc. */ +import { InternalLog } from '../../InternalLog'; +import { SdkVerbosity } from '../../config/types/SdkVerbosity'; + import type { ConfigurationWire, ParsedFlagsConfiguration, @@ -48,7 +51,13 @@ export const configurationFromString = ( } return configuration; - } catch { + } catch (error) { + InternalLog.log( + `Failed to parse the ConfigurationWire string: ${ + error instanceof Error ? error.message : String(error) + }`, + SdkVerbosity.WARN + ); return {}; } }; From 10097190c4d4ea857c5bf3c4aae1931a592020f9 Mon Sep 17 00:00:00 2001 From: Blake Date: Wed, 8 Jul 2026 10:30:15 -0400 Subject: [PATCH 7/8] fix(flags): accept any JSON value for object-typed flags (PR1 review) The decoder previously required an object variation value to be a non-array, non-null object. ffe-service enforces a top-level object at the API layer, but that is not a storage constraint, so a precomputed payload could carry any JSON value. Accept it defensively. Traced the downstream paths: stringifyValue handles arrays/null/primitives, and getDetails validates the value type at evaluation time (typeof flag.value), so a non-object value under an object flag falls back to the default via TYPE_MISMATCH rather than mis-serving. Native tracking only runs post type-check. No path throws. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../__tests__/precomputed.test.ts | 20 ++++++++++++++++--- .../src/flags/configuration/precomputed.ts | 12 +++++------ 2 files changed, 23 insertions(+), 9 deletions(-) diff --git a/packages/core/src/flags/configuration/__tests__/precomputed.test.ts b/packages/core/src/flags/configuration/__tests__/precomputed.test.ts index 6a4afb7ed..28b2e6928 100644 --- a/packages/core/src/flags/configuration/__tests__/precomputed.test.ts +++ b/packages/core/src/flags/configuration/__tests__/precomputed.test.ts @@ -233,18 +233,32 @@ describe('decodePrecomputedFlags', () => { ).toEqual({}); }); - it('rejects an array value for an object flag', () => { + it('accepts any JSON value for an object flag (array, null, primitive)', () => { + // ffe-service enforces a top-level object at the API layer, but that is not a + // storage constraint, so the decoder accepts whatever JSON arrives here. const cache = decodePrecomputedFlags( responseWith({ arr: flag({ variationType: 'object', variationValue: [1, 2, 3] + }), + nul: flag({ + variationType: 'object', + variationValue: null + }), + str: flag({ + variationType: 'object', + variationValue: 'hi' }) }) ); - expect(cache.arr).toBeUndefined(); - expect(InternalLog.log).toHaveBeenCalled(); + expect(cache.arr.value).toEqual([1, 2, 3]); + expect(cache.arr.variationValue).toBe('[1,2,3]'); + expect(cache.nul.value).toBeNull(); + expect(cache.nul.variationValue).toBe('null'); + expect(cache.str.value).toBe('hi'); + expect(cache.str.variationValue).toBe('hi'); }); it('stores a flag keyed "__proto__" as data without polluting the prototype', () => { diff --git a/packages/core/src/flags/configuration/precomputed.ts b/packages/core/src/flags/configuration/precomputed.ts index 8951ed1aa..c8d6bd14c 100644 --- a/packages/core/src/flags/configuration/precomputed.ts +++ b/packages/core/src/flags/configuration/precomputed.ts @@ -165,12 +165,12 @@ const valueMatchesVariationType = ( // natively, so require a whole number. return Number.isInteger(value); case 'object': - // Object flags are a JSON object at the root; arrays are not valid values. - return ( - typeof value === 'object' && - value !== null && - !Array.isArray(value) - ); + // The `object` variation type carries arbitrary JSON — objects, arrays, + // numbers, strings, booleans, or null. ffe-service enforces a top-level + // object at the API layer, but that is not a storage constraint, so a + // payload could still carry any JSON value here. Accept it and rely on the + // value being defended downstream (string form + evaluation both tolerate it). + return true; default: return false; } From 199fb8085e816fd6e37eff0f847acde9318e6e13 Mon Sep 17 00:00:00 2001 From: Blake Date: Wed, 8 Jul 2026 13:28:34 -0400 Subject: [PATCH 8/8] refactor(flags): reuse @datadog/flagging-core wire types and parser (PR1 review) Depend on @datadog/flagging-core (and @openfeature/core, which its published types reference) and reuse its canonical wire types and configurationFromString instead of maintaining our own copies. Our local type names are kept as aliases so the rest of the SDK is insulated from upstream naming. configurationToString stays local for now, with a loud TODO to adopt flagging-core's once the next major (>= 2.0.0) ships the fix from openfeature-js-client PR #331 (its 1.2.x serializer is broken). Consequences of delegating to flagging-core's parser: the bespoke parse-failure InternalLog warning is dropped (its parser swallows errors), and obfuscated is read via a small cast since it is absent from flagging-core's response type. integer/float variation types are kept as defensive runtime handling even though the CDN and flagging-core model only boolean/string/number/object. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/core/package.json | 2 + .../configuration/__tests__/wire.test.ts | 15 +--- .../src/flags/configuration/precomputed.ts | 8 +- .../core/src/flags/configuration/types.ts | 90 +++++-------------- packages/core/src/flags/configuration/wire.ts | 71 ++++----------- yarn.lock | 18 ++++ 6 files changed, 62 insertions(+), 142 deletions(-) diff --git a/packages/core/package.json b/packages/core/package.json index 2658db3c0..5e53d53fe 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -69,6 +69,7 @@ "react-native": ">=0.63.4 <1.0" }, "devDependencies": { + "@openfeature/core": "^1.9.2", "@testing-library/react-native": "7.0.2", "react-native-builder-bob": "0.26.0" }, @@ -116,6 +117,7 @@ } }, "dependencies": { + "@datadog/flagging-core": "^1.2.1", "big-integer": "^1.6.52" } } diff --git a/packages/core/src/flags/configuration/__tests__/wire.test.ts b/packages/core/src/flags/configuration/__tests__/wire.test.ts index 0ae2f056d..23f1d359b 100644 --- a/packages/core/src/flags/configuration/__tests__/wire.test.ts +++ b/packages/core/src/flags/configuration/__tests__/wire.test.ts @@ -4,21 +4,9 @@ * Copyright 2016-Present Datadog, Inc. */ -import { InternalLog } from '../../../InternalLog'; import type { ParsedFlagsConfiguration } from '../types'; import { configurationFromString, configurationToString } from '../wire'; -jest.mock('../../../InternalLog', () => { - return { - InternalLog: { log: jest.fn() }, - DATADOG_MESSAGE_PREFIX: 'DATADOG:' - }; -}); - -beforeEach(() => { - jest.clearAllMocks(); -}); - const buildResponse = () => ({ data: { id: '2', @@ -98,9 +86,8 @@ describe('configurationFromString', () => { expect(configurationFromString(wire)).toEqual({}); }); - it('returns an empty config and logs a warning for invalid JSON', () => { + it('returns an empty config for invalid JSON', () => { expect(configurationFromString('not json')).toEqual({}); - expect(InternalLog.log).toHaveBeenCalled(); }); it('returns an empty config when the inner response is invalid JSON', () => { diff --git a/packages/core/src/flags/configuration/precomputed.ts b/packages/core/src/flags/configuration/precomputed.ts index c8d6bd14c..d795c7c6e 100644 --- a/packages/core/src/flags/configuration/precomputed.ts +++ b/packages/core/src/flags/configuration/precomputed.ts @@ -43,9 +43,11 @@ export const decodePrecomputedFlags = ( ): Record => { const attributes = response?.data?.attributes; - if (attributes?.obfuscated) { - // Obfuscated payloads would need key de-hashing / value decoding that this SDK - // does not implement. Fail predictably instead of mis-mapping hashed keys. + // `obfuscated` is not part of flagging-core's response type, but the CDN payload + // carries it. Read it defensively so obfuscated payloads are still rejected: + // de-hashing keys / decoding values is not implemented here, so fail predictably + // instead of mis-mapping hashed keys. + if ((attributes as { obfuscated?: boolean } | undefined)?.obfuscated) { throw new UnsupportedConfigurationError( 'Obfuscated precomputed configurations are not supported.' ); diff --git a/packages/core/src/flags/configuration/types.ts b/packages/core/src/flags/configuration/types.ts index 969abfcca..0a21b8863 100644 --- a/packages/core/src/flags/configuration/types.ts +++ b/packages/core/src/flags/configuration/types.ts @@ -4,12 +4,18 @@ * Copyright 2016-Present Datadog, Inc. */ +import type { + FlagsConfiguration, + PrecomputedConfiguration, + PrecomputedConfigurationResponse as FlaggingCorePrecomputedConfigurationResponse, + PrecomputedFlag as FlaggingCorePrecomputedFlag +} from '@datadog/flagging-core'; + /** - * The flag `variationType`s emitted by the precomputed CDN response. - * - * `integer` and `float` are distinct on the wire but both map to a JavaScript - * `number` when decoded (JS has no int/float distinction). The original string is - * preserved on the {@link FlagCacheEntry} so native exposure tracking round-trips. + * The flag `variationType`s the decoder accepts. `@datadog/flagging-core` models the + * type as OpenFeature's `boolean | string | number | object`, and the CDN confirms only + * those are emitted. `integer`/`float` are kept here defensively — the decoder validates + * untrusted payloads and treats both as JavaScript `number`s. */ export const SUPPORTED_VARIATION_TYPES: ReadonlySet = new Set([ 'boolean', @@ -32,72 +38,18 @@ export type WireEvaluationContext = { targetingKey?: string; } & Record; -/** - * A single precomputed flag assignment as it appears inside the CDN response. - * - * `variationValue` is the already-typed value (e.g. the boolean `false`, the number - * `1.5`, or a JSON object), not a string. - */ -export interface PrecomputedFlag { - variationType: string; - variationValue: unknown; - variationKey: string; - allocationKey: string; - reason: string; - doLog: boolean; - extraLogging?: Record; - serialId?: number | null; -} - -/** - * The precomputed assignments payload returned by the CDN (the JSON that is encoded - * as the `precomputed.response` string on the wire). - */ -export interface PrecomputedConfigurationResponse { - data: { - id?: string; - type?: string; - attributes: { - // Only `flags` is load-bearing (and `obfuscated`, which gates support). The - // remaining fields are metadata the decoder ignores; typed loosely/optional - // on purpose so payload variation across environments doesn't matter. - obfuscated?: boolean; - createdAt?: string; - format?: string; - environment?: { name?: string }; - flags: Record; - }; - }; -} - -/** - * In-memory precomputed configuration: the parsed CDN response plus the metadata - * that travelled alongside it on the wire. - */ -export interface ParsedPrecomputedConfiguration { - /** The parsed CDN response (decoded from the wire's `response` string). */ - response: PrecomputedConfigurationResponse; - /** The evaluation context the assignments were computed for, if any. */ - context?: WireEvaluationContext; - /** Milliseconds since the Unix epoch when the configuration was fetched. */ - fetchedAt?: number; -} - -/** - * The in-memory configuration the SDK operates on, parsed from a `ConfigurationWire` - * string via {@link configurationFromString}. - * - * Named distinctly from the `enable()` options type (`FlagsConfiguration`) to avoid a - * collision. - */ -export interface ParsedFlagsConfiguration { - precomputed?: ParsedPrecomputedConfiguration; -} +// The wire/precomputed types are re-exported from `@datadog/flagging-core` so this SDK +// shares the canonical shapes instead of maintaining its own copies. Local names are +// kept so the rest of the SDK is insulated from the upstream naming. +export type PrecomputedFlag = FlaggingCorePrecomputedFlag; +export type PrecomputedConfigurationResponse = FlaggingCorePrecomputedConfigurationResponse; +export type ParsedPrecomputedConfiguration = PrecomputedConfiguration; +export type ParsedFlagsConfiguration = FlagsConfiguration; /** - * The serialized `ConfigurationWire` envelope (version 1). Internal to this module; - * the only public entry/exit points are {@link configurationFromString} / - * {@link configurationToString}. + * The serialized `ConfigurationWire` envelope (version 1). `@datadog/flagging-core` + * keeps its own wire envelope internal, so this mirrors the shape for our local + * {@link configurationToString} (see wire.ts). */ export interface ConfigurationWire { version: 1; diff --git a/packages/core/src/flags/configuration/wire.ts b/packages/core/src/flags/configuration/wire.ts index d8691349b..3608370cf 100644 --- a/packages/core/src/flags/configuration/wire.ts +++ b/packages/core/src/flags/configuration/wire.ts @@ -4,69 +4,28 @@ * Copyright 2016-Present Datadog, Inc. */ -import { InternalLog } from '../../InternalLog'; -import { SdkVerbosity } from '../../config/types/SdkVerbosity'; +import { configurationFromString } from '@datadog/flagging-core'; -import type { - ConfigurationWire, - ParsedFlagsConfiguration, - PrecomputedConfigurationResponse -} from './types'; +import type { ConfigurationWire, ParsedFlagsConfiguration } from './types'; -/** - * Parse a portable `ConfigurationWire` string into an in-memory - * {@link ParsedFlagsConfiguration}. - * - * Parsing is **lenient**: an empty configuration (`{}`) is returned for malformed - * input or an unsupported wire version rather than throwing. Predictable failure is - * surfaced later, at the `setConfiguration`/provider layer, from an empty/absent - * configuration. - * - * @param wire A `ConfigurationWire` string (as produced by {@link configurationToString}). - */ -export const configurationFromString = ( - wire: string -): ParsedFlagsConfiguration => { - try { - const parsed: Partial = JSON.parse(wire); - - // Only version 1 is supported. Any other version (or none) is treated as - // an unusable empty configuration rather than throwing. - if (parsed?.version !== 1) { - return {}; - } - - const configuration: ParsedFlagsConfiguration = {}; - - if (parsed.precomputed) { - const response: PrecomputedConfigurationResponse = JSON.parse( - parsed.precomputed.response - ); - - configuration.precomputed = { - response, - context: parsed.precomputed.context, - fetchedAt: parsed.precomputed.fetchedAt - }; - } - - return configuration; - } catch (error) { - InternalLog.log( - `Failed to parse the ConfigurationWire string: ${ - error instanceof Error ? error.message : String(error) - }`, - SdkVerbosity.WARN - ); - return {}; - } -}; +// Parsing is reused from `@datadog/flagging-core` (the canonical wire implementation) +// rather than reimplemented here. It is lenient: it returns an empty configuration +// (`{}`) for malformed input or an unsupported wire version rather than throwing. +export { configurationFromString }; /** * Serialize an in-memory {@link ParsedFlagsConfiguration} back into a portable - * `ConfigurationWire` string that {@link configurationFromString} can read. + * `ConfigurationWire` string that `configurationFromString` can read. * * The serialized format is unspecified/opaque and may change between versions. + * + * TODO: replace this with `@datadog/flagging-core`'s `configurationToString` once the + * next major version (>= 2.0.0) lands. flagging-core 1.2.x has a broken serializer + * (it stringifies the whole `precomputed` object into `precomputed.response` instead of + * just `.response`, which double-nests the response and drops every flag on a + * serialize→parse round-trip — https://github.com/DataDog/openfeature-js-client/pull/331). + * Until the fix ships, we keep this correct local copy and depend on flagging-core only + * for `configurationFromString`. */ export const configurationToString = ( configuration: ParsedFlagsConfiguration diff --git a/yarn.lock b/yarn.lock index 47f5dbc44..26788d988 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2722,6 +2722,15 @@ __metadata: languageName: node linkType: hard +"@datadog/flagging-core@npm:^1.2.1": + version: 1.2.1 + resolution: "@datadog/flagging-core@npm:1.2.1" + dependencies: + spark-md5: ^3.0.2 + checksum: 714e92b0fc43d9d14b79d9d235da0c97522332f3ba384ca8155b5c35817967286d012096282ee6975bf43d550358e750131ab0c3b00977dca2ffb0d5d9c3cd38 + languageName: node + linkType: hard + "@datadog/libdatadog@npm:^0.6.0": version: 0.6.0 resolution: "@datadog/libdatadog@npm:0.6.0" @@ -2863,6 +2872,8 @@ __metadata: version: 0.0.0-use.local resolution: "@datadog/mobile-react-native@workspace:packages/core" dependencies: + "@datadog/flagging-core": ^1.2.1 + "@openfeature/core": ^1.9.2 "@testing-library/react-native": 7.0.2 big-integer: ^1.6.52 react-native-builder-bob: 0.26.0 @@ -18126,6 +18137,13 @@ __metadata: languageName: node linkType: hard +"spark-md5@npm:^3.0.2": + version: 3.0.2 + resolution: "spark-md5@npm:3.0.2" + checksum: 5feebff0bfabcecf56ba03af3e38fdb068272ed41fbf0a94ff9ef65b9bb9cb1dd69be3684db6542e62497b1eac3ae324c07ac4dcb606465dc36ca048177077bf + languageName: node + linkType: hard + "spdx-correct@npm:^3.0.0": version: 3.2.0 resolution: "spdx-correct@npm:3.2.0"