From 2051bcae654e14af0811ba750e595589ffee6994 Mon Sep 17 00:00:00 2001 From: Blake Date: Tue, 7 Jul 2026 13:17:00 -0400 Subject: [PATCH 1/3] 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 b509254341e2546c3f668deccc0b7679240e5bea Mon Sep 17 00:00:00 2001 From: Blake Date: Tue, 7 Jul 2026 14:10:42 -0400 Subject: [PATCH 2/3] 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 2d2082589613c63e199b7f00ea2205b04ed5e57a Mon Sep 17 00:00:00 2001 From: Blake Date: Tue, 7 Jul 2026 14:36:53 -0400 Subject: [PATCH 3/3] 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;