Skip to content
Merged
28 changes: 28 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,34 @@ const { accessToken } = await workos.userManagement.authenticateWithCode({
});
```

## Feature flag runtime

The runtime client polls the v2 flag payload and evaluates flags locally. When a
flag is off it serves `off_value`. Otherwise rules are evaluated in array order:
the first matching rule's `value` wins, and `default_value` applies when no rule
matches. A matching rule can serve `false`. Every condition in a rule must match;
`one_of` matches an exact ID for its target type. Unknown rule kinds and condition
operators never match.

`isEnabled(key, context, defaultValue)` returns `defaultValue` (`false` when
omitted) if the flag is missing or the served value is not a boolean.
`getAllFlags` uses `false` in that case.

`getFlag(key)` returns the flag's `FlagPollEntry`: `slug`, `enabled`,
`default_value`, `off_value` and `rules`. The `previous` and `current` values in
`change` events have the same shape. Reordering rules emits a `change` event;
reordering IDs within a condition does not.

To start from saved configuration, pass a `FlagPollResponse`
(`{ version: 2, flags: { [slug]: FlagPollEntry } }`) as `bootstrapFlags`.
Unrecognized bootstrap data, including a bare map of entries without the
envelope, is ignored with a warning to `logger` and readiness still resolves;
polling populates the cache afterward.

A failed poll, including one that returns a payload the SDK can't read, emits
`error`, keeps the last good snapshot, and retries with backoff. A 401 stops
polling and also emits `failed`.

## SDK Versioning

For our SDKs WorkOS follows a Semantic Versioning ([SemVer](https://semver.org/)) process where all releases will have a version X.Y.Z (like 1.0.0) pattern wherein Z would be a bug fix (e.g., 1.0.1), Y would be a minor release (1.1.0) and X would be a major release (2.0.0). We permit any breaking changes to only be released in major versions and strongly recommend reading changelogs before making any major version upgrades.
Expand Down
143 changes: 73 additions & 70 deletions src/feature-flags/evaluator.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,17 @@ import {
RuntimeClientLogger,
} from './interfaces';

const membershipRule = (
targetType: string,
values: string[],
value: boolean = true,
) => ({
id: `rule_${targetType}`,
kind: 'conditions',
value,
conditions: [{ target_type: targetType, operator: 'one_of', values }],
});

describe('Evaluator', () => {
let store: InMemoryStore;
let evaluator: Evaluator;
Expand All @@ -15,44 +26,29 @@ describe('Evaluator', () => {
slug: 'enabled-flag',
enabled: true,
default_value: true,
targets: { users: [], organizations: [] },
off_value: false,
rules: [],
};

const disabledFlag: FlagPollEntry = {
slug: 'disabled-flag',
enabled: false,
default_value: true,
targets: { users: [], organizations: [] },
off_value: false,
rules: [],
};

const targetedFlag: FlagPollEntry = {
slug: 'targeted-flag',
enabled: true,
default_value: false,
targets: {
organizations: [{ id: 'org_123', enabled: true }],
users: [
{ id: 'user_456', enabled: true },
{ id: 'user_blocked', enabled: false },
],
custom_targets: [
{ type: 'workspace', id: 'ws_123', enabled: true },
{ type: 'workspace', id: 'ws_off', enabled: false },
{ type: 'region', id: 'us-east-1', enabled: true },
],
},
};

// Simulates a default-on flag with a disabled override, which the API
// cannot produce yet: the row must not turn the flag off.
const defaultOnFlag: FlagPollEntry = {
slug: 'default-on-flag',
enabled: true,
default_value: true,
targets: {
users: [{ id: 'user_blocked', enabled: false }],
organizations: [],
},
off_value: false,
rules: [
membershipRule('organization', ['org_123']),
membershipRule('user', ['user_456']),
membershipRule('workspace', ['ws_123']),
membershipRule('region', ['us-east-1']),
],
};

beforeEach(() => {
Expand All @@ -68,7 +64,6 @@ describe('Evaluator', () => {
'enabled-flag': enabledFlag,
'disabled-flag': disabledFlag,
'targeted-flag': targetedFlag,
'default-on-flag': defaultOnFlag,
});
});

Expand All @@ -82,60 +77,88 @@ describe('Evaluator', () => {
expect(evaluator.isEnabled('disabled-flag')).toBe(false);
});

it('returns true for a matching enabled organization target', () => {
it('returns true for a matching organization rule', () => {
expect(
evaluator.isEnabled('targeted-flag', { organizationId: 'org_123' }),
).toBe(true);
});

it('returns true for a matching enabled user target', () => {
it('returns true for a matching user rule', () => {
expect(evaluator.isEnabled('targeted-flag', { userId: 'user_456' })).toBe(
true,
);
});

it('treats targets with enabled=false as not present', () => {
// No enabled match, so the flag falls back to its default value —
// false here, but crucially the target does not force the flag off.
expect(
evaluator.isEnabled('targeted-flag', { userId: 'user_blocked' }),
).toBe(false);
it('falls back to the organization rule when the user rule does not match', () => {
expect(
evaluator.isEnabled('default-on-flag', { userId: 'user_blocked' }),
evaluator.isEnabled('targeted-flag', {
userId: 'user_other',
organizationId: 'org_123',
}),
).toBe(true);
});
});

describe('served values', () => {
const context = { userId: 'user_456', organizationId: 'org_123' };
const evaluate = (flag: Omit<FlagPollEntry, 'slug'>): boolean => {
store.swap({ flag: { slug: 'flag', ...flag } });
return evaluator.isEnabled('flag', context);
};

it('matches any enabled target with no precedence between types', () => {
it('serves off_value when the flag is off, even if a rule matches', () => {
expect(
evaluator.isEnabled('targeted-flag', {
userId: 'user_blocked',
organizationId: 'org_123',
evaluate({
enabled: false,
default_value: false,
off_value: true,
rules: [membershipRule('user', ['user_456'], false)],
}),
).toBe(true);
});

it('falls back to organization target when user target does not match', () => {
it('serves default_value when no rule matches', () => {
expect(
evaluator.isEnabled('targeted-flag', {
userId: 'user_other',
organizationId: 'org_123',
evaluate({
enabled: true,
default_value: true,
off_value: false,
rules: [membershipRule('user', ['user_other'], false)],
}),
).toBe(true);
});

it('returns default_value when no target matches', () => {
it('serves the first matching rule when it is true', () => {
expect(
evaluator.isEnabled('targeted-flag', { userId: 'user_other' }),
).toBe(false);
evaluate({
enabled: true,
default_value: false,
off_value: false,
rules: [
membershipRule('user', ['user_456'], true),
membershipRule('organization', ['org_123'], false),
],
}),
).toBe(true);
});

it('serves the first matching rule when it is false', () => {
expect(
evaluator.isEnabled('enabled-flag', { userId: 'user_other' }),
).toBe(true);
evaluate({
enabled: true,
default_value: true,
off_value: false,
rules: [
membershipRule('user', ['user_456'], false),
membershipRule('organization', ['org_123'], true),
],
}),
).toBe(false);
});
});

describe('typed evaluation contexts', () => {
it('matches custom targets by exact type and id', () => {
it('matches custom target rules by exact type and id', () => {
expect(
evaluator.isEnabled('targeted-flag', { workspace: { id: 'ws_123' } }),
).toBe(true);
Expand All @@ -161,23 +184,6 @@ describe('Evaluator', () => {
).toBe(true);
});

it('treats custom targets with enabled=false as not present', () => {
expect(
evaluator.isEnabled('targeted-flag', { workspace: { id: 'ws_off' } }),
).toBe(false);
});

it('evaluates safely when the payload has no custom_targets field', () => {
expect(
evaluator.isEnabled('enabled-flag', { workspace: { id: 'ws_123' } }),
).toBe(true);
expect(
evaluator.isEnabled('default-on-flag', {
workspace: { id: 'ws_123' },
}),
).toBe(true);
});

it('rejects a context mixing legacy and typed keys', () => {
const hybridContext: EvaluationContext = {
userId: 'user_456',
Expand Down Expand Up @@ -245,7 +251,6 @@ describe('Evaluator', () => {
'enabled-flag': true,
'disabled-flag': false,
'targeted-flag': true,
'default-on-flag': true,
});
});

Expand All @@ -256,7 +261,6 @@ describe('Evaluator', () => {
'enabled-flag': true,
'disabled-flag': false,
'targeted-flag': true,
'default-on-flag': true,
});
});

Expand All @@ -273,7 +277,6 @@ describe('Evaluator', () => {
'enabled-flag': true,
'disabled-flag': false,
'targeted-flag': false,
'default-on-flag': true,
Comment thread
stanleyphu marked this conversation as resolved.
});
});
});
Expand Down
73 changes: 34 additions & 39 deletions src/feature-flags/evaluator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,13 +44,12 @@ export class Evaluator {
// per flag.
const normalizedContext = this.normalizeContext(context);
const flags = this.store.getAll();
const result: Record<string, boolean> = {};

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(
Expand All @@ -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);
Comment thread
Deborah-Digges marked this conversation as resolved.
}

private servedValue(value: unknown, defaultValue: boolean): boolean {
return typeof value === 'boolean' ? value : defaultValue;
}

/**
Expand All @@ -85,6 +97,14 @@ export class Evaluator {
*/
private normalizeContext(context: EvaluationContext): Map<string, string> {
const normalized = new Map<string, string>();
if (
typeof context !== 'object' ||
context === null ||
Array.isArray(context)
) {
this.logger?.warn('Ignoring invalid evaluation context');
return normalized;
}
const record: Record<string, unknown> = context;

const legacyEntries: Array<[string, string]> = [];
Expand Down Expand Up @@ -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,
);
}
}
Loading
Loading