From c9ac1cd7632d805c62eeb29e2d3fdc1f3cade766 Mon Sep 17 00:00:00 2001 From: Stanley Phu Date: Wed, 30 Sep 2026 08:49:23 -0700 Subject: [PATCH 1/9] feat(feature-flags)!: Evaluate ordered targeting rules BREAKING CHANGE: getFlag() and change-event snapshots now expose v2 rule entries with off_value and rules instead of legacy targets. --- README.md | 24 + src/feature-flags/evaluator.spec.ts | 5 +- src/feature-flags/evaluator.ts | 77 +- src/feature-flags/in-memory-store.spec.ts | 15 +- src/feature-flags/in-memory-store.ts | 12 +- .../interfaces/flag-change.interface.ts | 6 +- .../flag-poll-response.interface.ts | 34 +- .../runtime-client-options.interface.ts | 5 +- src/feature-flags/payload.ts | 129 +++ src/feature-flags/runtime-client-v2.spec.ts | 751 ++++++++++++++++++ src/feature-flags/runtime-client.spec.ts | 59 +- src/feature-flags/runtime-client.ts | 94 ++- 12 files changed, 1092 insertions(+), 119 deletions(-) create mode 100644 src/feature-flags/payload.ts create mode 100644 src/feature-flags/runtime-client-v2.spec.ts diff --git a/README.md b/README.md index 0224c0714..26a47b8e1 100644 --- a/README.md +++ b/README.md @@ -136,6 +136,30 @@ const { accessToken } = await workos.userManagement.authenticateWithCode({ }); ``` +## Feature flag runtime + +The runtime client requests the v2 flag payload and evaluates rules in array order: +when a flag is off it serves `off_value`; otherwise the first matching rule wins, +then `default_value`. A matching rule can serve `false`. Conditions within a rule +must all match; `one_of` matches an exact ID for its target type. + +`isEnabled(key, context, defaultValue)` keeps the same API. Unknown rule kinds and +condition operators do not match. If the selected value is not a boolean, the +caller-provided default applies (`false` when omitted). `getAllFlags` also uses +`false` for values this SDK cannot interpret. + +Older APIs that return the flat v1 payload remain supported. `bootstrapFlags` +accepts either that legacy map or the complete `{ version: 2, flags: { ... } }` +envelope. Unrecognized bootstrap data is ignored and readiness resolves; polling +can populate the cache afterward. Failed polls retain the last good snapshot. + +**Configuration shape change:** `getFlag()` and the `previous` / `current` +snapshots in `change` events now return v2 entries with `off_value` and `rules`, +including when the API returns v1. Code inspecting `targets` must switch to +`rules`. Keep the versioned envelope when saving v2 bootstrap data; a bare map of +v2 entries is not a bootstrap payload. Reordering rules emits a change event; +reordering IDs within the same condition does not. + ## 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..27e45129d 100644 --- a/src/feature-flags/evaluator.spec.ts +++ b/src/feature-flags/evaluator.spec.ts @@ -1,5 +1,6 @@ import { Evaluator } from './evaluator'; import { InMemoryStore } from './in-memory-store'; +import { toV2 } from './payload'; import { EvaluationContext, FlagPollEntry, @@ -64,12 +65,14 @@ describe('Evaluator', () => { error: jest.fn(), }; evaluator = new Evaluator(store, logger); - store.swap({ + const payload = toV2({ 'enabled-flag': enabledFlag, 'disabled-flag': disabledFlag, 'targeted-flag': targetedFlag, 'default-on-flag': defaultOnFlag, }); + if (!payload) throw new Error('Invalid legacy test payload'); + store.swap(payload.flags); }); describe('isEnabled', () => { diff --git a/src/feature-flags/evaluator.ts b/src/feature-flags/evaluator.ts index 1089293a7..f5e69ea11 100644 --- a/src/feature-flags/evaluator.ts +++ b/src/feature-flags/evaluator.ts @@ -2,7 +2,7 @@ import { InMemoryStore } from './in-memory-store'; import { EvaluationContext, EvaluationResource, - FlagPollEntry, + FlagPollEntryV2, RuntimeClientLogger, } from './interfaces'; @@ -44,17 +44,16 @@ 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( - entry: FlagPollEntry | undefined, + entry: FlagPollEntryV2 | undefined, normalizedContext: Map, defaultValue: boolean, ): boolean { @@ -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..62e36417c 100644 --- a/src/feature-flags/in-memory-store.spec.ts +++ b/src/feature-flags/in-memory-store.spec.ts @@ -1,24 +1,23 @@ import { InMemoryStore } from './in-memory-store'; -import { FlagPollEntry } from './interfaces'; +import { FlagPollEntryV2 } from './interfaces'; describe('InMemoryStore', () => { let store: InMemoryStore; - const flagA: FlagPollEntry = { + const flagA: FlagPollEntryV2 = { slug: 'flag-a', enabled: true, default_value: true, - targets: { users: [], organizations: [] }, + off_value: false, + rules: [], }; - const flagB: FlagPollEntry = { + const flagB: FlagPollEntryV2 = { 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..0a50e3c2d 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'; +import { FlagPollEntryV2, FlagPollResponseV2 } from './interfaces'; export class InMemoryStore { - private flags: FlagPollResponse = {}; + private flags: FlagPollResponseV2['flags'] = {}; - swap(newFlags: FlagPollResponse): void { + swap(newFlags: FlagPollResponseV2['flags']): void { this.flags = { ...newFlags }; } - get(slug: string): FlagPollEntry | undefined { - return this.flags[slug]; + get(slug: string): FlagPollEntryV2 | undefined { + return Object.hasOwn(this.flags, slug) ? this.flags[slug] : undefined; } - getAll(): FlagPollResponse { + getAll(): FlagPollResponseV2['flags'] { return { ...this.flags }; } diff --git a/src/feature-flags/interfaces/flag-change.interface.ts b/src/feature-flags/interfaces/flag-change.interface.ts index 3b5e29662..a55d17526 100644 --- a/src/feature-flags/interfaces/flag-change.interface.ts +++ b/src/feature-flags/interfaces/flag-change.interface.ts @@ -1,7 +1,7 @@ -import { FlagPollEntry } from './flag-poll-response.interface'; +import { FlagPollEntryV2 } from './flag-poll-response.interface'; export interface FlagChange { key: string; - previous: FlagPollEntry | null; - current: FlagPollEntry | null; + previous: FlagPollEntryV2 | null; + current: FlagPollEntryV2 | null; } diff --git a/src/feature-flags/interfaces/flag-poll-response.interface.ts b/src/feature-flags/interfaces/flag-poll-response.interface.ts index 762f53ed4..801de2405 100644 --- a/src/feature-flags/interfaces/flag-poll-response.interface.ts +++ b/src/feature-flags/interfaces/flag-poll-response.interface.ts @@ -21,4 +21,36 @@ export interface FlagPollEntry { }; } -export type FlagPollResponse = Record; +export type FlagPollResponseV1 = Record; + +export interface FlagConditionV2 { + // 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 FlagRuleV2 { + id: string; + kind: string; + // Future multivariate values must fall back to the caller's boolean default. + value: unknown; + conditions?: FlagConditionV2[]; +} + +export interface FlagPollEntryV2 { + slug: string; + enabled: boolean; + default_value: unknown; + off_value: unknown; + /** Evaluated in array order; the first matching rule wins. */ + rules: FlagRuleV2[]; +} + +export interface FlagPollResponseV2 { + version: 2; + flags: Record; +} + +export type FlagPollResponse = FlagPollResponseV1 | FlagPollResponseV2; diff --git a/src/feature-flags/interfaces/runtime-client-options.interface.ts b/src/feature-flags/interfaces/runtime-client-options.interface.ts index 02726bd61..6cb00e536 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 legacy flat payload or a versioned v2 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..670ffd61c --- /dev/null +++ b/src/feature-flags/payload.ts @@ -0,0 +1,129 @@ +import { + FlagConditionV2, + FlagCustomTarget, + FlagPollEntry, + FlagPollEntryV2, + FlagPollResponseV2, + FlagRuleV2, + FlagTarget, +} from './interfaces'; + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function isTarget(value: unknown): value is FlagTarget { + return ( + isRecord(value) && + typeof value.id === 'string' && + typeof value.enabled === 'boolean' + ); +} + +function isCustomTarget(value: unknown): value is FlagCustomTarget { + return isTarget(value) && 'type' in value && typeof value.type === 'string'; +} + +function isV1Entry(value: unknown): value is FlagPollEntry { + return ( + isRecord(value) && + typeof value.slug === 'string' && + typeof value.enabled === 'boolean' && + typeof value.default_value === 'boolean' && + isRecord(value.targets) && + Array.isArray(value.targets.users) && + value.targets.users.every(isTarget) && + Array.isArray(value.targets.organizations) && + value.targets.organizations.every(isTarget) && + (value.targets.custom_targets === undefined || + (Array.isArray(value.targets.custom_targets) && + value.targets.custom_targets.every(isCustomTarget))) + ); +} + +function isCondition(value: unknown): value is FlagConditionV2 { + return ( + isRecord(value) && + typeof value.operator === 'string' && + (value.target_type === undefined || typeof value.target_type === 'string') + ); +} + +function isRule(value: unknown): value is FlagRuleV2 { + return ( + isRecord(value) && + typeof value.id === 'string' && + typeof value.kind === 'string' && + (value.conditions === undefined || + (Array.isArray(value.conditions) && value.conditions.every(isCondition))) + ); +} + +function isV2Entry(value: unknown): value is FlagPollEntryV2 { + 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) + ); +} + +/** Normalize both wire generations before swapping the store. Unknown shapes + * leave the last good snapshot intact; bootstrap callers can still be ready. */ +export function toV2(response: unknown): FlagPollResponseV2 | undefined { + if (!isRecord(response)) return undefined; + + // A legacy flag can itself be named "version". Its entry is an object, + // unlike the numeric marker on a versioned envelope. + if ('version' in response && !isV1Entry(response.version)) { + if (response.version !== 2 || !isRecord(response.flags)) return undefined; + const entries: Array<[string, FlagPollEntryV2]> = []; + for (const [slug, entry] of Object.entries(response.flags)) { + if (!isV2Entry(entry)) return undefined; + entries.push([slug, entry]); + } + return { version: 2, flags: Object.fromEntries(entries) }; + } + + const entries: Array<[string, FlagPollEntryV2]> = []; + for (const [slug, entry] of Object.entries(response)) { + if (!isV1Entry(entry)) return undefined; + const targetsByType = new Map(); + const addTarget = (type: string, target: FlagTarget) => { + if (!target.enabled) return; + const ids = targetsByType.get(type); + if (ids) ids.push(target.id); + else targetsByType.set(type, [target.id]); + }; + for (const target of entry.targets.users) addTarget('user', target); + for (const target of entry.targets.organizations) + addTarget('organization', target); + for (const target of entry.targets.custom_targets ?? []) + addTarget(target.type, target); + + // Legacy target order has no meaning. Keep the synthetic rule order stable + // so reordering a legacy response doesn't generate change events. + const rules: FlagRuleV2[] = [...targetsByType] + .sort(([a], [b]) => a.localeCompare(b)) + .map(([type, values]) => ({ + id: `v1:${type}`, + kind: 'conditions', + value: true, + conditions: [{ target_type: type, operator: 'one_of', values }], + })); + entries.push([ + slug, + { + slug: entry.slug, + enabled: entry.enabled, + default_value: entry.default_value, + off_value: false, + rules, + }, + ]); + } + return { version: 2, flags: Object.fromEntries(entries) }; +} diff --git a/src/feature-flags/runtime-client-v2.spec.ts b/src/feature-flags/runtime-client-v2.spec.ts new file mode 100644 index 000000000..7fc53103d --- /dev/null +++ b/src/feature-flags/runtime-client-v2.spec.ts @@ -0,0 +1,751 @@ +import fetch from 'jest-fetch-mock'; +import { fetchOnce, fetchURL } from '../common/utils/test-utils'; +import { WorkOS } from '../workos'; +import { + EvaluationContext, + FlagChange, + FlagPollEntryV2, + FlagPollResponse, + FlagPollResponseV2, +} 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: FlagPollEntryV2 = { + slug: 'flag', + enabled: true, + default_value: true, + off_value: false, + rules: [exclusion, inclusion], +}; +const payload: FlagPollResponseV2 = { version: 2, flags: { flag } }; + +describe('v2 runtime payloads', () => { + 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 } } }, + ], + ['invalid legacy entry', { flag: { slug: 'flag', targets: null } }], + ['null', null], + ['array', []], + ['scalar', false], + ])( + 'ignores %s bootstrap data, resolves readiness, and then polls', + async (_name, input) => { + const bootstrapFlags: FlagPollResponse = JSON.parse( + JSON.stringify(input), + ); + const client = workos.featureFlags.createRuntimeClient({ + bootstrapFlags, + }); + await client.waitUntilReady(); + + 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' }, + 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: 'malformed membership is nonmatching', + config: { + default_value: false, + rules: [ + { + ...inclusion, + conditions: [ + { target_type: 'user', operator: 'one_of', values: 'user_1' }, + ], + }, + ], + }, + context: { userId: 'user_1' }, + 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 v2 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 v2 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 } } }, + ], + ])( + '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); + 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('converts legacy enabled targets to one true rule per type with stable change detection', async () => { + const legacy = { + flag: { + slug: 'flag', + enabled: true, + default_value: false, + targets: { + users: [ + { id: 'user_1', enabled: true }, + { id: 'user_off', enabled: false }, + ], + organizations: [{ id: 'org_off', enabled: false }], + custom_targets: [ + { type: 'workspace', id: 'ws_1', enabled: true }, + { type: 'region', id: 'east', enabled: true }, + { type: 'workspace', id: 'ws_2', enabled: true }, + { type: 'workspace', id: 'ws_off', enabled: false }, + ], + }, + }, + }; + fetchOnce(legacy); + const client = workos.featureFlags.createRuntimeClient(); + const changes: FlagChange[] = []; + client.on('change', (change) => changes.push(change)); + await jest.advanceTimersByTimeAsync(0); + expect(client.getFlag('flag')).toEqual({ + slug: 'flag', + enabled: true, + default_value: false, + off_value: false, + rules: [ + { + id: 'v1:region', + kind: 'conditions', + value: true, + conditions: [ + { target_type: 'region', operator: 'one_of', values: ['east'] }, + ], + }, + { + id: 'v1:user', + kind: 'conditions', + value: true, + conditions: [ + { target_type: 'user', operator: 'one_of', values: ['user_1'] }, + ], + }, + { + id: 'v1:workspace', + kind: 'conditions', + value: true, + conditions: [ + { + target_type: 'workspace', + operator: 'one_of', + values: ['ws_1', 'ws_2'], + }, + ], + }, + ], + }); + expect(client.isEnabled('flag', { workspace: { id: 'ws_2' } })).toBe(true); + expect(client.isEnabled('flag', { workspace: { id: 'ws_off' } })).toBe( + false, + ); + fetchOnce({ + flag: { + ...legacy.flag, + targets: { + ...legacy.flag.targets, + custom_targets: [...legacy.flag.targets.custom_targets].reverse(), + }, + }, + }); + await jest.advanceTimersByTimeAsync(30_000); + expect(changes).toEqual([]); + // An API upgrade can start returning rules without restarting the SDK. + fetchOnce(payload); + await jest.advanceTimersByTimeAsync(30_000); + expect(client.isEnabled('flag', { organizationId: 'org_1' })).toBe(false); + // A rollback to an older API still replaces and evaluates the snapshot. + fetchOnce(legacy); + await jest.advanceTimersByTimeAsync(30_000); + expect(client.isEnabled('flag', { workspace: { id: 'ws_2' } })).toBe(true); + expect(client.getStats().pollErrorCount).toBe(0); + client.close(); + }); + + it.each([1, 2])( + 'accepts an empty v%s bootstrap snapshot', + async (version) => { + const bootstrapFlags: FlagPollResponse = + version === 1 ? {} : { version: 2, flags: {} }; + const client = workos.featureFlags.createRuntimeClient({ + bootstrapFlags, + }); + await client.waitUntilReady(); + expect(client.getAllFlags()).toEqual({}); + expect(client.isEnabled('absent', {}, true)).toBe(true); + client.close(); + }, + ); + + it.each([1, 2])( + 'preserves reserved-looking flag slugs in v%s payloads', + async (version) => { + const entries = ['version', 'flags', '__proto__', 'constructor'].map( + (slug) => [ + slug, + version === 1 + ? { + slug, + enabled: true, + default_value: true, + targets: { users: [], organizations: [] }, + } + : { ...flag, slug }, + ], + ); + const flags = Object.fromEntries(entries); + fetchOnce(version === 1 ? flags : { version: 2, flags }); + 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 v2 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..ad0e798b6 100644 --- a/src/feature-flags/runtime-client.spec.ts +++ b/src/feature-flags/runtime-client.spec.ts @@ -3,7 +3,7 @@ import { fetchOnce, fetchURL } from '../common/utils/test-utils'; import { UnauthorizedException } from '../common/exceptions'; import { WorkOS } from '../workos'; import { FeatureFlagsRuntimeClient } from './runtime-client'; -import { FlagPollResponse } from './interfaces'; +import { FlagPollResponse, FlagPollResponseV2 } from './interfaces'; const workos = new WorkOS('sk_test_Sz3IQjepeSWaI4cMS4ms4sMuU'); @@ -25,6 +25,32 @@ const pollResponse: FlagPollResponse = { }, }; +const normalizedFlags: FlagPollResponseV2['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: 'v1:user', + kind: 'conditions', + value: true, + conditions: [ + { target_type: 'user', operator: 'one_of', values: ['user_123'] }, + ], + }, + ], + }, +}; + describe('FeatureFlagsRuntimeClient', () => { beforeEach(() => { fetch.resetMocks(); @@ -178,12 +204,12 @@ describe('FeatureFlagsRuntimeClient', () => { }); describe('getFlag', () => { - it('returns raw flag entry', async () => { + it('returns normalized 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(normalizedFlags['flag-a']); expect(client.getFlag('unknown')).toBeUndefined(); client.close(); @@ -266,8 +292,8 @@ describe('FeatureFlagsRuntimeClient', () => { expect(changes).toEqual([ { key: 'flag-a', - previous: pollResponse['flag-a'], - current: updatedResponse['flag-a'], + previous: normalizedFlags['flag-a'], + current: { ...normalizedFlags['flag-a'], enabled: false }, }, ]); @@ -301,8 +327,25 @@ describe('FeatureFlagsRuntimeClient', () => { expect(changes).toEqual([ { key: 'flag-b', - previous: pollResponse['flag-b'], - current: updatedResponse['flag-b'], + previous: normalizedFlags['flag-b'], + current: { + ...normalizedFlags['flag-b'], + rules: [ + ...normalizedFlags['flag-b'].rules, + { + id: 'v1:workspace', + kind: 'conditions', + value: true, + conditions: [ + { + target_type: 'workspace', + operator: 'one_of', + values: ['ws_123'], + }, + ], + }, + ], + }, }, ]); @@ -324,7 +367,7 @@ describe('FeatureFlagsRuntimeClient', () => { expect(changes).toEqual([ { key: 'flag-b', - previous: pollResponse['flag-b'], + previous: normalizedFlags['flag-b'], current: null, }, ]); diff --git a/src/feature-flags/runtime-client.ts b/src/feature-flags/runtime-client.ts index e13841597..c37fa1e8b 100644 --- a/src/feature-flags/runtime-client.ts +++ b/src/feature-flags/runtime-client.ts @@ -3,13 +3,13 @@ import { WorkOS } from '../workos'; import { UnauthorizedException } from '../common/exceptions'; import { InMemoryStore } from './in-memory-store'; import { Evaluator } from './evaluator'; +import { toV2 } from './payload'; import { EvaluationContext, FlagChange, - FlagCustomTarget, - FlagPollEntry, + FlagPollEntryV2, FlagPollResponse, - FlagTarget, + FlagPollResponseV2, RuntimeClientOptions, RuntimeClientLogger, RuntimeClientStats, @@ -80,8 +80,9 @@ 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 = toV2(options.bootstrapFlags); + if (bootstrap) this.store.swap(bootstrap.flags); this.stats.flagCount = this.store.size; this.resolveReady(); } @@ -133,7 +134,8 @@ export class FeatureFlagsRuntimeClient extends EventEmitter return this.evaluator.getAllFlags(context); } - getFlag(flagKey: string): FlagPollEntry | undefined { + /** Returns normalized v2 configuration, including for legacy poll responses. */ + getFlag(flagKey: string): FlagPollEntryV2 | undefined { return this.store.get(flagKey); } @@ -164,15 +166,16 @@ export class FeatureFlagsRuntimeClient extends EventEmitter this.stats.pollCount++; this.stats.lastPollAt = new Date(); - const data = await this.fetchWithTimeout(); + const data = toV2(await this.fetchWithTimeout()); + if (!data) throw new Error('Unsupported feature flag payload'); - 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(); @@ -208,7 +211,7 @@ export class FeatureFlagsRuntimeClient extends EventEmitter 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 +265,8 @@ export class FeatureFlagsRuntimeClient extends EventEmitter } private emitChanges( - previous: FlagPollResponse, - current: FlagPollResponse, + previous: FlagPollResponseV2['flags'], + current: FlagPollResponseV2['flags'], ): void { if (!previous || !current) { return; @@ -275,8 +278,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', { @@ -289,41 +292,34 @@ export class FeatureFlagsRuntimeClient extends EventEmitter } private hasEntryChanged( - a: FlagPollEntry | undefined, - b: FlagPollEntry | undefined, + a: FlagPollEntryV2 | undefined, + b: FlagPollEntryV2 | 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: FlagPollEntryV2): 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); } } From 1d942cf4c8e48016786b67ba4cfc5528109e4e9e Mon Sep 17 00:00:00 2001 From: Stanley Phu Date: Thu, 15 Oct 2026 21:00:17 -0700 Subject: [PATCH 2/9] docs: Update feature flag runtime README Co-Authored-By: Claude Opus 5.5 --- README.md | 42 +++++++++++++++++++++--------------------- 1 file changed, 21 insertions(+), 21 deletions(-) diff --git a/README.md b/README.md index 26a47b8e1..c15e2221b 100644 --- a/README.md +++ b/README.md @@ -138,27 +138,27 @@ const { accessToken } = await workos.userManagement.authenticateWithCode({ ## Feature flag runtime -The runtime client requests the v2 flag payload and evaluates rules in array order: -when a flag is off it serves `off_value`; otherwise the first matching rule wins, -then `default_value`. A matching rule can serve `false`. Conditions within a rule -must all match; `one_of` matches an exact ID for its target type. - -`isEnabled(key, context, defaultValue)` keeps the same API. Unknown rule kinds and -condition operators do not match. If the selected value is not a boolean, the -caller-provided default applies (`false` when omitted). `getAllFlags` also uses -`false` for values this SDK cannot interpret. - -Older APIs that return the flat v1 payload remain supported. `bootstrapFlags` -accepts either that legacy map or the complete `{ version: 2, flags: { ... } }` -envelope. Unrecognized bootstrap data is ignored and readiness resolves; polling -can populate the cache afterward. Failed polls retain the last good snapshot. - -**Configuration shape change:** `getFlag()` and the `previous` / `current` -snapshots in `change` events now return v2 entries with `off_value` and `rules`, -including when the API returns v1. Code inspecting `targets` must switch to -`rules`. Keep the versioned envelope when saving v2 bootstrap data; a bare map of -v2 entries is not a bootstrap payload. Reordering rules emits a change event; -reordering IDs within the same condition does not. +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 `FlagPollEntryV2`: `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 `FlagPollResponseV2` +(`{ version: 2, flags: { [slug]: FlagPollEntryV2 } }`) as `bootstrapFlags`. +Unrecognized bootstrap data, including a bare map of entries without the +envelope, is ignored and readiness still resolves; polling populates the cache +afterward. Failed polls keep the last good snapshot. ## SDK Versioning From 6bf1a1252329e5acdaee77b2177f5a8f45211e54 Mon Sep 17 00:00:00 2001 From: Stanley Phu Date: Thu, 15 Oct 2026 23:00:55 -0700 Subject: [PATCH 3/9] refactor: Remove v1 flag payload support Co-Authored-By: Claude Opus 5.5 --- src/feature-flags/evaluator.spec.ts | 108 +++-------- .../flag-poll-response.interface.ts | 27 --- .../runtime-client-options.interface.ts | 6 +- src/feature-flags/payload.ts | 92 ++------- src/feature-flags/runtime-client-v2.spec.ts | 174 ++++-------------- src/feature-flags/runtime-client.spec.ts | 161 +++++++--------- src/feature-flags/runtime-client.ts | 6 +- 7 files changed, 148 insertions(+), 426 deletions(-) diff --git a/src/feature-flags/evaluator.spec.ts b/src/feature-flags/evaluator.spec.ts index 27e45129d..ab0572128 100644 --- a/src/feature-flags/evaluator.spec.ts +++ b/src/feature-flags/evaluator.spec.ts @@ -1,59 +1,50 @@ import { Evaluator } from './evaluator'; import { InMemoryStore } from './in-memory-store'; -import { toV2 } from './payload'; import { EvaluationContext, - FlagPollEntry, + FlagPollEntryV2, RuntimeClientLogger, } from './interfaces'; +const membershipRule = (targetType: string, values: string[]) => ({ + id: `rule_${targetType}`, + kind: 'conditions', + value: true, + conditions: [{ target_type: targetType, operator: 'one_of', values }], +}); + describe('Evaluator', () => { let store: InMemoryStore; let evaluator: Evaluator; let logger: jest.Mocked; - const enabledFlag: FlagPollEntry = { + const enabledFlag: FlagPollEntryV2 = { slug: 'enabled-flag', enabled: true, default_value: true, - targets: { users: [], organizations: [] }, + off_value: false, + rules: [], }; - const disabledFlag: FlagPollEntry = { + const disabledFlag: FlagPollEntryV2 = { slug: 'disabled-flag', enabled: false, default_value: true, - targets: { users: [], organizations: [] }, + off_value: false, + rules: [], }; - const targetedFlag: FlagPollEntry = { + const targetedFlag: FlagPollEntryV2 = { 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(() => { @@ -65,14 +56,11 @@ describe('Evaluator', () => { error: jest.fn(), }; evaluator = new Evaluator(store, logger); - const payload = toV2({ + store.swap({ 'enabled-flag': enabledFlag, 'disabled-flag': disabledFlag, 'targeted-flag': targetedFlag, - 'default-on-flag': defaultOnFlag, }); - if (!payload) throw new Error('Invalid legacy test payload'); - store.swap(payload.flags); }); describe('isEnabled', () => { @@ -85,39 +73,19 @@ 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); - expect( - evaluator.isEnabled('default-on-flag', { userId: 'user_blocked' }), - ).toBe(true); - }); - - it('matches any enabled target with no precedence between types', () => { - expect( - evaluator.isEnabled('targeted-flag', { - userId: 'user_blocked', - organizationId: 'org_123', - }), - ).toBe(true); - }); - - it('falls back to organization target when user target does not match', () => { + it('falls back to the organization rule when the user rule does not match', () => { expect( evaluator.isEnabled('targeted-flag', { userId: 'user_other', @@ -126,7 +94,7 @@ describe('Evaluator', () => { ).toBe(true); }); - it('returns default_value when no target matches', () => { + it('returns default_value when no rule matches', () => { expect( evaluator.isEnabled('targeted-flag', { userId: 'user_other' }), ).toBe(false); @@ -138,7 +106,7 @@ describe('Evaluator', () => { }); 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); @@ -164,23 +132,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', @@ -248,7 +199,6 @@ describe('Evaluator', () => { 'enabled-flag': true, 'disabled-flag': false, 'targeted-flag': true, - 'default-on-flag': true, }); }); @@ -259,7 +209,6 @@ describe('Evaluator', () => { 'enabled-flag': true, 'disabled-flag': false, 'targeted-flag': true, - 'default-on-flag': true, }); }); @@ -276,7 +225,6 @@ describe('Evaluator', () => { 'enabled-flag': true, 'disabled-flag': false, 'targeted-flag': false, - 'default-on-flag': true, }); }); }); diff --git a/src/feature-flags/interfaces/flag-poll-response.interface.ts b/src/feature-flags/interfaces/flag-poll-response.interface.ts index 801de2405..e26724354 100644 --- a/src/feature-flags/interfaces/flag-poll-response.interface.ts +++ b/src/feature-flags/interfaces/flag-poll-response.interface.ts @@ -1,28 +1,3 @@ -export interface FlagTarget { - id: string; - enabled: boolean; -} - -export interface FlagCustomTarget { - type: string; - id: string; - enabled: boolean; -} - -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[]; - }; -} - -export type FlagPollResponseV1 = Record; - export interface FlagConditionV2 { // Future operators may carry different operands. Unsupported conditions // never match, even if another condition in the rule matches. @@ -52,5 +27,3 @@ export interface FlagPollResponseV2 { version: 2; flags: Record; } - -export type FlagPollResponse = FlagPollResponseV1 | FlagPollResponseV2; diff --git a/src/feature-flags/interfaces/runtime-client-options.interface.ts b/src/feature-flags/interfaces/runtime-client-options.interface.ts index 6cb00e536..2526f3206 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 { FlagPollResponse } from './flag-poll-response.interface'; +import { FlagPollResponseV2 } from './flag-poll-response.interface'; export interface RuntimeClientLogger { debug(...args: unknown[]): void; @@ -9,8 +9,8 @@ export interface RuntimeClientLogger { export interface RuntimeClientOptions { pollingIntervalMs?: number; - /** A legacy flat payload or a versioned v2 envelope, as returned by polling. */ - bootstrapFlags?: FlagPollResponse; + /** A versioned envelope, as returned by polling. */ + bootstrapFlags?: FlagPollResponseV2; requestTimeoutMs?: number; logger?: RuntimeClientLogger; } diff --git a/src/feature-flags/payload.ts b/src/feature-flags/payload.ts index 670ffd61c..671b3b9ad 100644 --- a/src/feature-flags/payload.ts +++ b/src/feature-flags/payload.ts @@ -1,46 +1,14 @@ import { FlagConditionV2, - FlagCustomTarget, - FlagPollEntry, FlagPollEntryV2, FlagPollResponseV2, FlagRuleV2, - FlagTarget, } from './interfaces'; function isRecord(value: unknown): value is Record { return typeof value === 'object' && value !== null && !Array.isArray(value); } -function isTarget(value: unknown): value is FlagTarget { - return ( - isRecord(value) && - typeof value.id === 'string' && - typeof value.enabled === 'boolean' - ); -} - -function isCustomTarget(value: unknown): value is FlagCustomTarget { - return isTarget(value) && 'type' in value && typeof value.type === 'string'; -} - -function isV1Entry(value: unknown): value is FlagPollEntry { - return ( - isRecord(value) && - typeof value.slug === 'string' && - typeof value.enabled === 'boolean' && - typeof value.default_value === 'boolean' && - isRecord(value.targets) && - Array.isArray(value.targets.users) && - value.targets.users.every(isTarget) && - Array.isArray(value.targets.organizations) && - value.targets.organizations.every(isTarget) && - (value.targets.custom_targets === undefined || - (Array.isArray(value.targets.custom_targets) && - value.targets.custom_targets.every(isCustomTarget))) - ); -} - function isCondition(value: unknown): value is FlagConditionV2 { return ( isRecord(value) && @@ -71,59 +39,21 @@ function isV2Entry(value: unknown): value is FlagPollEntryV2 { ); } -/** Normalize both wire generations before swapping the store. Unknown shapes - * leave the last good snapshot intact; bootstrap callers can still be ready. */ +/** Validate a payload before swapping the store. Unknown shapes leave the last + * good snapshot intact; bootstrap callers can still be ready. */ export function toV2(response: unknown): FlagPollResponseV2 | undefined { - if (!isRecord(response)) return undefined; - - // A legacy flag can itself be named "version". Its entry is an object, - // unlike the numeric marker on a versioned envelope. - if ('version' in response && !isV1Entry(response.version)) { - if (response.version !== 2 || !isRecord(response.flags)) return undefined; - const entries: Array<[string, FlagPollEntryV2]> = []; - for (const [slug, entry] of Object.entries(response.flags)) { - if (!isV2Entry(entry)) return undefined; - entries.push([slug, entry]); - } - return { version: 2, flags: Object.fromEntries(entries) }; + if ( + !isRecord(response) || + response.version !== 2 || + !isRecord(response.flags) + ) { + return undefined; } const entries: Array<[string, FlagPollEntryV2]> = []; - for (const [slug, entry] of Object.entries(response)) { - if (!isV1Entry(entry)) return undefined; - const targetsByType = new Map(); - const addTarget = (type: string, target: FlagTarget) => { - if (!target.enabled) return; - const ids = targetsByType.get(type); - if (ids) ids.push(target.id); - else targetsByType.set(type, [target.id]); - }; - for (const target of entry.targets.users) addTarget('user', target); - for (const target of entry.targets.organizations) - addTarget('organization', target); - for (const target of entry.targets.custom_targets ?? []) - addTarget(target.type, target); - - // Legacy target order has no meaning. Keep the synthetic rule order stable - // so reordering a legacy response doesn't generate change events. - const rules: FlagRuleV2[] = [...targetsByType] - .sort(([a], [b]) => a.localeCompare(b)) - .map(([type, values]) => ({ - id: `v1:${type}`, - kind: 'conditions', - value: true, - conditions: [{ target_type: type, operator: 'one_of', values }], - })); - entries.push([ - slug, - { - slug: entry.slug, - enabled: entry.enabled, - default_value: entry.default_value, - off_value: false, - rules, - }, - ]); + for (const [slug, entry] of Object.entries(response.flags)) { + if (!isV2Entry(entry)) return undefined; + entries.push([slug, entry]); } return { version: 2, flags: Object.fromEntries(entries) }; } diff --git a/src/feature-flags/runtime-client-v2.spec.ts b/src/feature-flags/runtime-client-v2.spec.ts index 7fc53103d..7f831860b 100644 --- a/src/feature-flags/runtime-client-v2.spec.ts +++ b/src/feature-flags/runtime-client-v2.spec.ts @@ -5,7 +5,6 @@ import { EvaluationContext, FlagChange, FlagPollEntryV2, - FlagPollResponse, FlagPollResponseV2, } from './interfaces'; @@ -32,6 +31,14 @@ const flag: FlagPollEntryV2 = { rules: [exclusion, inclusion], }; const payload: FlagPollResponseV2 = { version: 2, flags: { flag } }; +const legacyPayload = { + flag: { + slug: 'flag', + enabled: true, + default_value: true, + targets: { users: [{ id: 'user_1', enabled: true }], organizations: [] }, + }, +}; describe('v2 runtime payloads', () => { beforeEach(() => { @@ -71,14 +78,14 @@ describe('v2 runtime payloads', () => { 'invalid entry', { version: 2, flags: { flag: { ...flag, rules: null } } }, ], - ['invalid legacy entry', { flag: { slug: 'flag', targets: null } }], + ['legacy flat', legacyPayload], ['null', null], ['array', []], ['scalar', false], ])( 'ignores %s bootstrap data, resolves readiness, and then polls', async (_name, input) => { - const bootstrapFlags: FlagPollResponse = JSON.parse( + const bootstrapFlags: FlagPollResponseV2 = JSON.parse( JSON.stringify(input), ); const client = workos.featureFlags.createRuntimeClient({ @@ -518,6 +525,7 @@ describe('v2 runtime payloads', () => { 'malformed payload', { version: 2, flags: { flag: { ...flag, rules: null } } }, ], + ['legacy flat payload', legacyPayload], ])( 'keeps the last good snapshot after %s and recovers on the next poll', async (_name, failure) => { @@ -553,149 +561,41 @@ describe('v2 runtime payloads', () => { }, ); - it('converts legacy enabled targets to one true rule per type with stable change detection', async () => { - const legacy = { - flag: { - slug: 'flag', - enabled: true, - default_value: false, - targets: { - users: [ - { id: 'user_1', enabled: true }, - { id: 'user_off', enabled: false }, - ], - organizations: [{ id: 'org_off', enabled: false }], - custom_targets: [ - { type: 'workspace', id: 'ws_1', enabled: true }, - { type: 'region', id: 'east', enabled: true }, - { type: 'workspace', id: 'ws_2', enabled: true }, - { type: 'workspace', id: 'ws_off', enabled: false }, - ], - }, - }, - }; - fetchOnce(legacy); + it('accepts 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.getFlag('flag')).toEqual({ - slug: 'flag', - enabled: true, - default_value: false, - off_value: false, - rules: [ - { - id: 'v1:region', - kind: 'conditions', - value: true, - conditions: [ - { target_type: 'region', operator: 'one_of', values: ['east'] }, - ], - }, - { - id: 'v1:user', - kind: 'conditions', - value: true, - conditions: [ - { target_type: 'user', operator: 'one_of', values: ['user_1'] }, - ], - }, - { - id: 'v1:workspace', - kind: 'conditions', - value: true, - conditions: [ - { - target_type: 'workspace', - operator: 'one_of', - values: ['ws_1', 'ws_2'], - }, - ], - }, - ], - }); - expect(client.isEnabled('flag', { workspace: { id: 'ws_2' } })).toBe(true); - expect(client.isEnabled('flag', { workspace: { id: 'ws_off' } })).toBe( - false, + expect(client.getAllFlags()).toEqual( + JSON.parse( + '{"version":true,"flags":true,"__proto__":true,"constructor":true}', + ), ); - fetchOnce({ - flag: { - ...legacy.flag, - targets: { - ...legacy.flag.targets, - custom_targets: [...legacy.flag.targets.custom_targets].reverse(), - }, - }, - }); - await jest.advanceTimersByTimeAsync(30_000); - expect(changes).toEqual([]); - // An API upgrade can start returning rules without restarting the SDK. - fetchOnce(payload); - await jest.advanceTimersByTimeAsync(30_000); - expect(client.isEnabled('flag', { organizationId: 'org_1' })).toBe(false); - // A rollback to an older API still replaces and evaluates the snapshot. - fetchOnce(legacy); + expect(client.getFlag('__proto__')?.slug).toBe('__proto__'); + fetchOnce({ version: 2, flags: {} }); await jest.advanceTimersByTimeAsync(30_000); - expect(client.isEnabled('flag', { workspace: { id: 'ws_2' } })).toBe(true); - expect(client.getStats().pollErrorCount).toBe(0); + expect(changes).toHaveLength(4); + expect(changes.every((change) => change.current === null)).toBe(true); + expect(client.getFlag('constructor')).toBeUndefined(); client.close(); }); - it.each([1, 2])( - 'accepts an empty v%s bootstrap snapshot', - async (version) => { - const bootstrapFlags: FlagPollResponse = - version === 1 ? {} : { version: 2, flags: {} }; - const client = workos.featureFlags.createRuntimeClient({ - bootstrapFlags, - }); - await client.waitUntilReady(); - expect(client.getAllFlags()).toEqual({}); - expect(client.isEnabled('absent', {}, true)).toBe(true); - client.close(); - }, - ); - - it.each([1, 2])( - 'preserves reserved-looking flag slugs in v%s payloads', - async (version) => { - const entries = ['version', 'flags', '__proto__', 'constructor'].map( - (slug) => [ - slug, - version === 1 - ? { - slug, - enabled: true, - default_value: true, - targets: { users: [], organizations: [] }, - } - : { ...flag, slug }, - ], - ); - const flags = Object.fromEntries(entries); - fetchOnce(version === 1 ? flags : { version: 2, flags }); - 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(); diff --git a/src/feature-flags/runtime-client.spec.ts b/src/feature-flags/runtime-client.spec.ts index ad0e798b6..37b3af4f8 100644 --- a/src/feature-flags/runtime-client.spec.ts +++ b/src/feature-flags/runtime-client.spec.ts @@ -3,53 +3,39 @@ import { fetchOnce, fetchURL } from '../common/utils/test-utils'; import { UnauthorizedException } from '../common/exceptions'; import { WorkOS } from '../workos'; import { FeatureFlagsRuntimeClient } from './runtime-client'; -import { FlagPollResponse, FlagPollResponseV2 } from './interfaces'; +import { FlagPollResponseV2 } 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: [], +const pollResponse: FlagPollResponseV2 = { + 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 normalizedFlags: FlagPollResponseV2['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: 'v1: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(() => { @@ -204,12 +190,12 @@ describe('FeatureFlagsRuntimeClient', () => { }); describe('getFlag', () => { - it('returns normalized flag configuration', async () => { + it('returns flag configuration', async () => { const client = createClientAndWait(); await jest.advanceTimersByTimeAsync(0); await client.waitUntilReady(); - expect(client.getFlag('flag-a')).toEqual(normalizedFlags['flag-a']); + expect(client.getFlag('flag-a')).toEqual(flagA); expect(client.getFlag('unknown')).toBeUndefined(); client.close(); @@ -276,14 +262,9 @@ describe('FeatureFlagsRuntimeClient', () => { const changes: unknown[] = []; 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'], + const updatedResponse: FlagPollResponseV2 = { + version: 2, + flags: { 'flag-a': { ...flagA, enabled: false }, 'flag-b': flagB }, }; fetchOnce(updatedResponse); @@ -292,15 +273,15 @@ describe('FeatureFlagsRuntimeClient', () => { expect(changes).toEqual([ { key: 'flag-a', - previous: normalizedFlags['flag-a'], - current: { ...normalizedFlags['flag-a'], enabled: false }, + 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(); @@ -308,45 +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: normalizedFlags['flag-b'], - current: { - ...normalizedFlags['flag-b'], - rules: [ - ...normalizedFlags['flag-b'].rules, - { - id: 'v1:workspace', - kind: 'conditions', - value: true, - conditions: [ - { - target_type: 'workspace', - operator: 'one_of', - values: ['ws_123'], - }, - ], - }, - ], - }, - }, + { key: 'flag-b', previous: flagB, current: updatedFlagB }, ]); client.close(); @@ -361,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: normalizedFlags['flag-b'], + previous: flagB, current: null, }, ]); @@ -378,12 +347,16 @@ 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: [] }, + const bootstrapFlags: FlagPollResponseV2 = { + 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 c37fa1e8b..2cec7f5bd 100644 --- a/src/feature-flags/runtime-client.ts +++ b/src/feature-flags/runtime-client.ts @@ -8,7 +8,6 @@ import { EvaluationContext, FlagChange, FlagPollEntryV2, - FlagPollResponse, FlagPollResponseV2, RuntimeClientOptions, RuntimeClientLogger, @@ -134,7 +133,6 @@ export class FeatureFlagsRuntimeClient extends EventEmitter return this.evaluator.getAllFlags(context); } - /** Returns normalized v2 configuration, including for legacy poll responses. */ getFlag(flagKey: string): FlagPollEntryV2 | undefined { return this.store.get(flagKey); } @@ -204,14 +202,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?payload_version=2') + .get('/sdk/feature-flags?payload_version=2') .then(({ data }) => data); const timeoutPromise = new Promise((_, reject) => { From f5d8f584e0cfcae20be18d055c4dcc22caec4418 Mon Sep 17 00:00:00 2001 From: Stanley Phu Date: Thu, 15 Oct 2026 23:01:28 -0700 Subject: [PATCH 4/9] refactor: Remove version suffixes from flag poll types Co-Authored-By: Claude Opus 5.5 --- README.md | 6 ++--- src/feature-flags/evaluator.spec.ts | 8 +++---- src/feature-flags/evaluator.ts | 4 ++-- src/feature-flags/in-memory-store.spec.ts | 6 ++--- src/feature-flags/in-memory-store.ts | 10 ++++----- .../interfaces/flag-change.interface.ts | 6 ++--- .../flag-poll-response.interface.ts | 14 ++++++------ .../runtime-client-options.interface.ts | 4 ++-- src/feature-flags/payload.ts | 22 ++++++++++--------- ...2.spec.ts => runtime-client-rules.spec.ts} | 20 ++++++++--------- src/feature-flags/runtime-client.spec.ts | 8 +++---- src/feature-flags/runtime-client.ts | 22 +++++++++---------- 12 files changed, 66 insertions(+), 64 deletions(-) rename src/feature-flags/{runtime-client-v2.spec.ts => runtime-client-rules.spec.ts} (97%) diff --git a/README.md b/README.md index c15e2221b..33f9cc0fe 100644 --- a/README.md +++ b/README.md @@ -149,13 +149,13 @@ operators never match. 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 `FlagPollEntryV2`: `slug`, `enabled`, +`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 `FlagPollResponseV2` -(`{ version: 2, flags: { [slug]: FlagPollEntryV2 } }`) as `bootstrapFlags`. +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 and readiness still resolves; polling populates the cache afterward. Failed polls keep the last good snapshot. diff --git a/src/feature-flags/evaluator.spec.ts b/src/feature-flags/evaluator.spec.ts index ab0572128..93f27b701 100644 --- a/src/feature-flags/evaluator.spec.ts +++ b/src/feature-flags/evaluator.spec.ts @@ -2,7 +2,7 @@ import { Evaluator } from './evaluator'; import { InMemoryStore } from './in-memory-store'; import { EvaluationContext, - FlagPollEntryV2, + FlagPollEntry, RuntimeClientLogger, } from './interfaces'; @@ -18,7 +18,7 @@ describe('Evaluator', () => { let evaluator: Evaluator; let logger: jest.Mocked; - const enabledFlag: FlagPollEntryV2 = { + const enabledFlag: FlagPollEntry = { slug: 'enabled-flag', enabled: true, default_value: true, @@ -26,7 +26,7 @@ describe('Evaluator', () => { rules: [], }; - const disabledFlag: FlagPollEntryV2 = { + const disabledFlag: FlagPollEntry = { slug: 'disabled-flag', enabled: false, default_value: true, @@ -34,7 +34,7 @@ describe('Evaluator', () => { rules: [], }; - const targetedFlag: FlagPollEntryV2 = { + const targetedFlag: FlagPollEntry = { slug: 'targeted-flag', enabled: true, default_value: false, diff --git a/src/feature-flags/evaluator.ts b/src/feature-flags/evaluator.ts index f5e69ea11..68fd6b88f 100644 --- a/src/feature-flags/evaluator.ts +++ b/src/feature-flags/evaluator.ts @@ -2,7 +2,7 @@ import { InMemoryStore } from './in-memory-store'; import { EvaluationContext, EvaluationResource, - FlagPollEntryV2, + FlagPollEntry, RuntimeClientLogger, } from './interfaces'; @@ -53,7 +53,7 @@ export class Evaluator { } private evaluate( - entry: FlagPollEntryV2 | undefined, + entry: FlagPollEntry | undefined, normalizedContext: Map, defaultValue: boolean, ): boolean { diff --git a/src/feature-flags/in-memory-store.spec.ts b/src/feature-flags/in-memory-store.spec.ts index 62e36417c..ca1179191 100644 --- a/src/feature-flags/in-memory-store.spec.ts +++ b/src/feature-flags/in-memory-store.spec.ts @@ -1,10 +1,10 @@ import { InMemoryStore } from './in-memory-store'; -import { FlagPollEntryV2 } from './interfaces'; +import { FlagPollEntry } from './interfaces'; describe('InMemoryStore', () => { let store: InMemoryStore; - const flagA: FlagPollEntryV2 = { + const flagA: FlagPollEntry = { slug: 'flag-a', enabled: true, default_value: true, @@ -12,7 +12,7 @@ describe('InMemoryStore', () => { rules: [], }; - const flagB: FlagPollEntryV2 = { + const flagB: FlagPollEntry = { slug: 'flag-b', enabled: false, default_value: false, diff --git a/src/feature-flags/in-memory-store.ts b/src/feature-flags/in-memory-store.ts index 0a50e3c2d..ff54cf2b7 100644 --- a/src/feature-flags/in-memory-store.ts +++ b/src/feature-flags/in-memory-store.ts @@ -1,17 +1,17 @@ -import { FlagPollEntryV2, FlagPollResponseV2 } from './interfaces'; +import { FlagPollEntry, FlagPollResponse } from './interfaces'; export class InMemoryStore { - private flags: FlagPollResponseV2['flags'] = {}; + private flags: FlagPollResponse['flags'] = {}; - swap(newFlags: FlagPollResponseV2['flags']): void { + swap(newFlags: FlagPollResponse['flags']): void { this.flags = { ...newFlags }; } - get(slug: string): FlagPollEntryV2 | undefined { + get(slug: string): FlagPollEntry | undefined { return Object.hasOwn(this.flags, slug) ? this.flags[slug] : undefined; } - getAll(): FlagPollResponseV2['flags'] { + getAll(): FlagPollResponse['flags'] { return { ...this.flags }; } diff --git a/src/feature-flags/interfaces/flag-change.interface.ts b/src/feature-flags/interfaces/flag-change.interface.ts index a55d17526..3b5e29662 100644 --- a/src/feature-flags/interfaces/flag-change.interface.ts +++ b/src/feature-flags/interfaces/flag-change.interface.ts @@ -1,7 +1,7 @@ -import { FlagPollEntryV2 } from './flag-poll-response.interface'; +import { FlagPollEntry } from './flag-poll-response.interface'; export interface FlagChange { key: string; - previous: FlagPollEntryV2 | null; - current: FlagPollEntryV2 | null; + previous: FlagPollEntry | null; + current: FlagPollEntry | null; } diff --git a/src/feature-flags/interfaces/flag-poll-response.interface.ts b/src/feature-flags/interfaces/flag-poll-response.interface.ts index e26724354..62e404462 100644 --- a/src/feature-flags/interfaces/flag-poll-response.interface.ts +++ b/src/feature-flags/interfaces/flag-poll-response.interface.ts @@ -1,4 +1,4 @@ -export interface FlagConditionV2 { +export interface FlagPollCondition { // Future operators may carry different operands. Unsupported conditions // never match, even if another condition in the rule matches. operator: string; @@ -6,24 +6,24 @@ export interface FlagConditionV2 { values?: unknown; } -export interface FlagRuleV2 { +export interface FlagPollRule { id: string; kind: string; // Future multivariate values must fall back to the caller's boolean default. value: unknown; - conditions?: FlagConditionV2[]; + conditions?: FlagPollCondition[]; } -export interface FlagPollEntryV2 { +export interface FlagPollEntry { slug: string; enabled: boolean; default_value: unknown; off_value: unknown; /** Evaluated in array order; the first matching rule wins. */ - rules: FlagRuleV2[]; + rules: FlagPollRule[]; } -export interface FlagPollResponseV2 { +export interface FlagPollResponse { version: 2; - flags: Record; + 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 2526f3206..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 { FlagPollResponseV2 } from './flag-poll-response.interface'; +import { FlagPollResponse } from './flag-poll-response.interface'; export interface RuntimeClientLogger { debug(...args: unknown[]): void; @@ -10,7 +10,7 @@ export interface RuntimeClientLogger { export interface RuntimeClientOptions { pollingIntervalMs?: number; /** A versioned envelope, as returned by polling. */ - bootstrapFlags?: FlagPollResponseV2; + bootstrapFlags?: FlagPollResponse; requestTimeoutMs?: number; logger?: RuntimeClientLogger; } diff --git a/src/feature-flags/payload.ts b/src/feature-flags/payload.ts index 671b3b9ad..8d9e0dce2 100644 --- a/src/feature-flags/payload.ts +++ b/src/feature-flags/payload.ts @@ -1,15 +1,15 @@ import { - FlagConditionV2, - FlagPollEntryV2, - FlagPollResponseV2, - FlagRuleV2, + 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 FlagConditionV2 { +function isCondition(value: unknown): value is FlagPollCondition { return ( isRecord(value) && typeof value.operator === 'string' && @@ -17,7 +17,7 @@ function isCondition(value: unknown): value is FlagConditionV2 { ); } -function isRule(value: unknown): value is FlagRuleV2 { +function isRule(value: unknown): value is FlagPollRule { return ( isRecord(value) && typeof value.id === 'string' && @@ -27,7 +27,7 @@ function isRule(value: unknown): value is FlagRuleV2 { ); } -function isV2Entry(value: unknown): value is FlagPollEntryV2 { +function isEntry(value: unknown): value is FlagPollEntry { return ( isRecord(value) && typeof value.slug === 'string' && @@ -41,7 +41,9 @@ function isV2Entry(value: unknown): value is FlagPollEntryV2 { /** Validate a payload before swapping the store. Unknown shapes leave the last * good snapshot intact; bootstrap callers can still be ready. */ -export function toV2(response: unknown): FlagPollResponseV2 | undefined { +export function parseFlagPollResponse( + response: unknown, +): FlagPollResponse | undefined { if ( !isRecord(response) || response.version !== 2 || @@ -50,9 +52,9 @@ export function toV2(response: unknown): FlagPollResponseV2 | undefined { return undefined; } - const entries: Array<[string, FlagPollEntryV2]> = []; + const entries: Array<[string, FlagPollEntry]> = []; for (const [slug, entry] of Object.entries(response.flags)) { - if (!isV2Entry(entry)) return undefined; + if (!isEntry(entry)) return undefined; entries.push([slug, entry]); } return { version: 2, flags: Object.fromEntries(entries) }; diff --git a/src/feature-flags/runtime-client-v2.spec.ts b/src/feature-flags/runtime-client-rules.spec.ts similarity index 97% rename from src/feature-flags/runtime-client-v2.spec.ts rename to src/feature-flags/runtime-client-rules.spec.ts index 7f831860b..8f708542e 100644 --- a/src/feature-flags/runtime-client-v2.spec.ts +++ b/src/feature-flags/runtime-client-rules.spec.ts @@ -4,8 +4,8 @@ import { WorkOS } from '../workos'; import { EvaluationContext, FlagChange, - FlagPollEntryV2, - FlagPollResponseV2, + FlagPollEntry, + FlagPollResponse, } from './interfaces'; const workos = new WorkOS('sk_test_example'); @@ -23,14 +23,14 @@ const inclusion = { value: true, conditions: [{ target_type: 'user', operator: 'one_of', values: ['user_1'] }], }; -const flag: FlagPollEntryV2 = { +const flag: FlagPollEntry = { slug: 'flag', enabled: true, default_value: true, off_value: false, rules: [exclusion, inclusion], }; -const payload: FlagPollResponseV2 = { version: 2, flags: { flag } }; +const payload: FlagPollResponse = { version: 2, flags: { flag } }; const legacyPayload = { flag: { slug: 'flag', @@ -40,7 +40,7 @@ const legacyPayload = { }, }; -describe('v2 runtime payloads', () => { +describe('runtime client rules', () => { beforeEach(() => { fetch.resetMocks(); jest.useFakeTimers(); @@ -85,7 +85,7 @@ describe('v2 runtime payloads', () => { ])( 'ignores %s bootstrap data, resolves readiness, and then polls', async (_name, input) => { - const bootstrapFlags: FlagPollResponseV2 = JSON.parse( + const bootstrapFlags: FlagPollResponse = JSON.parse( JSON.stringify(input), ); const client = workos.featureFlags.createRuntimeClient({ @@ -105,7 +105,7 @@ describe('v2 runtime payloads', () => { const cases: Array<{ name: string; - config: Partial; + config: Partial; context?: EvaluationContext; fallback?: boolean; expected: boolean; @@ -379,7 +379,7 @@ describe('v2 runtime payloads', () => { client.close(); }); - it('replaces v2 bootstrap data wholesale on the first poll without change events', async () => { + it('replaces bootstrap data wholesale on the first poll without change events', async () => { const client = workos.featureFlags.createRuntimeClient({ bootstrapFlags: payload, }); @@ -452,7 +452,7 @@ describe('v2 runtime payloads', () => { }, ], ['removed rule', { ...flag, rules: [inclusion] }], - ])('emits complete v2 snapshots when %s changes', async (_name, current) => { + ])('emits complete snapshots when %s changes', async (_name, current) => { fetchOnce(payload); const client = workos.featureFlags.createRuntimeClient(); const changes: FlagChange[] = []; @@ -617,7 +617,7 @@ describe('v2 runtime payloads', () => { client.close(); }); - it('retains the v2 snapshot and stops polling after a 401', async () => { + it('retains the snapshot and stops polling after a 401', async () => { fetchOnce(payload); const client = workos.featureFlags.createRuntimeClient(); const failures: Error[] = []; diff --git a/src/feature-flags/runtime-client.spec.ts b/src/feature-flags/runtime-client.spec.ts index 37b3af4f8..5e9d0cda4 100644 --- a/src/feature-flags/runtime-client.spec.ts +++ b/src/feature-flags/runtime-client.spec.ts @@ -3,11 +3,11 @@ import { fetchOnce, fetchURL } from '../common/utils/test-utils'; import { UnauthorizedException } from '../common/exceptions'; import { WorkOS } from '../workos'; import { FeatureFlagsRuntimeClient } from './runtime-client'; -import { FlagPollResponseV2 } from './interfaces'; +import { FlagPollResponse } from './interfaces'; const workos = new WorkOS('sk_test_Sz3IQjepeSWaI4cMS4ms4sMuU'); -const pollResponse: FlagPollResponseV2 = { +const pollResponse: FlagPollResponse = { version: 2, flags: { 'flag-a': { @@ -262,7 +262,7 @@ describe('FeatureFlagsRuntimeClient', () => { const changes: unknown[] = []; client.on('change', (change) => changes.push(change)); - const updatedResponse: FlagPollResponseV2 = { + const updatedResponse: FlagPollResponse = { version: 2, flags: { 'flag-a': { ...flagA, enabled: false }, 'flag-b': flagB }, }; @@ -347,7 +347,7 @@ describe('FeatureFlagsRuntimeClient', () => { describe('bootstrap flags', () => { it('first poll replaces bootstrap data', async () => { - const bootstrapFlags: FlagPollResponseV2 = { + const bootstrapFlags: FlagPollResponse = { version: 2, flags: { 'bootstrap-flag': { diff --git a/src/feature-flags/runtime-client.ts b/src/feature-flags/runtime-client.ts index 2cec7f5bd..91ecebc7b 100644 --- a/src/feature-flags/runtime-client.ts +++ b/src/feature-flags/runtime-client.ts @@ -3,12 +3,12 @@ import { WorkOS } from '../workos'; import { UnauthorizedException } from '../common/exceptions'; import { InMemoryStore } from './in-memory-store'; import { Evaluator } from './evaluator'; -import { toV2 } from './payload'; +import { parseFlagPollResponse } from './payload'; import { EvaluationContext, FlagChange, - FlagPollEntryV2, - FlagPollResponseV2, + FlagPollEntry, + FlagPollResponse, RuntimeClientOptions, RuntimeClientLogger, RuntimeClientStats, @@ -80,7 +80,7 @@ export class FeatureFlagsRuntimeClient extends EventEmitter this.readyPromise.catch(() => {}); if (options.bootstrapFlags !== undefined) { - const bootstrap = toV2(options.bootstrapFlags); + const bootstrap = parseFlagPollResponse(options.bootstrapFlags); if (bootstrap) this.store.swap(bootstrap.flags); this.stats.flagCount = this.store.size; this.resolveReady(); @@ -133,7 +133,7 @@ export class FeatureFlagsRuntimeClient extends EventEmitter return this.evaluator.getAllFlags(context); } - getFlag(flagKey: string): FlagPollEntryV2 | undefined { + getFlag(flagKey: string): FlagPollEntry | undefined { return this.store.get(flagKey); } @@ -164,7 +164,7 @@ export class FeatureFlagsRuntimeClient extends EventEmitter this.stats.pollCount++; this.stats.lastPollAt = new Date(); - const data = toV2(await this.fetchWithTimeout()); + const data = parseFlagPollResponse(await this.fetchWithTimeout()); if (!data) throw new Error('Unsupported feature flag payload'); this.store.swap(data.flags); @@ -263,8 +263,8 @@ export class FeatureFlagsRuntimeClient extends EventEmitter } private emitChanges( - previous: FlagPollResponseV2['flags'], - current: FlagPollResponseV2['flags'], + previous: FlagPollResponse['flags'], + current: FlagPollResponse['flags'], ): void { if (!previous || !current) { return; @@ -290,13 +290,13 @@ export class FeatureFlagsRuntimeClient extends EventEmitter } private hasEntryChanged( - a: FlagPollEntryV2 | undefined, - b: FlagPollEntryV2 | undefined, + a: FlagPollEntry | undefined, + b: FlagPollEntry | undefined, ): boolean { if (!a || !b) return a !== b; // Rule order determines precedence. Condition and membership order do not. - const signature = (entry: FlagPollEntryV2): string => + const signature = (entry: FlagPollEntry): string => JSON.stringify([ entry.enabled, entry.default_value, From ab83f9f9b8cbd0baed39fa5e5b5c1142f06e58fd Mon Sep 17 00:00:00 2001 From: Stanley Phu Date: Mon, 19 Oct 2026 01:07:09 -0700 Subject: [PATCH 5/9] fix(feature-flags): Warn when bootstrap flags are ignored Co-Authored-By: Claude Opus 5.5 --- README.md | 4 +-- .../runtime-client-rules.spec.ts | 36 +++++++++++++++++-- src/feature-flags/runtime-client.ts | 8 ++++- 3 files changed, 43 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 33f9cc0fe..48fbcb62d 100644 --- a/README.md +++ b/README.md @@ -157,8 +157,8 @@ 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 and readiness still resolves; polling populates the cache -afterward. Failed polls keep the last good snapshot. +envelope, is ignored with a warning to `logger` and readiness still resolves; +polling populates the cache afterward. Failed polls keep the last good snapshot. ## SDK Versioning diff --git a/src/feature-flags/runtime-client-rules.spec.ts b/src/feature-flags/runtime-client-rules.spec.ts index 8f708542e..78381d20e 100644 --- a/src/feature-flags/runtime-client-rules.spec.ts +++ b/src/feature-flags/runtime-client-rules.spec.ts @@ -6,6 +6,7 @@ import { FlagChange, FlagPollEntry, FlagPollResponse, + RuntimeClientLogger, } from './interfaces'; const workos = new WorkOS('sk_test_example'); @@ -39,6 +40,12 @@ const legacyPayload = { targets: { users: [{ id: 'user_1', enabled: true }], organizations: [] }, }, }; +const createLogger = (): jest.Mocked => ({ + debug: jest.fn(), + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), +}); describe('runtime client rules', () => { beforeEach(() => { @@ -79,20 +86,27 @@ describe('runtime client rules', () => { { version: 2, flags: { flag: { ...flag, rules: null } } }, ], ['legacy flat', legacyPayload], + ['bare entry map', { flag }], ['null', null], ['array', []], ['scalar', false], ])( - 'ignores %s bootstrap data, resolves readiness, and then polls', + '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); @@ -561,7 +575,25 @@ describe('runtime client rules', () => { }, ); - it('accepts an empty bootstrap snapshot', async () => { + 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: {} }, }); diff --git a/src/feature-flags/runtime-client.ts b/src/feature-flags/runtime-client.ts index 91ecebc7b..ddbf1d978 100644 --- a/src/feature-flags/runtime-client.ts +++ b/src/feature-flags/runtime-client.ts @@ -81,7 +81,13 @@ export class FeatureFlagsRuntimeClient extends EventEmitter if (options.bootstrapFlags !== undefined) { const bootstrap = parseFlagPollResponse(options.bootstrapFlags); - if (bootstrap) this.store.swap(bootstrap.flags); + 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(); } From 12b110a257cb9a10392ca9711f3620f4e47d8f86 Mon Sep 17 00:00:00 2001 From: Stanley Phu Date: Mon, 19 Oct 2026 12:40:43 -0700 Subject: [PATCH 6/9] test(feature-flags): Cover off values and rule precedence in the evaluator Co-Authored-By: Claude Opus 5.5 --- src/feature-flags/evaluator.spec.ts | 64 ++++++++++++++++++++++++++--- 1 file changed, 58 insertions(+), 6 deletions(-) diff --git a/src/feature-flags/evaluator.spec.ts b/src/feature-flags/evaluator.spec.ts index 93f27b701..eebfed3d0 100644 --- a/src/feature-flags/evaluator.spec.ts +++ b/src/feature-flags/evaluator.spec.ts @@ -6,10 +6,14 @@ import { RuntimeClientLogger, } from './interfaces'; -const membershipRule = (targetType: string, values: string[]) => ({ +const membershipRule = ( + targetType: string, + values: string[], + value: boolean = true, +) => ({ id: `rule_${targetType}`, kind: 'conditions', - value: true, + value, conditions: [{ target_type: targetType, operator: 'one_of', values }], }); @@ -93,16 +97,64 @@ describe('Evaluator', () => { }), ).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('returns default_value when no rule matches', () => { + it('serves off_value when the flag is off, even if a rule matches', () => { expect( - evaluator.isEnabled('targeted-flag', { userId: 'user_other' }), - ).toBe(false); + evaluate({ + enabled: false, + default_value: false, + off_value: true, + rules: [membershipRule('user', ['user_456'], false)], + }), + ).toBe(true); + }); + + it('serves default_value when no rule matches', () => { + expect( + evaluate({ + enabled: true, + default_value: true, + off_value: false, + rules: [membershipRule('user', ['user_other'], false)], + }), + ).toBe(true); + }); + it('serves the first matching rule when it is true', () => { expect( - evaluator.isEnabled('enabled-flag', { userId: 'user_other' }), + 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( + 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', () => { From 9dc2a41a9c848d8bc43a9badbf7e3c4a24f5c20e Mon Sep 17 00:00:00 2001 From: Stanley Phu Date: Mon, 19 Oct 2026 12:58:09 -0700 Subject: [PATCH 7/9] fix(feature-flags): Reject one_of conditions without a values list Co-Authored-By: Claude Opus 5.5 --- src/feature-flags/payload.ts | 6 ++- .../runtime-client-rules.spec.ts | 41 +++++++++++++++++-- 2 files changed, 43 insertions(+), 4 deletions(-) diff --git a/src/feature-flags/payload.ts b/src/feature-flags/payload.ts index 8d9e0dce2..77b122888 100644 --- a/src/feature-flags/payload.ts +++ b/src/feature-flags/payload.ts @@ -13,7 +13,11 @@ function isCondition(value: unknown): value is FlagPollCondition { return ( isRecord(value) && typeof value.operator === 'string' && - (value.target_type === undefined || typeof value.target_type === '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)) ); } diff --git a/src/feature-flags/runtime-client-rules.spec.ts b/src/feature-flags/runtime-client-rules.spec.ts index 78381d20e..42bde4ff1 100644 --- a/src/feature-flags/runtime-client-rules.spec.ts +++ b/src/feature-flags/runtime-client-rules.spec.ts @@ -40,6 +40,36 @@ const legacyPayload = { 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(), @@ -87,6 +117,8 @@ describe('runtime client rules', () => { ], ['legacy flat', legacyPayload], ['bare entry map', { flag }], + ['malformed membership', malformedMembership], + ['membership without values', membershipWithoutValues], ['null', null], ['array', []], ['scalar', false], @@ -299,6 +331,7 @@ describe('runtime client rules', () => { ], }, context: { userId: 'user_1' }, + fallback: true, expected: false, }, { @@ -350,19 +383,20 @@ describe('runtime client rules', () => { expected: true, }, { - name: 'malformed membership is nonmatching', + name: 'membership IDs that are not strings never match', config: { default_value: false, rules: [ { ...inclusion, conditions: [ - { target_type: 'user', operator: 'one_of', values: 'user_1' }, + { target_type: 'user', operator: 'one_of', values: [1] }, ], }, ], }, - context: { userId: 'user_1' }, + context: { userId: '1' }, + fallback: true, expected: false, }, ]; @@ -540,6 +574,7 @@ describe('runtime client rules', () => { { 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) => { From 4f9b2efff1d0b56ea9f6ea9aac542f26080d7a74 Mon Sep 17 00:00:00 2001 From: Stanley Phu Date: Mon, 19 Oct 2026 13:01:07 -0700 Subject: [PATCH 8/9] docs: Explain why one invalid flag rejects the poll payload Co-Authored-By: Claude Opus 5.5 --- src/feature-flags/payload.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/feature-flags/payload.ts b/src/feature-flags/payload.ts index 77b122888..0990d1eb7 100644 --- a/src/feature-flags/payload.ts +++ b/src/feature-flags/payload.ts @@ -43,8 +43,9 @@ function isEntry(value: unknown): value is FlagPollEntry { ); } -/** Validate a payload before swapping the store. Unknown shapes leave the last - * good snapshot intact; bootstrap callers can still be ready. */ +/** 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 { From aec7f7894fca35e6d01370cb7bafc73f3ebcc5a1 Mon Sep 17 00:00:00 2001 From: Stanley Phu Date: Mon, 19 Oct 2026 13:03:31 -0700 Subject: [PATCH 9/9] fix(feature-flags): Explain unreadable poll payload errors Co-Authored-By: Claude Opus 5.5 --- README.md | 6 +++++- src/feature-flags/runtime-client-rules.spec.ts | 3 +++ src/feature-flags/runtime-client.ts | 6 +++++- 3 files changed, 13 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 48fbcb62d..dce77ddd2 100644 --- a/README.md +++ b/README.md @@ -158,7 +158,11 @@ 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. Failed polls keep the last good snapshot. +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 diff --git a/src/feature-flags/runtime-client-rules.spec.ts b/src/feature-flags/runtime-client-rules.spec.ts index 42bde4ff1..ebd761ccd 100644 --- a/src/feature-flags/runtime-client-rules.spec.ts +++ b/src/feature-flags/runtime-client-rules.spec.ts @@ -601,6 +601,9 @@ describe('runtime client rules', () => { 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); diff --git a/src/feature-flags/runtime-client.ts b/src/feature-flags/runtime-client.ts index ddbf1d978..616522863 100644 --- a/src/feature-flags/runtime-client.ts +++ b/src/feature-flags/runtime-client.ts @@ -171,7 +171,11 @@ export class FeatureFlagsRuntimeClient extends EventEmitter this.stats.lastPollAt = new Date(); const data = parseFlagPollResponse(await this.fetchWithTimeout()); - if (!data) throw new Error('Unsupported feature flag payload'); + 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.flags); this.stats.lastSuccessfulPollAt = new Date();