From c9ac1cd7632d805c62eeb29e2d3fdc1f3cade766 Mon Sep 17 00:00:00 2001 From: Stanley Phu Date: Wed, 30 Sep 2026 08:49:23 -0700 Subject: [PATCH 01/13] 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 02/13] 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 03/13] 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 04/13] 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 05/13] 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 06/13] 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 07/13] 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 08/13] 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 09/13] 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(); From 2ce7689f15db372983edd168306d4cba89d32a08 Mon Sep 17 00:00:00 2001 From: Stanley Phu Date: Thu, 1 Oct 2026 10:05:25 -0700 Subject: [PATCH 10/13] feat(feature-flags): Add rule-first management methods --- README.md | 68 ++++ .../create-rule-with-targets-error.ts | 16 + src/feature-flags/feature-flags.ts | 104 +++++- .../create-flag-rule-options.interface.ts | 7 + .../create-flag-target-options.interface.ts | 5 + ...ate-rule-with-targets-options.interface.ts | 12 + .../interfaces/flag-rule.interface.ts | 29 ++ .../flag-target-membership.interface.ts | 26 ++ src/feature-flags/interfaces/index.ts | 6 + .../list-flag-rules-options.interface.ts | 9 + src/feature-flags/rule-management.spec.ts | 298 ++++++++++++++++++ .../serializers/flag-rule.serializer.ts | 15 + .../flag-target-membership.serializer.ts | 19 ++ src/feature-flags/serializers/index.ts | 2 + src/index.ts | 1 + src/index.worker.ts | 2 + 16 files changed, 618 insertions(+), 1 deletion(-) create mode 100644 src/feature-flags/create-rule-with-targets-error.ts create mode 100644 src/feature-flags/interfaces/create-flag-rule-options.interface.ts create mode 100644 src/feature-flags/interfaces/create-flag-target-options.interface.ts create mode 100644 src/feature-flags/interfaces/create-rule-with-targets-options.interface.ts create mode 100644 src/feature-flags/interfaces/flag-rule.interface.ts create mode 100644 src/feature-flags/interfaces/flag-target-membership.interface.ts create mode 100644 src/feature-flags/interfaces/list-flag-rules-options.interface.ts create mode 100644 src/feature-flags/rule-management.spec.ts create mode 100644 src/feature-flags/serializers/flag-rule.serializer.ts create mode 100644 src/feature-flags/serializers/flag-target-membership.serializer.ts diff --git a/README.md b/README.md index dce77ddd2..c720701b6 100644 --- a/README.md +++ b/README.md @@ -164,6 +164,74 @@ 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`. +## Feature flag rule management + +Create an empty rule, then add memberships using the rule's ID: + +```ts +const rule = await workos.featureFlags.createFlagRule({ + featureFlag: 'new-checkout', + targetType: 'organization', + value: true, +}); + +const membership = await workos.featureFlags.createFlagTarget({ + ruleId: rule.id, + targetId: 'org_...', +}); + +await workos.featureFlags.deleteFlagTarget(membership.id); +``` + +Rules are appended in evaluation order. `targetType` can be `organization`, +`user`, or a registered custom target type. A duplicate rule with the same +target type and value returns a conflict; it does not reuse the existing rule. +Use `listFlagRules({ featureFlag: 'new-checkout' })` to find existing rules, +`getFlagRule(rule.id)` to inspect one, or `deleteFlagRule(rule.id)` to delete a +rule and its memberships. Rule lists support `limit`, `before`, `after`, and +`autoPagination()` and always follow the server's evaluation order. + +To create a rule and its initial members together: + +```ts +import { CreateRuleWithTargetsError } from '@workos-inc/node'; + +try { + const { rule, targets } = await workos.featureFlags.createRuleWithTargets({ + featureFlag: 'new-checkout', + targetType: 'organization', + value: true, + targetIds: ['org_first', 'org_second'], + }); +} catch (error) { + if (error instanceof CreateRuleWithTargetsError) { + // Save these IDs to reconcile or resume membership creation. + console.error(error.rule.id, error.targets, error.failedTargetId, error.cause); + } + throw error; +} +``` + +This helper performs separate requests in order and stops at the first failure. +It does not roll back: the rule and confirmed memberships remain. A rule +creation error is returned unchanged; a membership error includes the created +rule, confirmed memberships, failed target ID, and original error as `cause`. +After an ambiguous network failure, the failed membership may also exist on +the server. Reconcile it before retrying; do not retry the whole helper, which +would try to create the rule again. An empty `targetIds` list creates an empty +rule. + +The legacy `addFlagTarget({ slug, targetId })` and +`removeFlagTarget({ slug, targetId })` helpers remain supported on their alias +routes. Their deprecation annotations guide new integrations toward explicit +rules; existing integrations do not need to change. The new REST membership +type is `FlagTargetMembership`; the existing `FlagTarget` polling type is +unchanged. + +Rule endpoints require the targeting-rules rollout. The membership methods and +convenience helper also require an API deployment that accepts `rule_id` on +`POST /flag_targets`. + ## 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/create-rule-with-targets-error.ts b/src/feature-flags/create-rule-with-targets-error.ts new file mode 100644 index 000000000..50fa6d3ac --- /dev/null +++ b/src/feature-flags/create-rule-with-targets-error.ts @@ -0,0 +1,16 @@ +import { FlagRule, FlagTargetMembership } from './interfaces'; + +/** A rule was created, but adding one of its targets failed. */ +export class CreateRuleWithTargetsError extends Error { + readonly name = 'CreateRuleWithTargetsError'; + + constructor( + readonly rule: FlagRule, + /** Memberships confirmed by the API before the failure. */ + readonly targets: FlagTargetMembership[], + readonly failedTargetId: string, + cause: unknown, + ) { + super('The rule was created, but adding a target failed.', { cause }); + } +} diff --git a/src/feature-flags/feature-flags.ts b/src/feature-flags/feature-flags.ts index 707f72336..d830e0938 100644 --- a/src/feature-flags/feature-flags.ts +++ b/src/feature-flags/feature-flags.ts @@ -2,13 +2,29 @@ import { AutoPaginatable } from '../common/utils/pagination'; import { WorkOS } from '../workos'; import { AddFlagTargetOptions, + CreateFlagRuleOptions, + CreateFlagTargetOptions, + CreateRuleWithTargetsOptions, + CreateRuleWithTargetsResult, FeatureFlag, FeatureFlagResponse, + FlagRule, + FlagRuleResponse, + FlagTargetMembership, + FlagTargetMembershipResponse, + ListFlagRulesOptions, ListFeatureFlagsOptions, RemoveFlagTargetOptions, RuntimeClientOptions, } from './interfaces'; -import { deserializeFeatureFlag } from './serializers'; +import { + deserializeFeatureFlag, + deserializeFlagRule, + deserializeFlagTargetMembership, +} from './serializers'; +import { CreateRuleWithTargetsError } from './create-rule-with-targets-error'; +import { ListResponse, PaginationOptions } from '../common/interfaces'; +import { deserializeList } from '../common/serializers'; import { fetchAndDeserialize } from '../common/utils/fetch-and-deserialize'; import { FeatureFlagsRuntimeClient } from './runtime-client'; import { ListOrganizationFeatureFlagsOptions } from '../organizations/interfaces/list-organization-feature-flags-options.interface'; @@ -18,6 +34,86 @@ import { encodePathParameter } from '../common/utils/encode-path-parameter'; export class FeatureFlags { constructor(private readonly workos: WorkOS) {} + /** Append an empty rule to a flag. Duplicate target type/value pairs return 409. */ + async createFlagRule(options: CreateFlagRuleOptions): Promise { + const { data } = await this.workos.post('/flag_rules', { + feature_flag: options.featureFlag, + target_type: options.targetType, + value: options.value, + }); + return deserializeFlagRule(data); + } + + /** List a flag's rules in evaluation order. */ + async listFlagRules( + options: ListFlagRulesOptions, + ): Promise> { + const fetchPage = async ({ limit, before, after }: PaginationOptions) => { + const { data } = await this.workos.get>( + '/flag_rules', + { + query: { feature_flag: options.featureFlag, limit, before, after }, + }, + ); + return deserializeList(data, deserializeFlagRule); + }; + return new AutoPaginatable(await fetchPage(options), fetchPage, options); + } + + /** Get a rule in the current environment. */ + async getFlagRule(id: string): Promise { + const { data } = await this.workos.get( + `/flag_rules/${encodePathParameter(id)}`, + ); + return deserializeFlagRule(data); + } + + /** Delete a rule and all its target memberships. */ + async deleteFlagRule(id: string): Promise { + await this.workos.delete(`/flag_rules/${encodePathParameter(id)}`); + } + + /** Add a membership to a rule. The rule supplies the target type and value. */ + async createFlagTarget( + options: CreateFlagTargetOptions, + ): Promise { + const { data } = await this.workos.post( + '/flag_targets', + { rule_id: options.ruleId, target_id: options.targetId }, + ); + return deserializeFlagTargetMembership(data); + } + + /** Delete a membership by its flag_target ID, not the targeted entity's ID. */ + async deleteFlagTarget(id: string): Promise { + await this.workos.delete(`/flag_targets/${encodePathParameter(id)}`); + } + + // @oagen-ignore-start + /** + * Create a rule, then add its targets sequentially in input order. + * This is not atomic: on failure, the rule and completed memberships remain. + * A CreateRuleWithTargetsError exposes the rule, confirmed targets, failed + * target ID, and original error. No later targets are attempted. + */ + async createRuleWithTargets( + options: CreateRuleWithTargetsOptions, + ): Promise { + const rule = await this.createFlagRule(options); + const targets: FlagTargetMembership[] = []; + for (const targetId of options.targetIds) { + try { + targets.push( + await this.createFlagTarget({ ruleId: rule.id, targetId }), + ); + } catch (cause) { + throw new CreateRuleWithTargetsError(rule, targets, targetId, cause); + } + } + return { rule, targets }; + } + // @oagen-ignore-end + /** * List feature flags * @@ -114,6 +210,9 @@ export class FeatureFlags { /** * Add a feature flag target * + * @deprecated For rule-based targeting, use createFlagRule and createFlagTarget. + * This compatibility helper remains supported. + * * Enables a feature flag for a specific target in the current environment. Currently, supported targets include users and organizations. * @params options - Object containing slug and targetId. * @returns {Promise} @@ -132,6 +231,9 @@ export class FeatureFlags { /** * Remove a feature flag target * + * @deprecated For rule-based targeting, use deleteFlagTarget with a membership ID. + * This compatibility helper remains supported. + * * Removes a target from the feature flag's target list in the current environment. Currently, supported targets include users and organizations. * @params options - Object containing slug and targetId. * @returns {Promise} diff --git a/src/feature-flags/interfaces/create-flag-rule-options.interface.ts b/src/feature-flags/interfaces/create-flag-rule-options.interface.ts new file mode 100644 index 000000000..6e4bebe2d --- /dev/null +++ b/src/feature-flags/interfaces/create-flag-rule-options.interface.ts @@ -0,0 +1,7 @@ +export interface CreateFlagRuleOptions { + /** The feature flag's slug. */ + featureFlag: string; + /** An organization, user, or registered custom target type. */ + targetType: string; + value: boolean; +} diff --git a/src/feature-flags/interfaces/create-flag-target-options.interface.ts b/src/feature-flags/interfaces/create-flag-target-options.interface.ts new file mode 100644 index 000000000..6a404ea17 --- /dev/null +++ b/src/feature-flags/interfaces/create-flag-target-options.interface.ts @@ -0,0 +1,5 @@ +export interface CreateFlagTargetOptions { + /** The rule determines the flag, target type, and value. */ + ruleId: string; + targetId: string; +} diff --git a/src/feature-flags/interfaces/create-rule-with-targets-options.interface.ts b/src/feature-flags/interfaces/create-rule-with-targets-options.interface.ts new file mode 100644 index 000000000..3b125e0a1 --- /dev/null +++ b/src/feature-flags/interfaces/create-rule-with-targets-options.interface.ts @@ -0,0 +1,12 @@ +import { CreateFlagRuleOptions } from './create-flag-rule-options.interface'; +import { FlagRule } from './flag-rule.interface'; +import { FlagTargetMembership } from './flag-target-membership.interface'; + +export interface CreateRuleWithTargetsOptions extends CreateFlagRuleOptions { + targetIds: string[]; +} + +export interface CreateRuleWithTargetsResult { + rule: FlagRule; + targets: FlagTargetMembership[]; +} diff --git a/src/feature-flags/interfaces/flag-rule.interface.ts b/src/feature-flags/interfaces/flag-rule.interface.ts new file mode 100644 index 000000000..519abe175 --- /dev/null +++ b/src/feature-flags/interfaces/flag-rule.interface.ts @@ -0,0 +1,29 @@ +export interface FlagRule { + object: 'flag_rule'; + id: string; + flagId: string; + flagSlug: string; + environmentId: string; + /** Null for rules that match every context. */ + targetType: string | null; + /** Rules are evaluated in ascending position order. */ + position: number; + valueType: 'boolean'; + value: boolean; + createdAt: string; + updatedAt: string; +} + +export interface FlagRuleResponse { + object: 'flag_rule'; + id: string; + flag_id: string; + flag_slug: string; + environment_id: string; + target_type: string | null; + position: number; + value_type: 'boolean'; + value: boolean; + created_at: string; + updated_at: string; +} diff --git a/src/feature-flags/interfaces/flag-target-membership.interface.ts b/src/feature-flags/interfaces/flag-target-membership.interface.ts new file mode 100644 index 000000000..a38ead10f --- /dev/null +++ b/src/feature-flags/interfaces/flag-target-membership.interface.ts @@ -0,0 +1,26 @@ +/** A REST target membership. The polling payload uses the separate FlagTarget type. */ +export interface FlagTargetMembership { + object: 'flag_target'; + id: string; + ruleId: string; + flagId: string; + flagSlug: string; + environmentId: string; + targetType: string; + targetId: string; + createdAt: string; + updatedAt: string; +} + +export interface FlagTargetMembershipResponse { + object: 'flag_target'; + id: string; + rule_id: string; + flag_id: string; + flag_slug: string; + environment_id: string; + target_type: string; + target_id: string; + created_at: string; + updated_at: string; +} diff --git a/src/feature-flags/interfaces/index.ts b/src/feature-flags/interfaces/index.ts index c677b1f20..549a2c930 100644 --- a/src/feature-flags/interfaces/index.ts +++ b/src/feature-flags/interfaces/index.ts @@ -1,8 +1,14 @@ export * from './add-flag-target-options.interface'; +export * from './create-flag-rule-options.interface'; +export * from './create-flag-target-options.interface'; +export * from './create-rule-with-targets-options.interface'; export * from './evaluation-context.interface'; export * from './feature-flag.interface'; export * from './flag-change.interface'; export * from './flag-poll-response.interface'; +export * from './flag-rule.interface'; +export * from './flag-target-membership.interface'; +export * from './list-flag-rules-options.interface'; export * from './list-feature-flags-options.interface'; export * from './remove-flag-target-options.interface'; export * from './runtime-client-options.interface'; diff --git a/src/feature-flags/interfaces/list-flag-rules-options.interface.ts b/src/feature-flags/interfaces/list-flag-rules-options.interface.ts new file mode 100644 index 000000000..12c34ab11 --- /dev/null +++ b/src/feature-flags/interfaces/list-flag-rules-options.interface.ts @@ -0,0 +1,9 @@ +import { PaginationOptions } from '../../common/interfaces'; + +export interface ListFlagRulesOptions extends Pick< + PaginationOptions, + 'limit' | 'before' | 'after' +> { + /** The feature flag's slug. Rules are always returned in position order. */ + featureFlag: string; +} diff --git a/src/feature-flags/rule-management.spec.ts b/src/feature-flags/rule-management.spec.ts new file mode 100644 index 000000000..6542dec4f --- /dev/null +++ b/src/feature-flags/rule-management.spec.ts @@ -0,0 +1,298 @@ +import fetch from 'jest-fetch-mock'; +import { ConflictException } from '../common/exceptions'; +import { + fetchBody, + fetchMethod, + fetchOnce, + fetchSearchParams, + fetchURL, +} from '../common/utils/test-utils'; +import { WorkOS } from '../workos'; +import { CreateRuleWithTargetsError } from './create-rule-with-targets-error'; +import { FlagRuleResponse, FlagTargetMembershipResponse } from './interfaces'; + +const workos = new WorkOS('sk_test_rule_management', { maxRetries: 0 }); + +const rule: FlagRuleResponse = { + object: 'flag_rule', + id: 'flag_rule_01H00000000000000000000001', + flag_id: 'flag_01H00000000000000000000001', + flag_slug: 'new-checkout', + environment_id: 'environment_01H00000000000000000000001', + target_type: 'organization', + position: 2, + value_type: 'boolean', + value: false, + created_at: '2026-10-01T00:00:00.000Z', + updated_at: '2026-10-01T00:00:00.000Z', +}; + +const target: FlagTargetMembershipResponse = { + object: 'flag_target', + id: 'flag_target_01H00000000000000000000001', + rule_id: rule.id, + flag_id: rule.flag_id, + flag_slug: rule.flag_slug, + environment_id: rule.environment_id, + target_type: 'organization', + target_id: 'org_01H00000000000000000000001', + created_at: '2026-10-01T00:00:00.000Z', + updated_at: '2026-10-01T00:00:00.000Z', +}; + +describe('Feature flag rule management', () => { + beforeEach(() => fetch.resetMocks()); + + it.each([ + ['organization', false], + ['user', true], + ['workspace', true], + ])('creates a %s rule serving %s', async (targetType, value) => { + fetchOnce({ ...rule, target_type: targetType, value }, { status: 201 }); + + const result = await workos.featureFlags.createFlagRule({ + featureFlag: 'new-checkout', + targetType, + value, + }); + + expect(new URL(String(fetchURL())).pathname).toBe('/flag_rules'); + expect(fetchMethod()).toBe('POST'); + expect(fetchBody()).toEqual({ + feature_flag: 'new-checkout', + target_type: targetType, + value, + }); + expect(result).toEqual({ + object: 'flag_rule', + id: 'flag_rule_01H00000000000000000000001', + flagId: 'flag_01H00000000000000000000001', + flagSlug: 'new-checkout', + environmentId: 'environment_01H00000000000000000000001', + targetType, + position: 2, + valueType: 'boolean', + value, + createdAt: '2026-10-01T00:00:00.000Z', + updatedAt: '2026-10-01T00:00:00.000Z', + }); + }); + + it('paginates rules without overriding evaluation order or losing the flag filter', async () => { + const first = { + object: 'list', + data: [rule], + list_metadata: { before: null, after: rule.id }, + }; + fetchOnce(first); + fetchOnce(first); + fetchOnce({ + object: 'list', + data: [{ ...rule, id: 'flag_rule_next', position: 3 }], + list_metadata: { before: rule.id, after: null }, + }); + + const page = await workos.featureFlags.listFlagRules({ + featureFlag: 'new-checkout', + }); + expect(page.listMetadata).toEqual({ before: null, after: rule.id }); + const all = await page.autoPagination(); + + expect(all.map(({ id, position }) => ({ id, position }))).toEqual([ + { id: rule.id, position: 2 }, + { id: 'flag_rule_next', position: 3 }, + ]); + expect( + fetch.mock.calls.map(([url]) => { + const request = new URL(String(url)); + expect(request.pathname).toBe('/flag_rules'); + return Object.fromEntries(request.searchParams); + }), + ).toEqual([ + { feature_flag: 'new-checkout' }, + { feature_flag: 'new-checkout', limit: '100' }, + { feature_flag: 'new-checkout', limit: '100', after: rule.id }, + ]); + }); + + it('passes an explicit page limit and before cursor', async () => { + fetchOnce({ object: 'list', data: [], list_metadata: {} }); + await workos.featureFlags.listFlagRules({ + featureFlag: 'a flag & more', + limit: 10, + before: rule.id, + }); + expect(fetchSearchParams()).toEqual({ + feature_flag: 'a flag & more', + limit: '10', + before: rule.id, + }); + }); + + it('gets a rule with a null target type and encodes its ID', async () => { + fetchOnce({ ...rule, target_type: null }); + const result = await workos.featureFlags.getFlagRule( + 'rule/with?reserved#chars', + ); + expect(new URL(String(fetchURL())).pathname).toBe( + '/flag_rules/rule%2Fwith%3Freserved%23chars', + ); + expect(fetchMethod()).toBe('GET'); + expect(result.targetType).toBeNull(); + }); + + it('deletes a rule by its encoded ID', async () => { + fetchOnce({}, { status: 204 }); + await workos.featureFlags.deleteFlagRule('rule/with?reserved#chars'); + expect(new URL(String(fetchURL())).pathname).toBe( + '/flag_rules/rule%2Fwith%3Freserved%23chars', + ); + expect(fetchMethod()).toBe('DELETE'); + }); + + it('creates a membership using only the rule and target IDs', async () => { + fetchOnce(target, { status: 201 }); + const result = await workos.featureFlags.createFlagTarget({ + ruleId: rule.id, + targetId: target.target_id, + }); + expect(new URL(String(fetchURL())).pathname).toBe('/flag_targets'); + expect(fetchMethod()).toBe('POST'); + expect(fetchBody()).toEqual({ + rule_id: rule.id, + target_id: target.target_id, + }); + expect(result).toEqual({ + object: 'flag_target', + id: 'flag_target_01H00000000000000000000001', + ruleId: 'flag_rule_01H00000000000000000000001', + flagId: 'flag_01H00000000000000000000001', + flagSlug: 'new-checkout', + environmentId: 'environment_01H00000000000000000000001', + targetType: 'organization', + targetId: 'org_01H00000000000000000000001', + createdAt: '2026-10-01T00:00:00.000Z', + updatedAt: '2026-10-01T00:00:00.000Z', + }); + }); + + it('deletes a membership by its encoded membership ID', async () => { + fetchOnce({}, { status: 204 }); + await workos.featureFlags.deleteFlagTarget('target/with?reserved#chars'); + expect(new URL(String(fetchURL())).pathname).toBe( + '/flag_targets/target%2Fwith%3Freserved%23chars', + ); + expect(fetchMethod()).toBe('DELETE'); + }); + + describe('createRuleWithTargets', () => { + it('uses the created rule ID and preserves the target order', async () => { + fetchOnce(rule, { status: 201 }); + fetchOnce(target, { status: 201 }); + fetchOnce( + { ...target, id: 'flag_target_second', target_id: 'org_second' }, + { status: 201 }, + ); + + const result = await workos.featureFlags.createRuleWithTargets({ + featureFlag: 'new-checkout', + targetType: 'organization', + value: false, + targetIds: [target.target_id, 'org_second'], + }); + + expect( + fetch.mock.calls.map(([url, init]) => ({ + path: new URL(String(url)).pathname, + method: init?.method, + body: JSON.parse(String(init?.body)), + })), + ).toEqual([ + { + path: '/flag_rules', + method: 'POST', + body: { + feature_flag: 'new-checkout', + target_type: 'organization', + value: false, + }, + }, + { + path: '/flag_targets', + method: 'POST', + body: { rule_id: rule.id, target_id: target.target_id }, + }, + { + path: '/flag_targets', + method: 'POST', + body: { rule_id: rule.id, target_id: 'org_second' }, + }, + ]); + expect(result.rule.id).toBe(rule.id); + expect(result.targets.map(({ targetId }) => targetId)).toEqual([ + target.target_id, + 'org_second', + ]); + }); + + it('supports an empty rule', async () => { + fetchOnce(rule, { status: 201 }); + const result = await workos.featureFlags.createRuleWithTargets({ + featureFlag: 'new-checkout', + targetType: 'organization', + value: false, + targetIds: [], + }); + expect(result.rule.id).toBe(rule.id); + expect(result.targets).toEqual([]); + expect(fetch).toHaveBeenCalledTimes(1); + }); + + it('preserves a rule creation conflict without attempting any targets', async () => { + fetchOnce( + { + message: 'Rule already exists', + code: 'flag_rule_already_exists_exception', + }, + { status: 409 }, + ); + await expect( + workos.featureFlags.createRuleWithTargets({ + featureFlag: 'new-checkout', + targetType: 'organization', + value: false, + targetIds: [target.target_id], + }), + ).rejects.toMatchObject({ + name: 'ConflictException', + status: 409, + code: 'flag_rule_already_exists_exception', + }); + expect(fetch).toHaveBeenCalledTimes(1); + }); + + it('reports partial progress and stops at the first failed target without deleting the rule', async () => { + fetchOnce(rule, { status: 201 }); + fetchOnce(target, { status: 201 }); + fetchOnce({ message: 'Target belongs to another rule' }, { status: 409 }); + const error = await workos.featureFlags + .createRuleWithTargets({ + featureFlag: 'new-checkout', + targetType: 'organization', + value: false, + targetIds: [target.target_id, 'org_conflict', 'org_never_attempted'], + }) + .catch((cause: unknown) => cause); + + expect(error).toBeInstanceOf(CreateRuleWithTargetsError); + if (!(error instanceof CreateRuleWithTargetsError)) { + throw new Error('Expected a partial creation error'); + } + expect(error.rule.id).toBe(rule.id); + expect(error.targets.map(({ id }) => id)).toEqual([target.id]); + expect(error.failedTargetId).toBe('org_conflict'); + expect(error.cause).toBeInstanceOf(ConflictException); + expect(fetch).toHaveBeenCalledTimes(3); + }); + }); +}); diff --git a/src/feature-flags/serializers/flag-rule.serializer.ts b/src/feature-flags/serializers/flag-rule.serializer.ts new file mode 100644 index 000000000..2ee31ac5c --- /dev/null +++ b/src/feature-flags/serializers/flag-rule.serializer.ts @@ -0,0 +1,15 @@ +import { FlagRule, FlagRuleResponse } from '../interfaces'; + +export const deserializeFlagRule = (rule: FlagRuleResponse): FlagRule => ({ + object: rule.object, + id: rule.id, + flagId: rule.flag_id, + flagSlug: rule.flag_slug, + environmentId: rule.environment_id, + targetType: rule.target_type, + position: rule.position, + valueType: rule.value_type, + value: rule.value, + createdAt: rule.created_at, + updatedAt: rule.updated_at, +}); diff --git a/src/feature-flags/serializers/flag-target-membership.serializer.ts b/src/feature-flags/serializers/flag-target-membership.serializer.ts new file mode 100644 index 000000000..0e6be8d3c --- /dev/null +++ b/src/feature-flags/serializers/flag-target-membership.serializer.ts @@ -0,0 +1,19 @@ +import { + FlagTargetMembership, + FlagTargetMembershipResponse, +} from '../interfaces'; + +export const deserializeFlagTargetMembership = ( + target: FlagTargetMembershipResponse, +): FlagTargetMembership => ({ + object: target.object, + id: target.id, + ruleId: target.rule_id, + flagId: target.flag_id, + flagSlug: target.flag_slug, + environmentId: target.environment_id, + targetType: target.target_type, + targetId: target.target_id, + createdAt: target.created_at, + updatedAt: target.updated_at, +}); diff --git a/src/feature-flags/serializers/index.ts b/src/feature-flags/serializers/index.ts index 6689e4fa3..dad209ee8 100644 --- a/src/feature-flags/serializers/index.ts +++ b/src/feature-flags/serializers/index.ts @@ -1 +1,3 @@ export * from './feature-flag.serializer'; +export * from './flag-rule.serializer'; +export * from './flag-target-membership.serializer'; diff --git a/src/index.ts b/src/index.ts index f00652919..a78252898 100644 --- a/src/index.ts +++ b/src/index.ts @@ -23,6 +23,7 @@ export * from './directory-sync/interfaces'; export * from './events/interfaces'; export * from './feature-flags/interfaces'; export { FeatureFlagsRuntimeClient } from './feature-flags/runtime-client'; +export { CreateRuleWithTargetsError } from './feature-flags/create-rule-with-targets-error'; export * from './groups/interfaces'; export * from './multi-factor-auth/interfaces'; export * from './organizations/interfaces'; diff --git a/src/index.worker.ts b/src/index.worker.ts index f1b0b4bbb..f1e821d03 100644 --- a/src/index.worker.ts +++ b/src/index.worker.ts @@ -16,6 +16,8 @@ export * from './common/interfaces'; export * from './common/utils/pagination'; export * from './directory-sync/interfaces'; export * from './events/interfaces'; +export * from './feature-flags/interfaces'; +export { CreateRuleWithTargetsError } from './feature-flags/create-rule-with-targets-error'; export * from './organizations/interfaces'; export * from './organization-domains/interfaces'; export * from './passwordless/interfaces'; From ac22454771ad01a32599748b0095e63e618d5aeb Mon Sep 17 00:00:00 2001 From: Stanley Phu Date: Wed, 7 Oct 2026 12:04:04 -0700 Subject: [PATCH 11/13] feat(feature-flags): Align rule management with target contracts --- README.md | 50 +++++- src/feature-flags/feature-flags.ts | 40 ++++- .../create-flag-rule-options.interface.ts | 2 +- .../flag-target-membership.interface.ts | 18 +++ src/feature-flags/interfaces/index.ts | 1 + .../list-flag-rules-options.interface.ts | 2 +- .../list-flag-targets-options.interface.ts | 8 + src/feature-flags/rule-management.spec.ts | 145 ++++++++++++++++-- .../flag-target-membership.serializer.ts | 25 ++- 9 files changed, 262 insertions(+), 29 deletions(-) create mode 100644 src/feature-flags/interfaces/list-flag-targets-options.interface.ts diff --git a/README.md b/README.md index c720701b6..b00e2a7eb 100644 --- a/README.md +++ b/README.md @@ -170,7 +170,7 @@ Create an empty rule, then add memberships using the rule's ID: ```ts const rule = await workos.featureFlags.createFlagRule({ - featureFlag: 'new-checkout', + flagSlug: 'new-checkout', targetType: 'organization', value: true, }); @@ -183,10 +183,30 @@ const membership = await workos.featureFlags.createFlagTarget({ await workos.featureFlags.deleteFlagTarget(membership.id); ``` +The `flagSlug` option is sent as `flag_slug` to the API for rule creation and +listing. Membership creation sends only `rule_id` and `target_id`; the rule +supplies the flag, target type, and served value. + +Use `getFlagTarget(membership.id)` to read a target or combine filters when listing: + +```ts +const members = await workos.featureFlags.listFlagTargets({ + ruleId: rule.id, + flagSlug: 'new-checkout', + targetType: 'organization', + order: 'asc', +}); +const allMembers = await members.autoPagination(); +``` + +Target lists also accept `targetId`, `limit`, `before`, and `after`. The SDK +defaults target lists to descending creation order, so `after` advances through +the results. Rule lists always follow evaluation order. + Rules are appended in evaluation order. `targetType` can be `organization`, `user`, or a registered custom target type. A duplicate rule with the same target type and value returns a conflict; it does not reuse the existing rule. -Use `listFlagRules({ featureFlag: 'new-checkout' })` to find existing rules, +Use `listFlagRules({ flagSlug: 'new-checkout' })` to find existing rules, `getFlagRule(rule.id)` to inspect one, or `deleteFlagRule(rule.id)` to delete a rule and its memberships. Rule lists support `limit`, `before`, `after`, and `autoPagination()` and always follow the server's evaluation order. @@ -198,7 +218,7 @@ import { CreateRuleWithTargetsError } from '@workos-inc/node'; try { const { rule, targets } = await workos.featureFlags.createRuleWithTargets({ - featureFlag: 'new-checkout', + flagSlug: 'new-checkout', targetType: 'organization', value: true, targetIds: ['org_first', 'org_second'], @@ -206,7 +226,12 @@ try { } catch (error) { if (error instanceof CreateRuleWithTargetsError) { // Save these IDs to reconcile or resume membership creation. - console.error(error.rule.id, error.targets, error.failedTargetId, error.cause); + console.error( + error.rule.id, + error.targets, + error.failedTargetId, + error.cause, + ); } throw error; } @@ -228,9 +253,20 @@ rules; existing integrations do not need to change. The new REST membership type is `FlagTargetMembership`; the existing `FlagTarget` polling type is unchanged. -Rule endpoints require the targeting-rules rollout. The membership methods and -convenience helper also require an API deployment that accepts `rule_id` on -`POST /flag_targets`. +Rule endpoints require `feature-flags-targeting-rules`. Membership creation and +`createRuleWithTargets` additionally require `feature-flags-legacy-target-contract` +to be off. The compatibility flag takes precedence: while it is on, the target +API retains its legacy contract. With both flags off, `/flag_targets` returns 404. + +`getFlagTarget()` and `listFlagTargets()` return `FlagTargetResource`: a +`FlagTargetMembership` with `ruleId` in membership mode, or a `LegacyFlagTarget` +with `value` and `valueType` in compatibility mode. Narrow using +`'ruleId' in target` before accessing the contract-specific fields. + +Release these methods only after the API deployment that supports these +contracts. Before enabling rule authoring for a team, verify v2-capable runtime +SDKs and `rule-based-flag-evaluation` in each affected environment. Keep rule +authoring off for teams using the legacy compatibility contract during migration. ## SDK Versioning diff --git a/src/feature-flags/feature-flags.ts b/src/feature-flags/feature-flags.ts index d830e0938..d9dac90bd 100644 --- a/src/feature-flags/feature-flags.ts +++ b/src/feature-flags/feature-flags.ts @@ -12,7 +12,10 @@ import { FlagRuleResponse, FlagTargetMembership, FlagTargetMembershipResponse, + FlagTargetResource, + FlagTargetResourceResponse, ListFlagRulesOptions, + ListFlagTargetsOptions, ListFeatureFlagsOptions, RemoveFlagTargetOptions, RuntimeClientOptions, @@ -21,6 +24,7 @@ import { deserializeFeatureFlag, deserializeFlagRule, deserializeFlagTargetMembership, + deserializeFlagTargetResource, } from './serializers'; import { CreateRuleWithTargetsError } from './create-rule-with-targets-error'; import { ListResponse, PaginationOptions } from '../common/interfaces'; @@ -37,7 +41,7 @@ export class FeatureFlags { /** Append an empty rule to a flag. Duplicate target type/value pairs return 409. */ async createFlagRule(options: CreateFlagRuleOptions): Promise { const { data } = await this.workos.post('/flag_rules', { - feature_flag: options.featureFlag, + flag_slug: options.flagSlug, target_type: options.targetType, value: options.value, }); @@ -52,7 +56,7 @@ export class FeatureFlags { const { data } = await this.workos.get>( '/flag_rules', { - query: { feature_flag: options.featureFlag, limit, before, after }, + query: { flag_slug: options.flagSlug, limit, before, after }, }, ); return deserializeList(data, deserializeFlagRule); @@ -84,6 +88,38 @@ export class FeatureFlags { return deserializeFlagTargetMembership(data); } + /** Get a membership, or a legacy target while the compatibility contract is enabled. */ + async getFlagTarget(id: string): Promise { + const { data } = await this.workos.get( + `/flag_targets/${encodePathParameter(id)}`, + ); + return deserializeFlagTargetResource(data); + } + + /** List targets with combined filters; the team's contract selects their shape. */ + async listFlagTargets( + options: ListFlagTargetsOptions = {}, + ): Promise> { + const fetchPage = async ({ limit, before, after }: PaginationOptions) => { + const { data } = await this.workos.get< + ListResponse + >('/flag_targets', { + query: { + rule_id: options.ruleId, + flag_slug: options.flagSlug, + target_type: options.targetType, + target_id: options.targetId, + order: options.order ?? 'desc', + limit, + before, + after, + }, + }); + return deserializeList(data, deserializeFlagTargetResource); + }; + return new AutoPaginatable(await fetchPage(options), fetchPage, options); + } + /** Delete a membership by its flag_target ID, not the targeted entity's ID. */ async deleteFlagTarget(id: string): Promise { await this.workos.delete(`/flag_targets/${encodePathParameter(id)}`); diff --git a/src/feature-flags/interfaces/create-flag-rule-options.interface.ts b/src/feature-flags/interfaces/create-flag-rule-options.interface.ts index 6e4bebe2d..a8f409f89 100644 --- a/src/feature-flags/interfaces/create-flag-rule-options.interface.ts +++ b/src/feature-flags/interfaces/create-flag-rule-options.interface.ts @@ -1,6 +1,6 @@ export interface CreateFlagRuleOptions { /** The feature flag's slug. */ - featureFlag: string; + flagSlug: string; /** An organization, user, or registered custom target type. */ targetType: string; value: boolean; diff --git a/src/feature-flags/interfaces/flag-target-membership.interface.ts b/src/feature-flags/interfaces/flag-target-membership.interface.ts index a38ead10f..a04866727 100644 --- a/src/feature-flags/interfaces/flag-target-membership.interface.ts +++ b/src/feature-flags/interfaces/flag-target-membership.interface.ts @@ -24,3 +24,21 @@ export interface FlagTargetMembershipResponse { created_at: string; updated_at: string; } + +/** Returned while the team's temporary legacy target contract is enabled. */ +export interface LegacyFlagTarget extends Omit { + valueType: 'boolean'; + value: boolean; +} + +export interface LegacyFlagTargetResponse extends Omit< + FlagTargetMembershipResponse, + 'rule_id' +> { + value_type: 'boolean'; + value: boolean; +} + +export type FlagTargetResource = FlagTargetMembership | LegacyFlagTarget; +export type FlagTargetResourceResponse = + FlagTargetMembershipResponse | LegacyFlagTargetResponse; diff --git a/src/feature-flags/interfaces/index.ts b/src/feature-flags/interfaces/index.ts index 549a2c930..4296f0c29 100644 --- a/src/feature-flags/interfaces/index.ts +++ b/src/feature-flags/interfaces/index.ts @@ -9,6 +9,7 @@ export * from './flag-poll-response.interface'; export * from './flag-rule.interface'; export * from './flag-target-membership.interface'; export * from './list-flag-rules-options.interface'; +export * from './list-flag-targets-options.interface'; export * from './list-feature-flags-options.interface'; export * from './remove-flag-target-options.interface'; export * from './runtime-client-options.interface'; diff --git a/src/feature-flags/interfaces/list-flag-rules-options.interface.ts b/src/feature-flags/interfaces/list-flag-rules-options.interface.ts index 12c34ab11..666e32c4c 100644 --- a/src/feature-flags/interfaces/list-flag-rules-options.interface.ts +++ b/src/feature-flags/interfaces/list-flag-rules-options.interface.ts @@ -5,5 +5,5 @@ export interface ListFlagRulesOptions extends Pick< 'limit' | 'before' | 'after' > { /** The feature flag's slug. Rules are always returned in position order. */ - featureFlag: string; + flagSlug: string; } diff --git a/src/feature-flags/interfaces/list-flag-targets-options.interface.ts b/src/feature-flags/interfaces/list-flag-targets-options.interface.ts new file mode 100644 index 000000000..6bc71eae5 --- /dev/null +++ b/src/feature-flags/interfaces/list-flag-targets-options.interface.ts @@ -0,0 +1,8 @@ +import { PaginationOptions } from '../../common/interfaces'; + +export interface ListFlagTargetsOptions extends PaginationOptions { + ruleId?: string; + flagSlug?: string; + targetType?: string; + targetId?: string; +} diff --git a/src/feature-flags/rule-management.spec.ts b/src/feature-flags/rule-management.spec.ts index 6542dec4f..ee5e18e0a 100644 --- a/src/feature-flags/rule-management.spec.ts +++ b/src/feature-flags/rule-management.spec.ts @@ -9,7 +9,11 @@ import { } from '../common/utils/test-utils'; import { WorkOS } from '../workos'; import { CreateRuleWithTargetsError } from './create-rule-with-targets-error'; -import { FlagRuleResponse, FlagTargetMembershipResponse } from './interfaces'; +import { + FlagRuleResponse, + FlagTargetMembershipResponse, + LegacyFlagTargetResponse, +} from './interfaces'; const workos = new WorkOS('sk_test_rule_management', { maxRetries: 0 }); @@ -40,6 +44,20 @@ const target: FlagTargetMembershipResponse = { updated_at: '2026-10-01T00:00:00.000Z', }; +const legacyTarget: LegacyFlagTargetResponse = { + object: target.object, + id: 'flag_target_legacy', + flag_id: target.flag_id, + flag_slug: target.flag_slug, + environment_id: target.environment_id, + target_type: target.target_type, + target_id: 'org_legacy', + value_type: 'boolean', + value: false, + created_at: target.created_at, + updated_at: target.updated_at, +}; + describe('Feature flag rule management', () => { beforeEach(() => fetch.resetMocks()); @@ -51,7 +69,7 @@ describe('Feature flag rule management', () => { fetchOnce({ ...rule, target_type: targetType, value }, { status: 201 }); const result = await workos.featureFlags.createFlagRule({ - featureFlag: 'new-checkout', + flagSlug: 'new-checkout', targetType, value, }); @@ -59,7 +77,7 @@ describe('Feature flag rule management', () => { expect(new URL(String(fetchURL())).pathname).toBe('/flag_rules'); expect(fetchMethod()).toBe('POST'); expect(fetchBody()).toEqual({ - feature_flag: 'new-checkout', + flag_slug: 'new-checkout', target_type: targetType, value, }); @@ -93,7 +111,7 @@ describe('Feature flag rule management', () => { }); const page = await workos.featureFlags.listFlagRules({ - featureFlag: 'new-checkout', + flagSlug: 'new-checkout', }); expect(page.listMetadata).toEqual({ before: null, after: rule.id }); const all = await page.autoPagination(); @@ -109,21 +127,21 @@ describe('Feature flag rule management', () => { return Object.fromEntries(request.searchParams); }), ).toEqual([ - { feature_flag: 'new-checkout' }, - { feature_flag: 'new-checkout', limit: '100' }, - { feature_flag: 'new-checkout', limit: '100', after: rule.id }, + { flag_slug: 'new-checkout' }, + { flag_slug: 'new-checkout', limit: '100' }, + { flag_slug: 'new-checkout', limit: '100', after: rule.id }, ]); }); it('passes an explicit page limit and before cursor', async () => { fetchOnce({ object: 'list', data: [], list_metadata: {} }); await workos.featureFlags.listFlagRules({ - featureFlag: 'a flag & more', + flagSlug: 'a flag & more', limit: 10, before: rule.id, }); expect(fetchSearchParams()).toEqual({ - feature_flag: 'a flag & more', + flag_slug: 'a flag & more', limit: '10', before: rule.id, }); @@ -176,6 +194,105 @@ describe('Feature flag rule management', () => { }); }); + it.each([target, legacyTarget])( + 'reads either target contract without inventing fields', + async (response) => { + fetchOnce(response); + const result = await workos.featureFlags.getFlagTarget( + 'target/with?reserved#chars', + ); + expect(new URL(String(fetchURL())).pathname).toBe( + '/flag_targets/target%2Fwith%3Freserved%23chars', + ); + expect(fetchMethod()).toBe('GET'); + expect(result).toMatchObject({ + id: response.id, + flagSlug: response.flag_slug, + targetId: response.target_id, + }); + if ('rule_id' in response) { + expect(result).toHaveProperty('ruleId', response.rule_id); + expect(result).not.toHaveProperty('value'); + expect(result).not.toHaveProperty('valueType'); + } else { + expect(result).toHaveProperty('value', false); + expect(result).toHaveProperty('valueType', 'boolean'); + expect(result).not.toHaveProperty('ruleId'); + } + }, + ); + + it('keeps all target filters across automatic pagination and deserializes both contracts', async () => { + const first = { + object: 'list', + data: [target], + list_metadata: { before: null, after: target.id }, + }; + fetchOnce(first); + fetchOnce(first); + fetchOnce({ + object: 'list', + data: [legacyTarget], + list_metadata: { before: target.id, after: null }, + }); + const page = await workos.featureFlags.listFlagTargets({ + ruleId: rule.id, + flagSlug: rule.flag_slug, + targetType: 'organization', + }); + expect(page.listMetadata).toEqual({ before: null, after: target.id }); + const results = await page.autoPagination(); + expect(results.map(({ id }) => id)).toEqual([target.id, legacyTarget.id]); + expect(results[0]).toHaveProperty('ruleId', rule.id); + expect(results[1]).toHaveProperty('value', false); + const filters = { + rule_id: rule.id, + flag_slug: rule.flag_slug, + target_type: 'organization', + order: 'desc', + }; + expect( + fetch.mock.calls.map(([url]) => { + const request = new URL(String(url)); + expect(request.pathname).toBe('/flag_targets'); + return Object.fromEntries(request.searchParams); + }), + ).toEqual([ + filters, + { ...filters, limit: '100' }, + { ...filters, limit: '100', after: target.id }, + ]); + }); + + it('passes target page cursors and explicit ordering', async () => { + fetchOnce({ object: 'list', data: [], list_metadata: {} }); + await workos.featureFlags.listFlagTargets({ + flagSlug: 'a flag & more', + ruleId: rule.id, + targetType: 'organization', + targetId: target.target_id, + limit: 10, + before: target.id, + order: 'asc', + }); + expect(fetchSearchParams()).toEqual({ + flag_slug: 'a flag & more', + rule_id: rule.id, + target_type: 'organization', + target_id: target.target_id, + limit: '10', + before: target.id, + order: 'asc', + }); + }); + + it('lists targets without requiring a filter', async () => { + fetchOnce({ object: 'list', data: [], list_metadata: {} }); + const page = await workos.featureFlags.listFlagTargets(); + expect(page.data).toEqual([]); + expect(fetchSearchParams()).toEqual({ order: 'desc' }); + }); + it('deletes a membership by its encoded membership ID', async () => { fetchOnce({}, { status: 204 }); await workos.featureFlags.deleteFlagTarget('target/with?reserved#chars'); @@ -195,7 +312,7 @@ describe('Feature flag rule management', () => { ); const result = await workos.featureFlags.createRuleWithTargets({ - featureFlag: 'new-checkout', + flagSlug: 'new-checkout', targetType: 'organization', value: false, targetIds: [target.target_id, 'org_second'], @@ -212,7 +329,7 @@ describe('Feature flag rule management', () => { path: '/flag_rules', method: 'POST', body: { - feature_flag: 'new-checkout', + flag_slug: 'new-checkout', target_type: 'organization', value: false, }, @@ -238,7 +355,7 @@ describe('Feature flag rule management', () => { it('supports an empty rule', async () => { fetchOnce(rule, { status: 201 }); const result = await workos.featureFlags.createRuleWithTargets({ - featureFlag: 'new-checkout', + flagSlug: 'new-checkout', targetType: 'organization', value: false, targetIds: [], @@ -258,7 +375,7 @@ describe('Feature flag rule management', () => { ); await expect( workos.featureFlags.createRuleWithTargets({ - featureFlag: 'new-checkout', + flagSlug: 'new-checkout', targetType: 'organization', value: false, targetIds: [target.target_id], @@ -277,7 +394,7 @@ describe('Feature flag rule management', () => { fetchOnce({ message: 'Target belongs to another rule' }, { status: 409 }); const error = await workos.featureFlags .createRuleWithTargets({ - featureFlag: 'new-checkout', + flagSlug: 'new-checkout', targetType: 'organization', value: false, targetIds: [target.target_id, 'org_conflict', 'org_never_attempted'], diff --git a/src/feature-flags/serializers/flag-target-membership.serializer.ts b/src/feature-flags/serializers/flag-target-membership.serializer.ts index 0e6be8d3c..46a58835a 100644 --- a/src/feature-flags/serializers/flag-target-membership.serializer.ts +++ b/src/feature-flags/serializers/flag-target-membership.serializer.ts @@ -1,14 +1,13 @@ import { FlagTargetMembership, FlagTargetMembershipResponse, + FlagTargetResource, + FlagTargetResourceResponse, } from '../interfaces'; -export const deserializeFlagTargetMembership = ( - target: FlagTargetMembershipResponse, -): FlagTargetMembership => ({ +const deserializeTargetFields = (target: FlagTargetResourceResponse) => ({ object: target.object, id: target.id, - ruleId: target.rule_id, flagId: target.flag_id, flagSlug: target.flag_slug, environmentId: target.environment_id, @@ -17,3 +16,21 @@ export const deserializeFlagTargetMembership = ( createdAt: target.created_at, updatedAt: target.updated_at, }); + +export const deserializeFlagTargetMembership = ( + target: FlagTargetMembershipResponse, +): FlagTargetMembership => ({ + ...deserializeTargetFields(target), + ruleId: target.rule_id, +}); + +export const deserializeFlagTargetResource = ( + target: FlagTargetResourceResponse, +): FlagTargetResource => + 'rule_id' in target + ? deserializeFlagTargetMembership(target) + : { + ...deserializeTargetFields(target), + valueType: target.value_type, + value: target.value, + }; From 6b72f8eb97bf840b3473658bd9f3c0029c2900a6 Mon Sep 17 00:00:00 2001 From: Stanley Phu Date: Thu, 15 Oct 2026 23:03:00 -0700 Subject: [PATCH 12/13] refactor: Rename FlagTargetMembership to FlagTarget Co-Authored-By: Claude Opus 5.5 --- README.md | 6 ++---- .../create-rule-with-targets-error.ts | 4 ++-- src/feature-flags/feature-flags.ts | 14 +++++++------- .../create-rule-with-targets-options.interface.ts | 4 ++-- ...rship.interface.ts => flag-target.interface.ts} | 14 +++++++------- src/feature-flags/interfaces/index.ts | 2 +- src/feature-flags/rule-management.spec.ts | 4 ++-- ...hip.serializer.ts => flag-target.serializer.ts} | 12 ++++++------ src/feature-flags/serializers/index.ts | 2 +- 9 files changed, 30 insertions(+), 32 deletions(-) rename src/feature-flags/interfaces/{flag-target-membership.interface.ts => flag-target.interface.ts} (62%) rename src/feature-flags/serializers/{flag-target-membership.serializer.ts => flag-target.serializer.ts} (77%) diff --git a/README.md b/README.md index b00e2a7eb..e5cc544dd 100644 --- a/README.md +++ b/README.md @@ -249,9 +249,7 @@ rule. The legacy `addFlagTarget({ slug, targetId })` and `removeFlagTarget({ slug, targetId })` helpers remain supported on their alias routes. Their deprecation annotations guide new integrations toward explicit -rules; existing integrations do not need to change. The new REST membership -type is `FlagTargetMembership`; the existing `FlagTarget` polling type is -unchanged. +rules; existing integrations do not need to change. Rule endpoints require `feature-flags-targeting-rules`. Membership creation and `createRuleWithTargets` additionally require `feature-flags-legacy-target-contract` @@ -259,7 +257,7 @@ to be off. The compatibility flag takes precedence: while it is on, the target API retains its legacy contract. With both flags off, `/flag_targets` returns 404. `getFlagTarget()` and `listFlagTargets()` return `FlagTargetResource`: a -`FlagTargetMembership` with `ruleId` in membership mode, or a `LegacyFlagTarget` +`FlagTarget` with `ruleId` in membership mode, or a `LegacyFlagTarget` with `value` and `valueType` in compatibility mode. Narrow using `'ruleId' in target` before accessing the contract-specific fields. diff --git a/src/feature-flags/create-rule-with-targets-error.ts b/src/feature-flags/create-rule-with-targets-error.ts index 50fa6d3ac..276e86926 100644 --- a/src/feature-flags/create-rule-with-targets-error.ts +++ b/src/feature-flags/create-rule-with-targets-error.ts @@ -1,4 +1,4 @@ -import { FlagRule, FlagTargetMembership } from './interfaces'; +import { FlagRule, FlagTarget } from './interfaces'; /** A rule was created, but adding one of its targets failed. */ export class CreateRuleWithTargetsError extends Error { @@ -7,7 +7,7 @@ export class CreateRuleWithTargetsError extends Error { constructor( readonly rule: FlagRule, /** Memberships confirmed by the API before the failure. */ - readonly targets: FlagTargetMembership[], + readonly targets: FlagTarget[], readonly failedTargetId: string, cause: unknown, ) { diff --git a/src/feature-flags/feature-flags.ts b/src/feature-flags/feature-flags.ts index d9dac90bd..b3acc06ae 100644 --- a/src/feature-flags/feature-flags.ts +++ b/src/feature-flags/feature-flags.ts @@ -10,8 +10,8 @@ import { FeatureFlagResponse, FlagRule, FlagRuleResponse, - FlagTargetMembership, - FlagTargetMembershipResponse, + FlagTarget, + FlagTargetResponse, FlagTargetResource, FlagTargetResourceResponse, ListFlagRulesOptions, @@ -23,7 +23,7 @@ import { import { deserializeFeatureFlag, deserializeFlagRule, - deserializeFlagTargetMembership, + deserializeFlagTarget, deserializeFlagTargetResource, } from './serializers'; import { CreateRuleWithTargetsError } from './create-rule-with-targets-error'; @@ -80,12 +80,12 @@ export class FeatureFlags { /** Add a membership to a rule. The rule supplies the target type and value. */ async createFlagTarget( options: CreateFlagTargetOptions, - ): Promise { - const { data } = await this.workos.post( + ): Promise { + const { data } = await this.workos.post( '/flag_targets', { rule_id: options.ruleId, target_id: options.targetId }, ); - return deserializeFlagTargetMembership(data); + return deserializeFlagTarget(data); } /** Get a membership, or a legacy target while the compatibility contract is enabled. */ @@ -136,7 +136,7 @@ export class FeatureFlags { options: CreateRuleWithTargetsOptions, ): Promise { const rule = await this.createFlagRule(options); - const targets: FlagTargetMembership[] = []; + const targets: FlagTarget[] = []; for (const targetId of options.targetIds) { try { targets.push( diff --git a/src/feature-flags/interfaces/create-rule-with-targets-options.interface.ts b/src/feature-flags/interfaces/create-rule-with-targets-options.interface.ts index 3b125e0a1..d652ffbbc 100644 --- a/src/feature-flags/interfaces/create-rule-with-targets-options.interface.ts +++ b/src/feature-flags/interfaces/create-rule-with-targets-options.interface.ts @@ -1,6 +1,6 @@ import { CreateFlagRuleOptions } from './create-flag-rule-options.interface'; import { FlagRule } from './flag-rule.interface'; -import { FlagTargetMembership } from './flag-target-membership.interface'; +import { FlagTarget } from './flag-target.interface'; export interface CreateRuleWithTargetsOptions extends CreateFlagRuleOptions { targetIds: string[]; @@ -8,5 +8,5 @@ export interface CreateRuleWithTargetsOptions extends CreateFlagRuleOptions { export interface CreateRuleWithTargetsResult { rule: FlagRule; - targets: FlagTargetMembership[]; + targets: FlagTarget[]; } diff --git a/src/feature-flags/interfaces/flag-target-membership.interface.ts b/src/feature-flags/interfaces/flag-target.interface.ts similarity index 62% rename from src/feature-flags/interfaces/flag-target-membership.interface.ts rename to src/feature-flags/interfaces/flag-target.interface.ts index a04866727..16f45329c 100644 --- a/src/feature-flags/interfaces/flag-target-membership.interface.ts +++ b/src/feature-flags/interfaces/flag-target.interface.ts @@ -1,5 +1,5 @@ -/** A REST target membership. The polling payload uses the separate FlagTarget type. */ -export interface FlagTargetMembership { +/** A target's membership in a flag rule. */ +export interface FlagTarget { object: 'flag_target'; id: string; ruleId: string; @@ -12,7 +12,7 @@ export interface FlagTargetMembership { updatedAt: string; } -export interface FlagTargetMembershipResponse { +export interface FlagTargetResponse { object: 'flag_target'; id: string; rule_id: string; @@ -26,19 +26,19 @@ export interface FlagTargetMembershipResponse { } /** Returned while the team's temporary legacy target contract is enabled. */ -export interface LegacyFlagTarget extends Omit { +export interface LegacyFlagTarget extends Omit { valueType: 'boolean'; value: boolean; } export interface LegacyFlagTargetResponse extends Omit< - FlagTargetMembershipResponse, + FlagTargetResponse, 'rule_id' > { value_type: 'boolean'; value: boolean; } -export type FlagTargetResource = FlagTargetMembership | LegacyFlagTarget; +export type FlagTargetResource = FlagTarget | LegacyFlagTarget; export type FlagTargetResourceResponse = - FlagTargetMembershipResponse | LegacyFlagTargetResponse; + FlagTargetResponse | LegacyFlagTargetResponse; diff --git a/src/feature-flags/interfaces/index.ts b/src/feature-flags/interfaces/index.ts index 4296f0c29..bc3e26385 100644 --- a/src/feature-flags/interfaces/index.ts +++ b/src/feature-flags/interfaces/index.ts @@ -7,7 +7,7 @@ export * from './feature-flag.interface'; export * from './flag-change.interface'; export * from './flag-poll-response.interface'; export * from './flag-rule.interface'; -export * from './flag-target-membership.interface'; +export * from './flag-target.interface'; export * from './list-flag-rules-options.interface'; export * from './list-flag-targets-options.interface'; export * from './list-feature-flags-options.interface'; diff --git a/src/feature-flags/rule-management.spec.ts b/src/feature-flags/rule-management.spec.ts index ee5e18e0a..8946488fc 100644 --- a/src/feature-flags/rule-management.spec.ts +++ b/src/feature-flags/rule-management.spec.ts @@ -11,7 +11,7 @@ import { WorkOS } from '../workos'; import { CreateRuleWithTargetsError } from './create-rule-with-targets-error'; import { FlagRuleResponse, - FlagTargetMembershipResponse, + FlagTargetResponse, LegacyFlagTargetResponse, } from './interfaces'; @@ -31,7 +31,7 @@ const rule: FlagRuleResponse = { updated_at: '2026-10-01T00:00:00.000Z', }; -const target: FlagTargetMembershipResponse = { +const target: FlagTargetResponse = { object: 'flag_target', id: 'flag_target_01H00000000000000000000001', rule_id: rule.id, diff --git a/src/feature-flags/serializers/flag-target-membership.serializer.ts b/src/feature-flags/serializers/flag-target.serializer.ts similarity index 77% rename from src/feature-flags/serializers/flag-target-membership.serializer.ts rename to src/feature-flags/serializers/flag-target.serializer.ts index 46a58835a..5eae4cd0b 100644 --- a/src/feature-flags/serializers/flag-target-membership.serializer.ts +++ b/src/feature-flags/serializers/flag-target.serializer.ts @@ -1,6 +1,6 @@ import { - FlagTargetMembership, - FlagTargetMembershipResponse, + FlagTarget, + FlagTargetResponse, FlagTargetResource, FlagTargetResourceResponse, } from '../interfaces'; @@ -17,9 +17,9 @@ const deserializeTargetFields = (target: FlagTargetResourceResponse) => ({ updatedAt: target.updated_at, }); -export const deserializeFlagTargetMembership = ( - target: FlagTargetMembershipResponse, -): FlagTargetMembership => ({ +export const deserializeFlagTarget = ( + target: FlagTargetResponse, +): FlagTarget => ({ ...deserializeTargetFields(target), ruleId: target.rule_id, }); @@ -28,7 +28,7 @@ export const deserializeFlagTargetResource = ( target: FlagTargetResourceResponse, ): FlagTargetResource => 'rule_id' in target - ? deserializeFlagTargetMembership(target) + ? deserializeFlagTarget(target) : { ...deserializeTargetFields(target), valueType: target.value_type, diff --git a/src/feature-flags/serializers/index.ts b/src/feature-flags/serializers/index.ts index dad209ee8..24323be96 100644 --- a/src/feature-flags/serializers/index.ts +++ b/src/feature-flags/serializers/index.ts @@ -1,3 +1,3 @@ export * from './feature-flag.serializer'; export * from './flag-rule.serializer'; -export * from './flag-target-membership.serializer'; +export * from './flag-target.serializer'; From 8e088a8aeeccf503be4ea4c0d5c1271faa0477d2 Mon Sep 17 00:00:00 2001 From: Stanley Phu Date: Mon, 19 Oct 2026 00:15:48 -0700 Subject: [PATCH 13/13] docs: Remove rollout notes from feature flag rule management README Co-Authored-By: Claude Opus 5.5 --- README.md | 16 +++------------- 1 file changed, 3 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index e5cc544dd..46bfeb60c 100644 --- a/README.md +++ b/README.md @@ -251,20 +251,10 @@ The legacy `addFlagTarget({ slug, targetId })` and routes. Their deprecation annotations guide new integrations toward explicit rules; existing integrations do not need to change. -Rule endpoints require `feature-flags-targeting-rules`. Membership creation and -`createRuleWithTargets` additionally require `feature-flags-legacy-target-contract` -to be off. The compatibility flag takes precedence: while it is on, the target -API retains its legacy contract. With both flags off, `/flag_targets` returns 404. - `getFlagTarget()` and `listFlagTargets()` return `FlagTargetResource`: a -`FlagTarget` with `ruleId` in membership mode, or a `LegacyFlagTarget` -with `value` and `valueType` in compatibility mode. Narrow using -`'ruleId' in target` before accessing the contract-specific fields. - -Release these methods only after the API deployment that supports these -contracts. Before enabling rule authoring for a team, verify v2-capable runtime -SDKs and `rule-based-flag-evaluation` in each affected environment. Keep rule -authoring off for teams using the legacy compatibility contract during migration. +`FlagTarget` with `ruleId`, or a `LegacyFlagTarget` with `value` and +`valueType` while the environment still uses the legacy target contract. Narrow +using `'ruleId' in target` before accessing the contract-specific fields. ## SDK Versioning