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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
120 changes: 120 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,126 @@ 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`.

## Feature flag rule management

Create an empty rule, then add memberships using the rule's ID:

```ts
const rule = await workos.featureFlags.createFlagRule({
flagSlug: 'new-checkout',
targetType: 'organization',
value: true,
});

const membership = await workos.featureFlags.createFlagTarget({
ruleId: rule.id,
targetId: 'org_...',
});

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({ 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.

To create a rule and its initial members together:

```ts
import { CreateRuleWithTargetsError } from '@workos-inc/node';

try {
const { rule, targets } = await workos.featureFlags.createRuleWithTargets({
flagSlug: '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.

`getFlagTarget()` and `listFlagTargets()` return `FlagTargetResource`: a
`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

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
16 changes: 16 additions & 0 deletions src/feature-flags/create-rule-with-targets-error.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { FlagRule, FlagTarget } 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: FlagTarget[],
readonly failedTargetId: string,
cause: unknown,
) {
super('The rule was created, but adding a target failed.', { cause });
}
}
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,
});
});
});
Expand Down
Loading
Loading