Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
301 changes: 298 additions & 3 deletions packages/core/src/flags/FlagsClient.ts

Large diffs are not rendered by default.

519 changes: 519 additions & 0 deletions packages/core/src/flags/__tests__/FlagsClient.test.ts

Large diffs are not rendered by default.

45 changes: 45 additions & 0 deletions packages/core/src/flags/__tests__/internal.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
/*
* 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 { processEvaluationContext } from '../internal';

jest.mock('../../InternalLog', () => {
return {
InternalLog: { log: jest.fn() },
DATADOG_MESSAGE_PREFIX: 'DATADOG:'
};
});

describe('processEvaluationContext', () => {
it('keeps primitive attributes and drops non-primitive ones', () => {
expect(
processEvaluationContext({
targetingKey: 'user-1',
attributes: {
country: 'US',
age: 25,
beta: true,
// Dropped: non-primitive.
nested: { a: 1 } as never
}
})
).toEqual({
targetingKey: 'user-1',
attributes: { country: 'US', age: 25, beta: true }
});
});

it('does not null the prototype for a "__proto__": null attribute', () => {
const result = processEvaluationContext({
targetingKey: 'user-1',
attributes: { ['__proto__']: null }
});

// A plain `attributes[key] = value` would have set the object's prototype to
// null here; the Map + Object.fromEntries build keeps it a normal object.
expect(Object.getPrototypeOf(result.attributes)).toBe(Object.prototype);
});
});
120 changes: 120 additions & 0 deletions packages/core/src/flags/configuration/__tests__/context.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
/*
* 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 { EvaluationContext } from '../../types';
import { contextMatchesConfiguration, normalizeWireContext } from '../context';

jest.mock('../../../InternalLog', () => {
return {
InternalLog: { log: jest.fn() },
DATADOG_MESSAGE_PREFIX: 'DATADOG:'
};
});

describe('normalizeWireContext', () => {
it('maps a flat wire context to { targetingKey, attributes }', () => {
expect(
normalizeWireContext({
targetingKey: 'user-1',
country: 'US',
age: 25
})
).toEqual({
targetingKey: 'user-1',
attributes: { country: 'US', age: 25 }
});
});

it('defaults a missing targeting key to an empty string', () => {
expect(normalizeWireContext({ country: 'US' })).toEqual({
targetingKey: '',
attributes: { country: 'US' }
});
});

it('drops non-primitive attributes', () => {
expect(
normalizeWireContext({
targetingKey: 'user-1',
country: 'US',
nested: { a: 1 }
})
).toEqual({ targetingKey: 'user-1', attributes: { country: 'US' } });
});

it('handles a "__proto__" attribute without polluting the prototype', () => {
const normalized = normalizeWireContext({
targetingKey: 'user-1',
['__proto__']: 'x'
});

// A reserved "__proto__" attribute does not pollute the prototype: the normalized
// attributes keep `Object.prototype` and nothing leaks onto the global prototype.
// (Whether the key is retained or dropped is an implementation detail we don't assert.)
expect(Object.getPrototypeOf(normalized.attributes)).toBe(
Object.prototype
);
expect(({} as Record<string, unknown>).x).toBeUndefined();
});
});

describe('contextMatchesConfiguration', () => {
const active: EvaluationContext = {
targetingKey: 'user-1',
attributes: { country: 'US' }
};

it('matches any context when the config has no embedded context', () => {
expect(contextMatchesConfiguration(undefined, active)).toBe(true);
});

it('matches an equal context', () => {
expect(
contextMatchesConfiguration(
{ targetingKey: 'user-1', country: 'US' },
active
)
).toBe(true);
});

it('does not match a different targeting key', () => {
expect(
contextMatchesConfiguration(
{ targetingKey: 'user-2', country: 'US' },
active
)
).toBe(false);
});

it('does not match a different attribute value', () => {
expect(
contextMatchesConfiguration(
{ targetingKey: 'user-1', country: 'CA' },
active
)
).toBe(false);
});

it('does not match when the wire has an extra primitive attribute', () => {
expect(
contextMatchesConfiguration(
{ targetingKey: 'user-1', country: 'US', plan: 'pro' },
active
)
).toBe(false);
});

it('ignores dropped non-primitive attributes on both sides', () => {
// The nested attribute is dropped by the same normalization applied to the
// active context, so a wire context that only differs by it still matches.
expect(
contextMatchesConfiguration(
{ targetingKey: 'user-1', country: 'US', nested: { a: 1 } },
active
)
).toBe(true);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -225,12 +225,22 @@ describe('decodePrecomputedFlags', () => {
expect(cache.get('inf')).toBeUndefined();
});

it('returns an empty map for a structurally broken response', () => {
expect(
it.each([
['a null response', null],
['a non-object response', 'nonsense'],
['a missing data envelope', {}],
['a missing attributes envelope', { data: {} }],
['a missing flags map', { data: { attributes: {} } }],
['a null flags map', { data: { attributes: { flags: null } } }],
['an array flags map', { data: { attributes: { flags: [] } } }]
])('throws for a structurally malformed response (%s)', (_label, input) => {
expect(() =>
decodePrecomputedFlags(
({} as unknown) as Parameters<typeof decodePrecomputedFlags>[0]
).size
).toBe(0);
(input as unknown) as Parameters<
typeof decodePrecomputedFlags
>[0]
)
).toThrow(UnsupportedConfigurationError);
});

it('accepts any JSON value for an object flag (array, null, primitive)', () => {
Expand Down
76 changes: 76 additions & 0 deletions packages/core/src/flags/configuration/context.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
/*
* 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 { processEvaluationContext } from '../internal';
import type { EvaluationContext, PrimitiveValue } from '../types';

import type { WireEvaluationContext } from './types';

/**
* Normalize a wire (OpenFeature-flat) evaluation context into the SDK's internal
* `{ targetingKey, attributes }` shape, applying the **same** processing the active
* context went through (`processEvaluationContext`) so the two are comparable — the
* flat wire shape and the internal shape are otherwise never equal.
*/
export const normalizeWireContext = (
wireContext: WireEvaluationContext
): EvaluationContext => {
const { targetingKey, ...attributes } = wireContext;

return processEvaluationContext({
// The wire is untrusted, so a non-string targetingKey is treated as absent.
targetingKey: typeof targetingKey === 'string' ? targetingKey : '',
// `processEvaluationContext` drops non-primitive attributes; casting here mirrors
// how the active context's attributes are typed before that same processing.
attributes: attributes as Record<string, PrimitiveValue>
});
};

/**
* Whether a precomputed configuration's embedded context matches the active evaluation
* context.
*
* - A configuration with **no** embedded context is context-agnostic and matches any
* active context.
* - Otherwise the embedded context must match the active context exactly (after
* normalizing both through the same processing).
*/
export const contextMatchesConfiguration = (
wireContext: WireEvaluationContext | undefined,
activeContext: EvaluationContext
): boolean => {
if (!wireContext) {
return true;
}

return contextsEqual(normalizeWireContext(wireContext), activeContext);
};

const contextsEqual = (a: EvaluationContext, b: EvaluationContext): boolean => {
if (a.targetingKey !== b.targetingKey) {
return false;
}

return attributesEqual(a.attributes ?? {}, b.attributes ?? {});
};

/**
* Compare two attribute maps. After `processEvaluationContext`, attribute values are
* primitives, so a key-set + strict-value comparison is sufficient.
*/
const attributesEqual = (
a: Record<string, PrimitiveValue>,
b: Record<string, PrimitiveValue>
): boolean => {
const aKeys = Object.keys(a);
const bKeys = Object.keys(b);

if (aKeys.length !== bKeys.length) {
return false;
}

return aKeys.every(key => a[key] === b[key]);
};
3 changes: 3 additions & 0 deletions packages/core/src/flags/configuration/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,9 @@ export {
decodePrecomputedFlags,
UnsupportedConfigurationError
} from './precomputed';
// `contextMatchesConfiguration` is intentionally NOT re-exported — it is an internal
// helper consumed directly by `FlagsClient` (see its import from `./configuration/context`).
export { normalizeWireContext } from './context';
export type {
ParsedFlagsConfiguration,
ParsedPrecomputedConfiguration,
Expand Down
20 changes: 18 additions & 2 deletions packages/core/src/flags/configuration/precomputed.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,8 @@ export class UnsupportedConfigurationError extends Error {
* booleans, `String(...)` otherwise) because native Android exposure tracking
* rebuilds the flag from the string form.
*
* @throws {UnsupportedConfigurationError} if the response is obfuscated.
* @throws {UnsupportedConfigurationError} if the response is obfuscated, or its envelope is
* structurally malformed (missing/non-object `data.attributes.flags`).
*/
export const decodePrecomputedFlags = (
response: PrecomputedConfigurationResponse
Expand All @@ -53,7 +54,17 @@ export const decodePrecomputedFlags = (
);
}

const flags = attributes?.flags ?? {};
// Validate the response envelope before trusting it. An untrusted wire can be JSON-valid yet
// structurally malformed (a null/non-object envelope, or a `flags` that is null or an array).
// Fail predictably so the caller classifies it as an error instead of silently decoding it to
// an empty — but "ready" — configuration. A genuinely empty `flags: {}` is still accepted.
const flags = attributes?.flags;
if (!isFlagsMap(flags)) {
throw new UnsupportedConfigurationError(
"Malformed precomputed configuration: 'data.attributes.flags' must be an object."
);
}

// A Map (returned as-is) so a pathological flag keyed "__proto__" is stored as data
// and later looked up via `.get()` — never hitting the `Object.prototype` "__proto__"
// setter (on write) or the prototype chain (on read) the way a plain object would.
Expand All @@ -69,6 +80,11 @@ export const decodePrecomputedFlags = (
return cache;
};

// A well-formed flags container is a plain object (a possibly-empty map of flag key -> flag).
// `null`, arrays, and primitives are malformed envelopes.
const isFlagsMap = (value: unknown): value is Record<string, unknown> =>
typeof value === 'object' && value !== null && !Array.isArray(value);

const toFlagCacheEntry = (
key: string,
flag: unknown
Expand Down
9 changes: 6 additions & 3 deletions packages/core/src/flags/internal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,10 @@ export const processEvaluationContext = (
const providedAttributes: Record<string, unknown> =
context.attributes ?? {};

const attributes: Record<string, PrimitiveValue> = {};
// Accumulate in a Map so reserved keys such as "__proto__" are handled as data
// instead of hitting the Object.prototype setter (which would silently drop them or
// pollute the prototype). Object.fromEntries then materializes own properties safely.
const attributes = new Map<string, PrimitiveValue>();

for (const [key, value] of Object.entries(providedAttributes)) {
const isPrimitiveValue =
Expand All @@ -53,11 +56,11 @@ export const processEvaluationContext = (
continue;
}

attributes[key] = value;
attributes.set(key, value as PrimitiveValue);
}

return {
targetingKey,
attributes
attributes: Object.fromEntries(attributes)
};
};
4 changes: 3 additions & 1 deletion packages/core/src/flags/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -169,7 +169,9 @@ type FlagErrorCode =
| 'PROVIDER_NOT_READY'
| 'FLAG_NOT_FOUND'
| 'PARSE_ERROR'
| 'TYPE_MISMATCH';
| 'TYPE_MISMATCH'
| 'INVALID_CONTEXT'
| 'GENERAL';

/**
* Detailed information about a feature flag evaluation.
Expand Down