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
158 changes: 158 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,164 @@ 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.

## Feature flag events

The Events API and `webhooks.constructEvent` support the namespaced feature flag
lifecycle events:

- `feature_flags.flag.created`, `feature_flags.flag.updated`, `feature_flags.flag.deleted`
- `feature_flags.flag_rule.created`, `feature_flags.flag_rule.updated`, `feature_flags.flag_rule.deleted`
- `feature_flags.flag_target.created`, `feature_flags.flag_target.deleted`

Use these names in `events.listEvents({ events: [...] })`. The returned `Event`
union narrows by `event`; event data and context use camelCase fields.

```ts
const { data: events } = await workos.events.listEvents({
events: [
'feature_flags.flag_target.created',
'feature_flags.flag_rule.updated',
],
});

for (const event of events) {
if (event.event === 'feature_flags.flag_target.created') {
if ('ruleId' in event.data) {
console.log(event.data.ruleId, event.data.targetId);
} else {
console.log(event.data.valueType, event.data.value);
}
}
}
```

Flag events include `environmentId`, nullable `description`, and a nullable
`owner` with `email`, `firstName`, and `lastName`. Updated flag context includes
optional `previousAttributes.data`, and updated rule context includes the rule's
previous `value` there. Rule context can omit `actor` for system changes. Target
event data is a `FlagTarget` with `ruleId`, or a `LegacyFlagTarget` with `value`
and `valueType`. The `flag.*` events keep their existing data shape.

## 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
186 changes: 186 additions & 0 deletions src/common/interfaces/event.interface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,10 @@ import { ApiKey, SerializedApiKey } from '../../api-keys/interfaces';
import {
FeatureFlag,
FeatureFlagResponse,
FlagRule,
FlagRuleResponse,
FlagTargetResource,
FlagTargetResourceResponse,
} from '../../feature-flags/interfaces';
import { Group, GroupResponse } from '../../groups/interfaces';
import { ConnectedAccountState } from '../../pipes/interfaces';
Expand Down Expand Up @@ -718,6 +722,172 @@ export interface ApiKeyRevokedEventResponse extends EventResponseBase {
data: SerializedApiKey;
}

export type FeatureFlagEventData = Omit<FeatureFlag, 'description'> & {
environmentId: string;
description: string | null;
owner: {
email: string;
firstName: string | null;
lastName: string | null;
} | null;
};

export type FeatureFlagEventResponseData = Omit<
FeatureFlagResponse,
'description'
> & {
environment_id: string;
description: string | null;
owner: {
email: string;
first_name: string | null;
last_name: string | null;
} | null;
};

export type FeatureFlagActor = {
id: string;
source: 'api' | 'dashboard' | 'admin_portal' | 'system';
name: string | null;
};

export type FeatureFlagContext = { clientId: string; actor: FeatureFlagActor };
export type FeatureFlagContextResponse = {
client_id: string;
actor: FeatureFlagActor;
};
export type FeatureFlagRuleContext = {
clientId: string;
actor?: FeatureFlagActor;
};
export type FeatureFlagRuleContextResponse = {
client_id: string;
actor?: FeatureFlagActor;
};
export type FeatureFlagRuleUpdatedContext = FeatureFlagRuleContext & {
previousAttributes?: { data?: Partial<Pick<FlagRule, 'value'>> };
};
export type FeatureFlagRuleUpdatedContextResponse =
FeatureFlagRuleContextResponse & {
previous_attributes?: { data?: Partial<Pick<FlagRuleResponse, 'value'>> };
};
export type FeatureFlagUpdatedContext = FeatureFlagContext & {
previousAttributes?: {
data?: Partial<
Pick<
FeatureFlagEventData,
'name' | 'description' | 'tags' | 'owner' | 'enabled' | 'defaultValue'
>
>;
};
};
export type FeatureFlagUpdatedContextResponse = FeatureFlagContextResponse & {
previous_attributes?: {
data?: Partial<
Pick<
FeatureFlagEventResponseData,
'name' | 'description' | 'tags' | 'owner' | 'enabled' | 'default_value'
>
>;
};
};

export interface FeatureFlagsFlagCreatedEvent extends EventBase {
event: 'feature_flags.flag.created';
data: FeatureFlagEventData;
context: FeatureFlagContext;
}

export interface FeatureFlagsFlagCreatedEventResponse extends EventResponseBase {
event: 'feature_flags.flag.created';
data: FeatureFlagEventResponseData;
context: FeatureFlagContextResponse;
}

export interface FeatureFlagsFlagUpdatedEvent extends EventBase {
event: 'feature_flags.flag.updated';
data: FeatureFlagEventData;
context: FeatureFlagUpdatedContext;
}

export interface FeatureFlagsFlagUpdatedEventResponse extends EventResponseBase {
event: 'feature_flags.flag.updated';
data: FeatureFlagEventResponseData;
context: FeatureFlagUpdatedContextResponse;
}

export interface FeatureFlagsFlagDeletedEvent extends EventBase {
event: 'feature_flags.flag.deleted';
data: FeatureFlagEventData;
context: FeatureFlagContext;
}

export interface FeatureFlagsFlagDeletedEventResponse extends EventResponseBase {
event: 'feature_flags.flag.deleted';
data: FeatureFlagEventResponseData;
context: FeatureFlagContextResponse;
}

export interface FeatureFlagsFlagRuleCreatedEvent extends EventBase {
event: 'feature_flags.flag_rule.created';
data: FlagRule;
context: FeatureFlagRuleContext;
}

export interface FeatureFlagsFlagRuleCreatedEventResponse extends EventResponseBase {
event: 'feature_flags.flag_rule.created';
data: FlagRuleResponse;
context: FeatureFlagRuleContextResponse;
}

export interface FeatureFlagsFlagRuleUpdatedEvent extends EventBase {
event: 'feature_flags.flag_rule.updated';
data: FlagRule;
context: FeatureFlagRuleUpdatedContext;
}

export interface FeatureFlagsFlagRuleUpdatedEventResponse extends EventResponseBase {
event: 'feature_flags.flag_rule.updated';
data: FlagRuleResponse;
context: FeatureFlagRuleUpdatedContextResponse;
}

export interface FeatureFlagsFlagRuleDeletedEvent extends EventBase {
event: 'feature_flags.flag_rule.deleted';
data: FlagRule;
context: FeatureFlagRuleContext;
}

export interface FeatureFlagsFlagRuleDeletedEventResponse extends EventResponseBase {
event: 'feature_flags.flag_rule.deleted';
data: FlagRuleResponse;
context: FeatureFlagRuleContextResponse;
}

export interface FeatureFlagsFlagTargetCreatedEvent extends EventBase {
event: 'feature_flags.flag_target.created';
data: FlagTargetResource;
context: FeatureFlagContext;
}

export interface FeatureFlagsFlagTargetCreatedEventResponse extends EventResponseBase {
event: 'feature_flags.flag_target.created';
data: FlagTargetResourceResponse;
context: FeatureFlagContextResponse;
}

export interface FeatureFlagsFlagTargetDeletedEvent extends EventBase {
event: 'feature_flags.flag_target.deleted';
data: FlagTargetResource;
context: FeatureFlagContext;
}

export interface FeatureFlagsFlagTargetDeletedEventResponse extends EventResponseBase {
event: 'feature_flags.flag_target.deleted';
data: FlagTargetResourceResponse;
context: FeatureFlagContextResponse;
}

export interface FlagCreatedEvent extends EventBase {
event: 'flag.created';
data: FeatureFlag;
Expand Down Expand Up @@ -1079,6 +1249,14 @@ export type Event =
| OrganizationDomainDeletedEvent
| ApiKeyCreatedEvent
| ApiKeyRevokedEvent
| FeatureFlagsFlagCreatedEvent
| FeatureFlagsFlagUpdatedEvent
| FeatureFlagsFlagDeletedEvent
| FeatureFlagsFlagRuleCreatedEvent
| FeatureFlagsFlagRuleUpdatedEvent
| FeatureFlagsFlagRuleDeletedEvent
| FeatureFlagsFlagTargetCreatedEvent
| FeatureFlagsFlagTargetDeletedEvent
| FlagCreatedEvent
| FlagUpdatedEvent
| FlagDeletedEvent
Expand Down Expand Up @@ -1165,6 +1343,14 @@ export type EventResponse =
| OrganizationDomainDeletedEventResponse
| ApiKeyCreatedEventResponse
| ApiKeyRevokedEventResponse
| FeatureFlagsFlagCreatedEventResponse
| FeatureFlagsFlagUpdatedEventResponse
| FeatureFlagsFlagDeletedEventResponse
| FeatureFlagsFlagRuleCreatedEventResponse
| FeatureFlagsFlagRuleUpdatedEventResponse
| FeatureFlagsFlagRuleDeletedEventResponse
| FeatureFlagsFlagTargetCreatedEventResponse
| FeatureFlagsFlagTargetDeletedEventResponse
| FlagCreatedEventResponse
| FlagUpdatedEventResponse
| FlagDeletedEventResponse
Expand Down
Loading
Loading