diff --git a/README.md b/README.md index 0224c0714..dce77ddd2 100644 --- a/README.md +++ b/README.md @@ -136,6 +136,34 @@ const { accessToken } = await workos.userManagement.authenticateWithCode({ }); ``` +## Feature flag runtime + +The runtime client polls the v2 flag payload and evaluates flags locally. When a +flag is off it serves `off_value`. Otherwise rules are evaluated in array order: +the first matching rule's `value` wins, and `default_value` applies when no rule +matches. A matching rule can serve `false`. Every condition in a rule must match; +`one_of` matches an exact ID for its target type. Unknown rule kinds and condition +operators never match. + +`isEnabled(key, context, defaultValue)` returns `defaultValue` (`false` when +omitted) if the flag is missing or the served value is not a boolean. +`getAllFlags` uses `false` in that case. + +`getFlag(key)` returns the flag's `FlagPollEntry`: `slug`, `enabled`, +`default_value`, `off_value` and `rules`. The `previous` and `current` values in +`change` events have the same shape. Reordering rules emits a `change` event; +reordering IDs within a condition does not. + +To start from saved configuration, pass a `FlagPollResponse` +(`{ version: 2, flags: { [slug]: FlagPollEntry } }`) as `bootstrapFlags`. +Unrecognized bootstrap data, including a bare map of entries without the +envelope, is ignored with a warning to `logger` and readiness still resolves; +polling populates the cache afterward. + +A failed poll, including one that returns a payload the SDK can't read, emits +`error`, keeps the last good snapshot, and retries with backoff. A 401 stops +polling and also emits `failed`. + ## SDK Versioning For our SDKs WorkOS follows a Semantic Versioning ([SemVer](https://semver.org/)) process where all releases will have a version X.Y.Z (like 1.0.0) pattern wherein Z would be a bug fix (e.g., 1.0.1), Y would be a minor release (1.1.0) and X would be a major release (2.0.0). We permit any breaking changes to only be released in major versions and strongly recommend reading changelogs before making any major version upgrades. diff --git a/src/feature-flags/evaluator.spec.ts b/src/feature-flags/evaluator.spec.ts index eaac42b61..eebfed3d0 100644 --- a/src/feature-flags/evaluator.spec.ts +++ b/src/feature-flags/evaluator.spec.ts @@ -6,6 +6,17 @@ import { RuntimeClientLogger, } from './interfaces'; +const membershipRule = ( + targetType: string, + values: string[], + value: boolean = true, +) => ({ + id: `rule_${targetType}`, + kind: 'conditions', + value, + conditions: [{ target_type: targetType, operator: 'one_of', values }], +}); + describe('Evaluator', () => { let store: InMemoryStore; let evaluator: Evaluator; @@ -15,44 +26,29 @@ describe('Evaluator', () => { slug: 'enabled-flag', enabled: true, default_value: true, - targets: { users: [], organizations: [] }, + off_value: false, + rules: [], }; const disabledFlag: FlagPollEntry = { slug: 'disabled-flag', enabled: false, default_value: true, - targets: { users: [], organizations: [] }, + off_value: false, + rules: [], }; const targetedFlag: FlagPollEntry = { slug: 'targeted-flag', enabled: true, default_value: false, - targets: { - organizations: [{ id: 'org_123', enabled: true }], - users: [ - { id: 'user_456', enabled: true }, - { id: 'user_blocked', enabled: false }, - ], - custom_targets: [ - { type: 'workspace', id: 'ws_123', enabled: true }, - { type: 'workspace', id: 'ws_off', enabled: false }, - { type: 'region', id: 'us-east-1', enabled: true }, - ], - }, - }; - - // Simulates a default-on flag with a disabled override, which the API - // cannot produce yet: the row must not turn the flag off. - const defaultOnFlag: FlagPollEntry = { - slug: 'default-on-flag', - enabled: true, - default_value: true, - targets: { - users: [{ id: 'user_blocked', enabled: false }], - organizations: [], - }, + off_value: false, + rules: [ + membershipRule('organization', ['org_123']), + membershipRule('user', ['user_456']), + membershipRule('workspace', ['ws_123']), + membershipRule('region', ['us-east-1']), + ], }; beforeEach(() => { @@ -68,7 +64,6 @@ describe('Evaluator', () => { 'enabled-flag': enabledFlag, 'disabled-flag': disabledFlag, 'targeted-flag': targetedFlag, - 'default-on-flag': defaultOnFlag, }); }); @@ -82,60 +77,88 @@ describe('Evaluator', () => { expect(evaluator.isEnabled('disabled-flag')).toBe(false); }); - it('returns true for a matching enabled organization target', () => { + it('returns true for a matching organization rule', () => { expect( evaluator.isEnabled('targeted-flag', { organizationId: 'org_123' }), ).toBe(true); }); - it('returns true for a matching enabled user target', () => { + it('returns true for a matching user rule', () => { expect(evaluator.isEnabled('targeted-flag', { userId: 'user_456' })).toBe( true, ); }); - it('treats targets with enabled=false as not present', () => { - // No enabled match, so the flag falls back to its default value — - // false here, but crucially the target does not force the flag off. - expect( - evaluator.isEnabled('targeted-flag', { userId: 'user_blocked' }), - ).toBe(false); + it('falls back to the organization rule when the user rule does not match', () => { expect( - evaluator.isEnabled('default-on-flag', { userId: 'user_blocked' }), + evaluator.isEnabled('targeted-flag', { + userId: 'user_other', + organizationId: 'org_123', + }), ).toBe(true); }); + }); + + describe('served values', () => { + const context = { userId: 'user_456', organizationId: 'org_123' }; + const evaluate = (flag: Omit): boolean => { + store.swap({ flag: { slug: 'flag', ...flag } }); + return evaluator.isEnabled('flag', context); + }; - it('matches any enabled target with no precedence between types', () => { + it('serves off_value when the flag is off, even if a rule matches', () => { expect( - evaluator.isEnabled('targeted-flag', { - userId: 'user_blocked', - organizationId: 'org_123', + evaluate({ + enabled: false, + default_value: false, + off_value: true, + rules: [membershipRule('user', ['user_456'], false)], }), ).toBe(true); }); - it('falls back to organization target when user target does not match', () => { + it('serves default_value when no rule matches', () => { expect( - evaluator.isEnabled('targeted-flag', { - userId: 'user_other', - organizationId: 'org_123', + evaluate({ + enabled: true, + default_value: true, + off_value: false, + rules: [membershipRule('user', ['user_other'], false)], }), ).toBe(true); }); - it('returns default_value when no target matches', () => { + it('serves the first matching rule when it is true', () => { expect( - evaluator.isEnabled('targeted-flag', { userId: 'user_other' }), - ).toBe(false); + evaluate({ + enabled: true, + default_value: false, + off_value: false, + rules: [ + membershipRule('user', ['user_456'], true), + membershipRule('organization', ['org_123'], false), + ], + }), + ).toBe(true); + }); + it('serves the first matching rule when it is false', () => { expect( - evaluator.isEnabled('enabled-flag', { userId: 'user_other' }), - ).toBe(true); + evaluate({ + enabled: true, + default_value: true, + off_value: false, + rules: [ + membershipRule('user', ['user_456'], false), + membershipRule('organization', ['org_123'], true), + ], + }), + ).toBe(false); }); }); describe('typed evaluation contexts', () => { - it('matches custom targets by exact type and id', () => { + it('matches custom target rules by exact type and id', () => { expect( evaluator.isEnabled('targeted-flag', { workspace: { id: 'ws_123' } }), ).toBe(true); @@ -161,23 +184,6 @@ describe('Evaluator', () => { ).toBe(true); }); - it('treats custom targets with enabled=false as not present', () => { - expect( - evaluator.isEnabled('targeted-flag', { workspace: { id: 'ws_off' } }), - ).toBe(false); - }); - - it('evaluates safely when the payload has no custom_targets field', () => { - expect( - evaluator.isEnabled('enabled-flag', { workspace: { id: 'ws_123' } }), - ).toBe(true); - expect( - evaluator.isEnabled('default-on-flag', { - workspace: { id: 'ws_123' }, - }), - ).toBe(true); - }); - it('rejects a context mixing legacy and typed keys', () => { const hybridContext: EvaluationContext = { userId: 'user_456', @@ -245,7 +251,6 @@ describe('Evaluator', () => { 'enabled-flag': true, 'disabled-flag': false, 'targeted-flag': true, - 'default-on-flag': true, }); }); @@ -256,7 +261,6 @@ describe('Evaluator', () => { 'enabled-flag': true, 'disabled-flag': false, 'targeted-flag': true, - 'default-on-flag': true, }); }); @@ -273,7 +277,6 @@ describe('Evaluator', () => { 'enabled-flag': true, 'disabled-flag': false, 'targeted-flag': false, - 'default-on-flag': true, }); }); }); diff --git a/src/feature-flags/evaluator.ts b/src/feature-flags/evaluator.ts index 1089293a7..68fd6b88f 100644 --- a/src/feature-flags/evaluator.ts +++ b/src/feature-flags/evaluator.ts @@ -44,13 +44,12 @@ export class Evaluator { // per flag. const normalizedContext = this.normalizeContext(context); const flags = this.store.getAll(); - const result: Record = {}; - - for (const slug of Object.keys(flags)) { - result[slug] = this.evaluate(flags[slug], normalizedContext, false); - } - - return result; + return Object.fromEntries( + Object.entries(flags).map(([slug, flag]) => [ + slug, + this.evaluate(flag, normalizedContext, false), + ]), + ); } private evaluate( @@ -63,18 +62,31 @@ export class Evaluator { } if (!entry.enabled) { - return false; + return this.servedValue(entry.off_value, defaultValue); } - // Evaluation is enable-only: any enabled target matching the context - // turns the flag on, with no precedence between target types. - for (const [targetType, targetId] of normalizedContext) { - if (this.hasEnabledTarget(entry, targetType, targetId)) { - return true; + for (const rule of entry.rules) { + if (rule.kind !== 'conditions' || !rule.conditions?.length) continue; + const matches = rule.conditions.every((condition) => { + if ( + condition.operator !== 'one_of' || + !condition.target_type || + !Array.isArray(condition.values) + ) + return false; + const id = normalizedContext.get(condition.target_type); + return id !== undefined && condition.values.includes(id); + }); + if (matches) { + return this.servedValue(rule.value, defaultValue); } } - return entry.default_value; + return this.servedValue(entry.default_value, defaultValue); + } + + private servedValue(value: unknown, defaultValue: boolean): boolean { + return typeof value === 'boolean' ? value : defaultValue; } /** @@ -85,6 +97,14 @@ export class Evaluator { */ private normalizeContext(context: EvaluationContext): Map { const normalized = new Map(); + if ( + typeof context !== 'object' || + context === null || + Array.isArray(context) + ) { + this.logger?.warn('Ignoring invalid evaluation context'); + return normalized; + } const record: Record = context; const legacyEntries: Array<[string, string]> = []; @@ -161,29 +181,4 @@ export class Evaluator { return normalized; } - - /** - * A target participates in evaluation only while its `enabled` is true. A - * `false` value is reserved for future disabled overrides and is treated - * as if the target were absent. - */ - private hasEnabledTarget( - entry: FlagPollEntry, - targetType: string, - targetId: string, - ): boolean { - if (targetType === 'user') { - return entry.targets.users.some((t) => t.id === targetId && t.enabled); - } - - if (targetType === 'organization') { - return entry.targets.organizations.some( - (t) => t.id === targetId && t.enabled, - ); - } - - return (entry.targets.custom_targets ?? []).some( - (t) => t.type === targetType && t.id === targetId && t.enabled, - ); - } } diff --git a/src/feature-flags/in-memory-store.spec.ts b/src/feature-flags/in-memory-store.spec.ts index c567dd25e..ca1179191 100644 --- a/src/feature-flags/in-memory-store.spec.ts +++ b/src/feature-flags/in-memory-store.spec.ts @@ -8,17 +8,16 @@ describe('InMemoryStore', () => { slug: 'flag-a', enabled: true, default_value: true, - targets: { users: [], organizations: [] }, + off_value: false, + rules: [], }; const flagB: FlagPollEntry = { slug: 'flag-b', enabled: false, default_value: false, - targets: { - users: [{ id: 'user_123', enabled: true }], - organizations: [], - }, + off_value: false, + rules: [], }; beforeEach(() => { diff --git a/src/feature-flags/in-memory-store.ts b/src/feature-flags/in-memory-store.ts index d93c009c7..ff54cf2b7 100644 --- a/src/feature-flags/in-memory-store.ts +++ b/src/feature-flags/in-memory-store.ts @@ -1,17 +1,17 @@ import { FlagPollEntry, FlagPollResponse } from './interfaces'; export class InMemoryStore { - private flags: FlagPollResponse = {}; + private flags: FlagPollResponse['flags'] = {}; - swap(newFlags: FlagPollResponse): void { + swap(newFlags: FlagPollResponse['flags']): void { this.flags = { ...newFlags }; } get(slug: string): FlagPollEntry | undefined { - return this.flags[slug]; + return Object.hasOwn(this.flags, slug) ? this.flags[slug] : undefined; } - getAll(): FlagPollResponse { + getAll(): FlagPollResponse['flags'] { return { ...this.flags }; } diff --git a/src/feature-flags/interfaces/flag-poll-response.interface.ts b/src/feature-flags/interfaces/flag-poll-response.interface.ts index 762f53ed4..62e404462 100644 --- a/src/feature-flags/interfaces/flag-poll-response.interface.ts +++ b/src/feature-flags/interfaces/flag-poll-response.interface.ts @@ -1,24 +1,29 @@ -export interface FlagTarget { - id: string; - enabled: boolean; +export interface FlagPollCondition { + // Future operators may carry different operands. Unsupported conditions + // never match, even if another condition in the rule matches. + operator: string; + target_type?: string; + values?: unknown; } -export interface FlagCustomTarget { - type: string; +export interface FlagPollRule { id: string; - enabled: boolean; + kind: string; + // Future multivariate values must fall back to the caller's boolean default. + value: unknown; + conditions?: FlagPollCondition[]; } export interface FlagPollEntry { slug: string; enabled: boolean; - default_value: boolean; - targets: { - users: FlagTarget[]; - organizations: FlagTarget[]; - /** Absent until the API's custom-targets rollout flag is enabled. */ - custom_targets?: FlagCustomTarget[]; - }; + default_value: unknown; + off_value: unknown; + /** Evaluated in array order; the first matching rule wins. */ + rules: FlagPollRule[]; } -export type FlagPollResponse = Record; +export interface FlagPollResponse { + version: 2; + flags: Record; +} diff --git a/src/feature-flags/interfaces/runtime-client-options.interface.ts b/src/feature-flags/interfaces/runtime-client-options.interface.ts index 02726bd61..4fe9fbcb1 100644 --- a/src/feature-flags/interfaces/runtime-client-options.interface.ts +++ b/src/feature-flags/interfaces/runtime-client-options.interface.ts @@ -1,4 +1,4 @@ -import { FlagPollEntry } from './flag-poll-response.interface'; +import { FlagPollResponse } from './flag-poll-response.interface'; export interface RuntimeClientLogger { debug(...args: unknown[]): void; @@ -9,7 +9,8 @@ export interface RuntimeClientLogger { export interface RuntimeClientOptions { pollingIntervalMs?: number; - bootstrapFlags?: Record; + /** A versioned envelope, as returned by polling. */ + bootstrapFlags?: FlagPollResponse; requestTimeoutMs?: number; logger?: RuntimeClientLogger; } diff --git a/src/feature-flags/payload.ts b/src/feature-flags/payload.ts new file mode 100644 index 000000000..0990d1eb7 --- /dev/null +++ b/src/feature-flags/payload.ts @@ -0,0 +1,66 @@ +import { + FlagPollCondition, + FlagPollEntry, + FlagPollResponse, + FlagPollRule, +} from './interfaces'; + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function isCondition(value: unknown): value is FlagPollCondition { + return ( + isRecord(value) && + typeof value.operator === 'string' && + (value.target_type === undefined || + typeof value.target_type === 'string') && + // `one_of` always carries a list of IDs. Other operators may carry + // different operands; they pass through and never match. + (value.operator !== 'one_of' || Array.isArray(value.values)) + ); +} + +function isRule(value: unknown): value is FlagPollRule { + return ( + isRecord(value) && + typeof value.id === 'string' && + typeof value.kind === 'string' && + (value.conditions === undefined || + (Array.isArray(value.conditions) && value.conditions.every(isCondition))) + ); +} + +function isEntry(value: unknown): value is FlagPollEntry { + return ( + isRecord(value) && + typeof value.slug === 'string' && + typeof value.enabled === 'boolean' && + 'default_value' in value && + 'off_value' in value && + Array.isArray(value.rules) && + value.rules.every(isRule) + ); +} + +/** Validate a payload before swapping the store. One invalid entry rejects the + * whole payload, so the store never holds a partial snapshot and the last good + * one stays intact; bootstrap callers can still be ready. */ +export function parseFlagPollResponse( + response: unknown, +): FlagPollResponse | undefined { + if ( + !isRecord(response) || + response.version !== 2 || + !isRecord(response.flags) + ) { + return undefined; + } + + const entries: Array<[string, FlagPollEntry]> = []; + for (const [slug, entry] of Object.entries(response.flags)) { + if (!isEntry(entry)) return undefined; + entries.push([slug, entry]); + } + return { version: 2, flags: Object.fromEntries(entries) }; +} diff --git a/src/feature-flags/runtime-client-rules.spec.ts b/src/feature-flags/runtime-client-rules.spec.ts new file mode 100644 index 000000000..ebd761ccd --- /dev/null +++ b/src/feature-flags/runtime-client-rules.spec.ts @@ -0,0 +1,721 @@ +import fetch from 'jest-fetch-mock'; +import { fetchOnce, fetchURL } from '../common/utils/test-utils'; +import { WorkOS } from '../workos'; +import { + EvaluationContext, + FlagChange, + FlagPollEntry, + FlagPollResponse, + RuntimeClientLogger, +} from './interfaces'; + +const workos = new WorkOS('sk_test_example'); +const exclusion = { + id: 'rule_org', + kind: 'conditions', + value: false, + conditions: [ + { target_type: 'organization', operator: 'one_of', values: ['org_1'] }, + ], +}; +const inclusion = { + id: 'rule_user', + kind: 'conditions', + value: true, + conditions: [{ target_type: 'user', operator: 'one_of', values: ['user_1'] }], +}; +const flag: FlagPollEntry = { + slug: 'flag', + enabled: true, + default_value: true, + off_value: false, + rules: [exclusion, inclusion], +}; +const payload: FlagPollResponse = { version: 2, flags: { flag } }; +const legacyPayload = { + flag: { + slug: 'flag', + enabled: true, + default_value: true, + targets: { users: [{ id: 'user_1', enabled: true }], organizations: [] }, + }, +}; +const malformedMembership = { + version: 2, + flags: { + flag: { + ...flag, + rules: [ + { + ...inclusion, + conditions: [ + { target_type: 'user', operator: 'one_of', values: 'user_1' }, + ], + }, + ], + }, + }, +}; +const membershipWithoutValues = { + version: 2, + flags: { + flag: { + ...flag, + rules: [ + { + ...inclusion, + conditions: [{ target_type: 'user', operator: 'one_of' }], + }, + ], + }, + }, +}; +const createLogger = (): jest.Mocked => ({ + debug: jest.fn(), + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), +}); + +describe('runtime client rules', () => { + beforeEach(() => { + fetch.resetMocks(); + jest.useFakeTimers(); + jest.spyOn(Math, 'random').mockReturnValue(0.5); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it('requests v2 and evaluates exclusions in payload order', async () => { + fetchOnce(payload); + const client = workos.featureFlags.createRuntimeClient(); + await jest.advanceTimersByTimeAsync(0); + await client.waitUntilReady(); + + expect( + new URL(String(fetchURL())).searchParams.get('payload_version'), + ).toBe('2'); + expect( + client.isEnabled('flag', { userId: 'user_1', organizationId: 'org_1' }), + ).toBe(false); + expect(client.isEnabled('flag', { userId: 'user_1' })).toBe(true); + expect(client.getFlag('flag')).toEqual(flag); + expect(client.getStats().flagCount).toBe(1); + client.close(); + }); + + it.each([ + ['future version', { ...payload, version: 3 }], + ['invalid version', { ...payload, version: '2' }], + ['missing version', { flags: { flag } }], + ['invalid flags', { version: 2, flags: null }], + [ + 'invalid entry', + { version: 2, flags: { flag: { ...flag, rules: null } } }, + ], + ['legacy flat', legacyPayload], + ['bare entry map', { flag }], + ['malformed membership', malformedMembership], + ['membership without values', membershipWithoutValues], + ['null', null], + ['array', []], + ['scalar', false], + ])( + 'ignores %s bootstrap data with a warning, resolves readiness, and then polls', + async (_name, input) => { + const bootstrapFlags: FlagPollResponse = JSON.parse( + JSON.stringify(input), + ); + const logger = createLogger(); + const client = workos.featureFlags.createRuntimeClient({ + bootstrapFlags, + logger, + }); + await client.waitUntilReady(); + + expect(logger.warn).toHaveBeenCalledTimes(1); + expect(logger.warn).toHaveBeenCalledWith( + expect.stringContaining('Ignoring bootstrapFlags'), + ); + expect(client.getStats().flagCount).toBe(0); + expect(client.isEnabled('flag', {}, true)).toBe(true); + + fetchOnce(payload); + await jest.advanceTimersByTimeAsync(0); + expect(client.getStats().flagCount).toBe(1); + client.close(); + }, + ); + + const cases: Array<{ + name: string; + config: Partial; + context?: EvaluationContext; + fallback?: boolean; + expected: boolean; + }> = [ + { + name: 'off value overrides matching rules and both defaults', + config: { enabled: false }, + context: { userId: 'user_1' }, + fallback: true, + expected: false, + }, + { + name: 'a boolean off value is honored', + config: { enabled: false, off_value: true }, + expected: true, + }, + { + name: 'an empty rule list serves the flag default', + config: { rules: [], default_value: false }, + fallback: true, + expected: false, + }, + { + name: 'no match serves the flag default, not the caller default', + config: {}, + expected: true, + }, + { + name: 'a missing context type does not match', + config: { default_value: false, rules: [inclusion] }, + expected: false, + }, + { + name: 'a matching false rule stops evaluation', + config: {}, + context: { user: { id: 'user_1' }, organization: { id: 'org_1' } }, + fallback: true, + expected: false, + }, + { + name: 'reordering conflicting target types changes the winner', + config: { rules: [inclusion, exclusion] }, + context: { userId: 'user_1', organizationId: 'org_1' }, + expected: true, + }, + { + name: 'a later rule can match', + config: { default_value: false }, + context: { userId: 'user_1' }, + expected: true, + }, + { + name: 'custom target membership can match any listed ID', + config: { + default_value: false, + rules: [ + { + ...inclusion, + conditions: [ + { + target_type: 'workspace', + operator: 'one_of', + values: ['ws_0', 'ws_1'], + }, + ], + }, + ], + }, + context: { workspace: { id: 'ws_1' } }, + expected: true, + }, + { + name: 'membership uses exact, case-sensitive IDs', + config: { default_value: false, rules: [inclusion] }, + context: { userId: 'USER_1' }, + expected: false, + }, + { + name: 'an ID from another type does not match', + config: { default_value: false, rules: [inclusion] }, + context: { workspace: { id: 'user_1' } }, + expected: false, + }, + { + name: 'all conditions must match', + config: { + default_value: false, + rules: [ + { + ...inclusion, + conditions: [...inclusion.conditions, ...exclusion.conditions], + }, + ], + }, + context: { userId: 'user_1', organizationId: 'org_1' }, + expected: true, + }, + { + name: 'one matching condition cannot override a failed condition', + config: { + default_value: false, + rules: [ + { + ...inclusion, + conditions: [...inclusion.conditions, ...exclusion.conditions], + }, + ], + }, + context: { userId: 'user_1', organizationId: 'org_other' }, + expected: false, + }, + { + name: 'one matching condition cannot override a missing context type', + config: { + default_value: false, + rules: [ + { + ...inclusion, + conditions: [...inclusion.conditions, ...exclusion.conditions], + }, + ], + }, + context: { userId: 'user_1' }, + expected: false, + }, + { + name: 'empty conditions never match', + config: { + default_value: false, + rules: [{ ...inclusion, conditions: [] }], + }, + expected: false, + }, + { + name: 'empty membership never matches', + config: { + default_value: false, + rules: [ + { + ...inclusion, + conditions: [ + { target_type: 'user', operator: 'one_of', values: [] }, + ], + }, + ], + }, + context: { userId: 'user_1' }, + expected: false, + }, + { + name: 'unknown rule kinds are skipped even with matching conditions', + config: { + default_value: false, + rules: [{ ...inclusion, kind: 'rollout' }], + }, + context: { userId: 'user_1' }, + expected: false, + }, + { + name: 'unknown rules without conditions allow later rules to match', + config: { + default_value: false, + rules: [{ id: 'future', kind: 'rollout', value: false }, inclusion], + }, + context: { userId: 'user_1' }, + expected: true, + }, + { + name: 'unknown operators invalidate the whole rule', + config: { + default_value: false, + rules: [ + { + ...inclusion, + conditions: [...inclusion.conditions, { operator: 'segment' }], + }, + ], + }, + context: { userId: 'user_1' }, + fallback: true, + expected: false, + }, + { + name: 'unknown operators allow later rules to match', + config: { + default_value: false, + rules: [ + { + ...exclusion, + conditions: [ + { + target_type: 'user', + operator: 'not_one_of', + values: ['user_1'], + }, + ], + }, + inclusion, + ], + }, + context: { userId: 'user_1' }, + expected: true, + }, + { + name: 'unparseable matching values use the caller default and stop', + config: { + default_value: true, + rules: [{ ...inclusion, value: 'true' }, inclusion], + }, + context: { userId: 'user_1' }, + fallback: false, + expected: false, + }, + { + name: 'unparseable off values use a true caller default', + config: { enabled: false, off_value: 0, default_value: false }, + fallback: true, + expected: true, + }, + { + name: 'unparseable defaults use the caller default', + config: { rules: [], default_value: 'false' }, + expected: false, + }, + { + name: 'unparseable values in unmatched rules have no effect', + config: { rules: [{ ...exclusion, value: null }] }, + context: { organizationId: 'org_other' }, + expected: true, + }, + { + name: 'membership IDs that are not strings never match', + config: { + default_value: false, + rules: [ + { + ...inclusion, + conditions: [ + { target_type: 'user', operator: 'one_of', values: [1] }, + ], + }, + ], + }, + context: { userId: '1' }, + fallback: true, + expected: false, + }, + ]; + + it.each(cases)('$name', async ({ config, context, fallback, expected }) => { + const client = workos.featureFlags.createRuntimeClient({ + bootstrapFlags: { version: 2, flags: { flag: { ...flag, ...config } } }, + }); + await client.waitUntilReady(); + expect(client.isEnabled('flag', context, fallback)).toBe(expected); + client.close(); + }); + + it('ignores extra payload keys and evaluates all flags with a false fallback', async () => { + fetchOnce({ + ...payload, + future: { version: 9 }, + flags: { flag, unknown: { ...flag, slug: 'unknown', default_value: 1 } }, + }); + const client = workos.featureFlags.createRuntimeClient(); + await jest.advanceTimersByTimeAsync(0); + expect(client.getAllFlags({ organization: { id: 'org_1' } })).toEqual({ + flag: false, + unknown: false, + }); + expect(client.getAllFlags()).toEqual({ flag: true, unknown: false }); + expect(client.isEnabled('missing', {}, true)).toBe(true); + client.close(); + }); + + it('replaces bootstrap data wholesale on the first poll without change events', async () => { + const client = workos.featureFlags.createRuntimeClient({ + bootstrapFlags: payload, + }); + const changes: FlagChange[] = []; + client.on('change', (change) => changes.push(change)); + expect(client.isEnabled('flag')).toBe(true); + fetchOnce({ version: 2, flags: {} }); + await jest.advanceTimersByTimeAsync(0); + expect(client.getAllFlags()).toEqual({}); + expect(client.getStats().flagCount).toBe(0); + expect(changes).toEqual([]); + client.close(); + }); + + it.each([ + ['rule order', { ...flag, rules: [inclusion, exclusion] }], + [ + 'rule value', + { ...flag, rules: [{ ...exclusion, value: true }, inclusion] }, + ], + [ + 'membership', + { + ...flag, + rules: [ + { + ...inclusion, + conditions: [ + { target_type: 'user', operator: 'one_of', values: ['user_2'] }, + ], + }, + ], + }, + ], + ['off value', { ...flag, off_value: true }], + ['default value', { ...flag, default_value: false }], + ['enabled', { ...flag, enabled: false }], + [ + 'rule kind', + { ...flag, rules: [{ ...exclusion, kind: 'rollout' }, inclusion] }, + ], + [ + 'condition operator', + { + ...flag, + rules: [ + { + ...exclusion, + conditions: [ + { ...exclusion.conditions[0], operator: 'not_one_of' }, + ], + }, + inclusion, + ], + }, + ], + [ + 'target type', + { + ...flag, + rules: [ + { + ...exclusion, + conditions: [ + { ...exclusion.conditions[0], target_type: 'workspace' }, + ], + }, + inclusion, + ], + }, + ], + ['removed rule', { ...flag, rules: [inclusion] }], + ])('emits complete snapshots when %s changes', async (_name, current) => { + fetchOnce(payload); + const client = workos.featureFlags.createRuntimeClient(); + const changes: FlagChange[] = []; + client.on('change', (change) => changes.push(change)); + await jest.advanceTimersByTimeAsync(0); + expect(changes).toEqual([]); + fetchOnce({ version: 2, flags: { flag: current } }); + await jest.advanceTimersByTimeAsync(30_000); + expect(changes).toEqual([{ key: 'flag', previous: flag, current }]); + client.close(); + }); + + it('ignores membership and condition ordering when detecting changes', async () => { + const conditions = [ + { target_type: 'user', operator: 'one_of', values: ['user_1', 'user_2'] }, + { target_type: 'workspace', operator: 'one_of', values: ['ws_1'] }, + ]; + fetchOnce({ + version: 2, + flags: { flag: { ...flag, rules: [{ ...inclusion, conditions }] } }, + }); + const client = workos.featureFlags.createRuntimeClient(); + const changes: FlagChange[] = []; + client.on('change', (change) => changes.push(change)); + await jest.advanceTimersByTimeAsync(0); + fetchOnce({ + version: 2, + flags: { + flag: { + ...flag, + rules: [ + { + ...inclusion, + conditions: [ + conditions[1], + { ...conditions[0], values: ['user_2', 'user_1'] }, + ], + }, + ], + }, + }, + }); + await jest.advanceTimersByTimeAsync(30_000); + expect(changes).toEqual([]); + client.close(); + }); + + it('emits additions and removals while replacing the entire snapshot', async () => { + fetchOnce(payload); + const client = workos.featureFlags.createRuntimeClient(); + const changes: FlagChange[] = []; + client.on('change', (change) => changes.push(change)); + await jest.advanceTimersByTimeAsync(0); + const added = { ...flag, slug: 'added' }; + fetchOnce({ version: 2, flags: { added } }); + await jest.advanceTimersByTimeAsync(30_000); + expect(changes).toEqual([ + { key: 'flag', previous: flag, current: null }, + { key: 'added', previous: null, current: added }, + ]); + expect(client.getAllFlags()).toEqual({ added: true }); + client.close(); + }); + + it.each([ + ['network failure', 'network'], + ['request timeout', 'timeout'], + ['unknown version', { ...payload, version: 3 }], + [ + 'malformed payload', + { version: 2, flags: { flag: { ...flag, rules: null } } }, + ], + ['legacy flat payload', legacyPayload], + ['malformed membership', malformedMembership], + ])( + 'keeps the last good snapshot after %s and recovers on the next poll', + async (_name, failure) => { + fetchOnce(payload); + const client = workos.featureFlags.createRuntimeClient({ + requestTimeoutMs: 50, + }); + const changes: FlagChange[] = []; + const errors: Error[] = []; + client.on('change', (change) => changes.push(change)); + client.on('error', (error) => errors.push(error)); + await jest.advanceTimersByTimeAsync(0); + const lastSuccessfulPollAt = client.getStats().lastSuccessfulPollAt; + if (failure === 'network') fetch.mockRejectOnce(new Error('offline')); + else if (failure === 'timeout') + fetch.mockResponseOnce(() => new Promise(() => {})); + else fetchOnce(failure); + await jest.advanceTimersByTimeAsync(30_050); + expect(client.isEnabled('flag', { organizationId: 'org_1' })).toBe(false); + expect(client.getAllFlags()).toEqual({ flag: true }); + expect(client.getStats()).toMatchObject({ + flagCount: 1, + pollErrorCount: 1, + lastSuccessfulPollAt, + }); + expect(errors).toHaveLength(1); + if (typeof failure !== 'string') { + expect(errors[0].message).toContain("can't read"); + } + expect(changes).toEqual([]); + fetchOnce({ version: 2, flags: {} }); + await jest.advanceTimersByTimeAsync(30_000); + expect(client.getAllFlags()).toEqual({}); + expect(changes).toEqual([{ key: 'flag', previous: flag, current: null }]); + client.close(); + }, + ); + + it.each<[string, FlagPollResponse]>([ + ['an empty', { version: 2, flags: {} }], + ['a populated', payload], + ])( + 'accepts %s bootstrap snapshot without a warning', + async (_name, input) => { + const logger = createLogger(); + const client = workos.featureFlags.createRuntimeClient({ + bootstrapFlags: input, + logger, + }); + await client.waitUntilReady(); + expect(logger.warn).not.toHaveBeenCalled(); + expect(client.getStats().flagCount).toBe(Object.keys(input.flags).length); + client.close(); + }, + ); + + it('serves defaults from an empty bootstrap snapshot', async () => { + const client = workos.featureFlags.createRuntimeClient({ + bootstrapFlags: { version: 2, flags: {} }, + }); + await client.waitUntilReady(); + expect(client.getAllFlags()).toEqual({}); + expect(client.isEnabled('absent', {}, true)).toBe(true); + client.close(); + }); + + it('preserves reserved-looking flag slugs', async () => { + const entries = ['version', 'flags', '__proto__', 'constructor'].map( + (slug) => [slug, { ...flag, slug }], + ); + fetchOnce({ version: 2, flags: Object.fromEntries(entries) }); + const client = workos.featureFlags.createRuntimeClient(); + const changes: FlagChange[] = []; + client.on('change', (change) => changes.push(change)); + expect(client.getFlag('constructor')).toBeUndefined(); + expect(client.isEnabled('toString', {}, true)).toBe(true); + await jest.advanceTimersByTimeAsync(0); + expect(client.getAllFlags()).toEqual( + JSON.parse( + '{"version":true,"flags":true,"__proto__":true,"constructor":true}', + ), + ); + expect(client.getFlag('__proto__')?.slug).toBe('__proto__'); + fetchOnce({ version: 2, flags: {} }); + await jest.advanceTimersByTimeAsync(30_000); + expect(changes).toHaveLength(4); + expect(changes.every((change) => change.current === null)).toBe(true); + expect(client.getFlag('constructor')).toBeUndefined(); + client.close(); + }); + + it('does not mark a malformed initial poll ready or emit changes on recovery', async () => { + fetchOnce({ version: 99, flags: {} }); + const client = workos.featureFlags.createRuntimeClient(); + client.on('error', () => {}); + const changes: FlagChange[] = []; + client.on('change', (change) => changes.push(change)); + let ready = false; + const readiness = client.waitUntilReady().then(() => { + ready = true; + }); + await jest.advanceTimersByTimeAsync(0); + expect(ready).toBe(false); + expect(client.getStats().lastSuccessfulPollAt).toBeNull(); + fetchOnce(payload); + await jest.advanceTimersByTimeAsync(30_000); + await readiness; + expect(ready).toBe(true); + expect(changes).toEqual([]); + client.close(); + }); + + it('retains the snapshot and stops polling after a 401', async () => { + fetchOnce(payload); + const client = workos.featureFlags.createRuntimeClient(); + const failures: Error[] = []; + client.on('error', () => {}); + client.on('failed', (error) => failures.push(error)); + await jest.advanceTimersByTimeAsync(0); + fetchOnce({ message: 'Unauthorized' }, { status: 401 }); + await jest.advanceTimersByTimeAsync(30_000); + expect(failures).toHaveLength(1); + expect(client.getAllFlags({ organizationId: 'org_1' })).toEqual({ + flag: false, + }); + await client.waitUntilReady(); + await jest.advanceTimersByTimeAsync(120_000); + expect(fetch.mock.calls).toHaveLength(2); + client.close(); + }); + + it.each([null, false, 'invalid', []])( + 'never throws on a malformed top-level context: %j', + (input) => { + const context: EvaluationContext = JSON.parse(JSON.stringify(input)); + const client = workos.featureFlags.createRuntimeClient({ + bootstrapFlags: payload, + }); + expect(client.isEnabled('flag', context)).toBe(true); + expect(client.getAllFlags(context)).toEqual({ flag: true }); + client.close(); + }, + ); +}); diff --git a/src/feature-flags/runtime-client.spec.ts b/src/feature-flags/runtime-client.spec.ts index 249112565..5e9d0cda4 100644 --- a/src/feature-flags/runtime-client.spec.ts +++ b/src/feature-flags/runtime-client.spec.ts @@ -8,22 +8,34 @@ import { FlagPollResponse } from './interfaces'; const workos = new WorkOS('sk_test_Sz3IQjepeSWaI4cMS4ms4sMuU'); const pollResponse: FlagPollResponse = { - 'flag-a': { - slug: 'flag-a', - enabled: true, - default_value: true, - targets: { users: [], organizations: [] }, - }, - 'flag-b': { - slug: 'flag-b', - enabled: true, - default_value: false, - targets: { - users: [{ id: 'user_123', enabled: true }], - organizations: [], + version: 2, + flags: { + 'flag-a': { + slug: 'flag-a', + enabled: true, + default_value: true, + off_value: false, + rules: [], + }, + 'flag-b': { + slug: 'flag-b', + enabled: true, + default_value: false, + off_value: false, + rules: [ + { + id: 'rule_user', + kind: 'conditions', + value: true, + conditions: [ + { target_type: 'user', operator: 'one_of', values: ['user_123'] }, + ], + }, + ], }, }, }; +const { 'flag-a': flagA, 'flag-b': flagB } = pollResponse.flags; describe('FeatureFlagsRuntimeClient', () => { beforeEach(() => { @@ -178,12 +190,12 @@ describe('FeatureFlagsRuntimeClient', () => { }); describe('getFlag', () => { - it('returns raw flag entry', async () => { + it('returns flag configuration', async () => { const client = createClientAndWait(); await jest.advanceTimersByTimeAsync(0); await client.waitUntilReady(); - expect(client.getFlag('flag-a')).toEqual(pollResponse['flag-a']); + expect(client.getFlag('flag-a')).toEqual(flagA); expect(client.getFlag('unknown')).toBeUndefined(); client.close(); @@ -251,13 +263,8 @@ describe('FeatureFlagsRuntimeClient', () => { client.on('change', (change) => changes.push(change)); const updatedResponse: FlagPollResponse = { - 'flag-a': { - slug: 'flag-a', - enabled: false, - default_value: true, - targets: { users: [], organizations: [] }, - }, - 'flag-b': pollResponse['flag-b'], + version: 2, + flags: { 'flag-a': { ...flagA, enabled: false }, 'flag-b': flagB }, }; fetchOnce(updatedResponse); @@ -266,15 +273,15 @@ describe('FeatureFlagsRuntimeClient', () => { expect(changes).toEqual([ { key: 'flag-a', - previous: pollResponse['flag-a'], - current: updatedResponse['flag-a'], + previous: flagA, + current: { ...flagA, enabled: false }, }, ]); client.close(); }); - it('emits change when only custom targets change', async () => { + it('emits change when only custom target rules change', async () => { const client = createClientAndWait(); await jest.advanceTimersByTimeAsync(0); await client.waitUntilReady(); @@ -282,28 +289,33 @@ describe('FeatureFlagsRuntimeClient', () => { const changes: unknown[] = []; client.on('change', (change) => changes.push(change)); - const updatedResponse: FlagPollResponse = { - 'flag-a': pollResponse['flag-a'], - 'flag-b': { - ...pollResponse['flag-b'], - targets: { - ...pollResponse['flag-b'].targets, - custom_targets: [ - { type: 'workspace', id: 'ws_123', enabled: true }, + const updatedFlagB = { + ...flagB, + rules: [ + ...flagB.rules, + { + id: 'rule_workspace', + kind: 'conditions', + value: true, + conditions: [ + { + target_type: 'workspace', + operator: 'one_of', + values: ['ws_123'], + }, ], }, - }, + ], }; - fetchOnce(updatedResponse); + fetchOnce({ + version: 2, + flags: { 'flag-a': flagA, 'flag-b': updatedFlagB }, + }); await jest.advanceTimersByTimeAsync(35_000); expect(changes).toEqual([ - { - key: 'flag-b', - previous: pollResponse['flag-b'], - current: updatedResponse['flag-b'], - }, + { key: 'flag-b', previous: flagB, current: updatedFlagB }, ]); client.close(); @@ -318,13 +330,13 @@ describe('FeatureFlagsRuntimeClient', () => { client.on('change', (change) => changes.push(change)); // Second poll returns only flag-a - fetchOnce({ 'flag-a': pollResponse['flag-a'] }); + fetchOnce({ version: 2, flags: { 'flag-a': flagA } }); await jest.advanceTimersByTimeAsync(35_000); expect(changes).toEqual([ { key: 'flag-b', - previous: pollResponse['flag-b'], + previous: flagB, current: null, }, ]); @@ -336,11 +348,15 @@ describe('FeatureFlagsRuntimeClient', () => { describe('bootstrap flags', () => { it('first poll replaces bootstrap data', async () => { const bootstrapFlags: FlagPollResponse = { - 'bootstrap-flag': { - slug: 'bootstrap-flag', - enabled: true, - default_value: true, - targets: { users: [], organizations: [] }, + version: 2, + flags: { + 'bootstrap-flag': { + slug: 'bootstrap-flag', + enabled: true, + default_value: true, + off_value: false, + rules: [], + }, }, }; diff --git a/src/feature-flags/runtime-client.ts b/src/feature-flags/runtime-client.ts index e13841597..616522863 100644 --- a/src/feature-flags/runtime-client.ts +++ b/src/feature-flags/runtime-client.ts @@ -3,13 +3,12 @@ import { WorkOS } from '../workos'; import { UnauthorizedException } from '../common/exceptions'; import { InMemoryStore } from './in-memory-store'; import { Evaluator } from './evaluator'; +import { parseFlagPollResponse } from './payload'; import { EvaluationContext, FlagChange, - FlagCustomTarget, FlagPollEntry, FlagPollResponse, - FlagTarget, RuntimeClientOptions, RuntimeClientLogger, RuntimeClientStats, @@ -80,8 +79,15 @@ export class FeatureFlagsRuntimeClient extends EventEmitter // Prevent unhandled rejection if no one awaits waitUntilReady this.readyPromise.catch(() => {}); - if (options.bootstrapFlags) { - this.store.swap(options.bootstrapFlags); + if (options.bootstrapFlags !== undefined) { + const bootstrap = parseFlagPollResponse(options.bootstrapFlags); + if (bootstrap) { + this.store.swap(bootstrap.flags); + } else { + this.logger?.warn( + 'Ignoring bootstrapFlags: expected a v2 flag payload ({ version: 2, flags }). Flags will load on the first successful poll.', + ); + } this.stats.flagCount = this.store.size; this.resolveReady(); } @@ -164,15 +170,20 @@ export class FeatureFlagsRuntimeClient extends EventEmitter this.stats.pollCount++; this.stats.lastPollAt = new Date(); - const data = await this.fetchWithTimeout(); + const data = parseFlagPollResponse(await this.fetchWithTimeout()); + if (!data) { + throw new Error( + "Feature flag poll returned a payload this SDK can't read; keeping the last good flags", + ); + } - this.store.swap(data); + this.store.swap(data.flags); this.stats.lastSuccessfulPollAt = new Date(); this.stats.flagCount = this.store.size; this.consecutiveErrors = 0; if (this.initialized) { - this.emitChanges(previousFlags, data); + this.emitChanges(previousFlags, data.flags); } this.initialized = true; this.resolveReady(); @@ -201,14 +212,14 @@ export class FeatureFlagsRuntimeClient extends EventEmitter this.scheduleNextPoll(); } - private async fetchWithTimeout(): Promise { + private async fetchWithTimeout(): Promise { this.pollAbortController = new AbortController(); const { signal } = this.pollAbortController; let timeoutId: ReturnType; const fetchPromise = this.workos - .get('/sdk/feature-flags') + .get('/sdk/feature-flags?payload_version=2') .then(({ data }) => data); const timeoutPromise = new Promise((_, reject) => { @@ -262,8 +273,8 @@ export class FeatureFlagsRuntimeClient extends EventEmitter } private emitChanges( - previous: FlagPollResponse, - current: FlagPollResponse, + previous: FlagPollResponse['flags'], + current: FlagPollResponse['flags'], ): void { if (!previous || !current) { return; @@ -275,8 +286,8 @@ export class FeatureFlagsRuntimeClient extends EventEmitter ]); for (const key of allKeys) { - const prev = previous[key]; - const curr = current[key]; + const prev = Object.hasOwn(previous, key) ? previous[key] : undefined; + const curr = Object.hasOwn(current, key) ? current[key] : undefined; if (this.hasEntryChanged(prev, curr)) { this.emit('change', { @@ -292,38 +303,31 @@ export class FeatureFlagsRuntimeClient extends EventEmitter a: FlagPollEntry | undefined, b: FlagPollEntry | undefined, ): boolean { - if (!a || !b) { - return a !== b; - } - - if (a.enabled !== b.enabled || a.default_value !== b.default_value) { - return true; - } - - const targetsChanged = (xs: FlagTarget[], ys: FlagTarget[]): boolean => { - if (xs.length !== ys.length) return true; - const map = new Map(ys.map((t) => [t.id, t.enabled])); - return xs.some((t) => map.get(t.id) !== t.enabled); - }; - - // Type slugs cannot contain ':', so the first ':' unambiguously ends the - // type in this composite key even though target IDs may contain ':'. - const customTargetsChanged = ( - xs: FlagCustomTarget[], - ys: FlagCustomTarget[], - ): boolean => { - if (xs.length !== ys.length) return true; - const map = new Map(ys.map((t) => [`${t.type}:${t.id}`, t.enabled])); - return xs.some((t) => map.get(`${t.type}:${t.id}`) !== t.enabled); - }; - - return ( - targetsChanged(a.targets.users, b.targets.users) || - targetsChanged(a.targets.organizations, b.targets.organizations) || - customTargetsChanged( - a.targets.custom_targets ?? [], - b.targets.custom_targets ?? [], - ) - ); + if (!a || !b) return a !== b; + + // Rule order determines precedence. Condition and membership order do not. + const signature = (entry: FlagPollEntry): string => + JSON.stringify([ + entry.enabled, + entry.default_value, + entry.off_value, + entry.rules.map((rule) => [ + rule.id, + rule.kind, + rule.value, + rule.conditions + ?.map((condition) => + JSON.stringify([ + condition.operator, + condition.target_type, + Array.isArray(condition.values) + ? [...new Set(condition.values)].sort() + : condition.values, + ]), + ) + .sort(), + ]), + ]); + return signature(a) !== signature(b); } }