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
104 changes: 104 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,110 @@ const { accessToken } = await workos.userManagement.authenticateWithCode({
});
```

## 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. The new REST membership
type is `FlagTargetMembership`; the existing `FlagTarget` polling type is
unchanged.

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

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, 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 });
}
}
140 changes: 139 additions & 1 deletion src/feature-flags/feature-flags.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,33 @@ import { AutoPaginatable } from '../common/utils/pagination';
import { WorkOS } from '../workos';
import {
AddFlagTargetOptions,
CreateFlagRuleOptions,
CreateFlagTargetOptions,
CreateRuleWithTargetsOptions,
CreateRuleWithTargetsResult,
FeatureFlag,
FeatureFlagResponse,
FlagRule,
FlagRuleResponse,
FlagTargetMembership,
FlagTargetMembershipResponse,
FlagTargetResource,
FlagTargetResourceResponse,
ListFlagRulesOptions,
ListFlagTargetsOptions,
ListFeatureFlagsOptions,
RemoveFlagTargetOptions,
RuntimeClientOptions,
} from './interfaces';
import { deserializeFeatureFlag } from './serializers';
import {
deserializeFeatureFlag,
deserializeFlagRule,
deserializeFlagTargetMembership,
deserializeFlagTargetResource,
} 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';
Expand All @@ -18,6 +38,118 @@ 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<FlagRule> {
const { data } = await this.workos.post<FlagRuleResponse>('/flag_rules', {
flag_slug: options.flagSlug,
target_type: options.targetType,
value: options.value,
});
return deserializeFlagRule(data);
}

/** List a flag's rules in evaluation order. */
async listFlagRules(
options: ListFlagRulesOptions,
): Promise<AutoPaginatable<FlagRule, ListFlagRulesOptions>> {
const fetchPage = async ({ limit, before, after }: PaginationOptions) => {
const { data } = await this.workos.get<ListResponse<FlagRuleResponse>>(
'/flag_rules',
{
query: { flag_slug: options.flagSlug, 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<FlagRule> {
const { data } = await this.workos.get<FlagRuleResponse>(
`/flag_rules/${encodePathParameter(id)}`,
);
return deserializeFlagRule(data);
}

/** Delete a rule and all its target memberships. */
async deleteFlagRule(id: string): Promise<void> {
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<FlagTargetMembership> {
const { data } = await this.workos.post<FlagTargetMembershipResponse>(
'/flag_targets',
{ rule_id: options.ruleId, target_id: options.targetId },
);
Comment on lines +84 to +87

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Membership creation fails

The current API rejects this POST /flag_targets payload: it does not yet accept rule_id and requires flag_slug and target_type. Direct calls to createFlagTarget therefore return a 400. createRuleWithTargets creates the rule first, then fails on its first membership and leaves an empty rule to reconcile. This needs the AUTH-7084 API deployment and a successful end-to-end check before release.

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/feature-flags/feature-flags.ts
Line: 80-83

Comment:
**Membership creation fails**

The current API rejects this `POST /flag_targets` payload: it does not yet accept `rule_id` and requires `flag_slug` and `target_type`. Direct calls to `createFlagTarget` therefore return a 400. `createRuleWithTargets` creates the rule first, then fails on its first membership and leaves an empty rule to reconcile. This needs the AUTH-7084 API deployment and a successful end-to-end check before release.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

return deserializeFlagTargetMembership(data);
}

/** Get a membership, or a legacy target while the compatibility contract is enabled. */
async getFlagTarget(id: string): Promise<FlagTargetResource> {
const { data } = await this.workos.get<FlagTargetResourceResponse>(
`/flag_targets/${encodePathParameter(id)}`,
);
return deserializeFlagTargetResource(data);
}

/** List targets with combined filters; the team's contract selects their shape. */
async listFlagTargets(
options: ListFlagTargetsOptions = {},
): Promise<AutoPaginatable<FlagTargetResource, ListFlagTargetsOptions>> {
const fetchPage = async ({ limit, before, after }: PaginationOptions) => {
const { data } = await this.workos.get<
ListResponse<FlagTargetResourceResponse>
>('/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<void> {
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<CreateRuleWithTargetsResult> {
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
*
Expand Down Expand Up @@ -114,6 +246,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<void>}
Expand All @@ -132,6 +267,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<void>}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
export interface CreateFlagRuleOptions {
/** The feature flag's slug. */
flagSlug: string;
/** An organization, user, or registered custom target type. */
targetType: string;
value: boolean;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
export interface CreateFlagTargetOptions {
/** The rule determines the flag, target type, and value. */
ruleId: string;
targetId: string;
}
Original file line number Diff line number Diff line change
@@ -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[];
}
29 changes: 29 additions & 0 deletions src/feature-flags/interfaces/flag-rule.interface.ts
Original file line number Diff line number Diff line change
@@ -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;
}
Loading
Loading