diff --git a/README.md b/README.md index 0224c0714..bf97734c9 100644 --- a/README.md +++ b/README.md @@ -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. 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..d9dac90bd 100644 --- a/src/feature-flags/feature-flags.ts +++ b/src/feature-flags/feature-flags.ts @@ -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'; @@ -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 { + const { data } = await this.workos.post('/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> { + const fetchPage = async ({ limit, before, after }: PaginationOptions) => { + const { data } = await this.workos.get>( + '/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 { + 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); + } + + /** 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)}`); + } + + // @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 +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} @@ -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} 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..a8f409f89 --- /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. */ + flagSlug: 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..a04866727 --- /dev/null +++ b/src/feature-flags/interfaces/flag-target-membership.interface.ts @@ -0,0 +1,44 @@ +/** 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; +} + +/** 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 c677b1f20..4296f0c29 100644 --- a/src/feature-flags/interfaces/index.ts +++ b/src/feature-flags/interfaces/index.ts @@ -1,8 +1,15 @@ 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-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 new file mode 100644 index 000000000..666e32c4c --- /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. */ + 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 new file mode 100644 index 000000000..ee5e18e0a --- /dev/null +++ b/src/feature-flags/rule-management.spec.ts @@ -0,0 +1,415 @@ +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, + LegacyFlagTargetResponse, +} 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', +}; + +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()); + + 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({ + flagSlug: 'new-checkout', + targetType, + value, + }); + + expect(new URL(String(fetchURL())).pathname).toBe('/flag_rules'); + expect(fetchMethod()).toBe('POST'); + expect(fetchBody()).toEqual({ + flag_slug: '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({ + flagSlug: '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([ + { 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({ + flagSlug: 'a flag & more', + limit: 10, + before: rule.id, + }); + expect(fetchSearchParams()).toEqual({ + flag_slug: '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.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'); + 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({ + flagSlug: '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: { + flag_slug: '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({ + flagSlug: '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({ + flagSlug: '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({ + flagSlug: '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..46a58835a --- /dev/null +++ b/src/feature-flags/serializers/flag-target-membership.serializer.ts @@ -0,0 +1,36 @@ +import { + FlagTargetMembership, + FlagTargetMembershipResponse, + FlagTargetResource, + FlagTargetResourceResponse, +} from '../interfaces'; + +const deserializeTargetFields = (target: FlagTargetResourceResponse) => ({ + object: target.object, + id: target.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, +}); + +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, + }; 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';