diff --git a/README.md b/README.md index 0224c0714..ffb275054 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/src/common/interfaces/event.interface.ts b/src/common/interfaces/event.interface.ts index 53ae0f37c..292f892b8 100644 --- a/src/common/interfaces/event.interface.ts +++ b/src/common/interfaces/event.interface.ts @@ -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'; @@ -718,6 +722,172 @@ export interface ApiKeyRevokedEventResponse extends EventResponseBase { data: SerializedApiKey; } +export type FeatureFlagEventData = Omit & { + 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> }; +}; +export type FeatureFlagRuleUpdatedContextResponse = + FeatureFlagRuleContextResponse & { + previous_attributes?: { data?: Partial> }; + }; +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; @@ -1079,6 +1249,14 @@ export type Event = | OrganizationDomainDeletedEvent | ApiKeyCreatedEvent | ApiKeyRevokedEvent + | FeatureFlagsFlagCreatedEvent + | FeatureFlagsFlagUpdatedEvent + | FeatureFlagsFlagDeletedEvent + | FeatureFlagsFlagRuleCreatedEvent + | FeatureFlagsFlagRuleUpdatedEvent + | FeatureFlagsFlagRuleDeletedEvent + | FeatureFlagsFlagTargetCreatedEvent + | FeatureFlagsFlagTargetDeletedEvent | FlagCreatedEvent | FlagUpdatedEvent | FlagDeletedEvent @@ -1165,6 +1343,14 @@ export type EventResponse = | OrganizationDomainDeletedEventResponse | ApiKeyCreatedEventResponse | ApiKeyRevokedEventResponse + | FeatureFlagsFlagCreatedEventResponse + | FeatureFlagsFlagUpdatedEventResponse + | FeatureFlagsFlagDeletedEventResponse + | FeatureFlagsFlagRuleCreatedEventResponse + | FeatureFlagsFlagRuleUpdatedEventResponse + | FeatureFlagsFlagRuleDeletedEventResponse + | FeatureFlagsFlagTargetCreatedEventResponse + | FeatureFlagsFlagTargetDeletedEventResponse | FlagCreatedEventResponse | FlagUpdatedEventResponse | FlagDeletedEventResponse diff --git a/src/common/serializers/event.serializer.ts b/src/common/serializers/event.serializer.ts index 7df974cc3..00991d31b 100644 --- a/src/common/serializers/event.serializer.ts +++ b/src/common/serializers/event.serializer.ts @@ -33,6 +33,16 @@ import { deserializeAuthenticationRadarRiskDetectedEvent } from '../../user-mana import { deserializeApiKey } from '../../api-keys/serializers/api-key.serializer'; import { deserializeOrganizationRoleEvent } from '../../authorization/serializers/organization-role.serializer'; import { deserializePermission } from '../../authorization/serializers/permission.serializer'; +import { + deserializeFlagRule, + deserializeFlagTargetResource, +} from '../../feature-flags/serializers'; +import { + deserializeFeatureFlagEventData, + deserializeFeatureFlagRuleContext, + deserializeFeatureFlagRuleUpdatedContext, + deserializeFeatureFlagUpdatedContext, +} from './feature-flag-event.serializer'; import { deserializeFeatureFlag } from '../../feature-flags/serializers/feature-flag.serializer'; import { deserializeGroup } from '../../groups/serializers'; import { @@ -272,6 +282,50 @@ export const deserializeEvent = (event: EventResponse): Event => { event: event.event, data: deserializeApiKey(event.data), }; + case 'feature_flags.flag.created': + case 'feature_flags.flag.deleted': + return { + ...eventBase, + event: event.event, + data: deserializeFeatureFlagEventData(event.data), + context: { + clientId: event.context.client_id, + actor: event.context.actor, + }, + }; + case 'feature_flags.flag.updated': + return { + ...eventBase, + event: event.event, + data: deserializeFeatureFlagEventData(event.data), + context: deserializeFeatureFlagUpdatedContext(event.context), + }; + case 'feature_flags.flag_rule.created': + case 'feature_flags.flag_rule.deleted': + return { + ...eventBase, + event: event.event, + data: deserializeFlagRule(event.data), + context: deserializeFeatureFlagRuleContext(event.context), + }; + case 'feature_flags.flag_rule.updated': + return { + ...eventBase, + event: event.event, + data: deserializeFlagRule(event.data), + context: deserializeFeatureFlagRuleUpdatedContext(event.context), + }; + case 'feature_flags.flag_target.created': + case 'feature_flags.flag_target.deleted': + return { + ...eventBase, + event: event.event, + data: deserializeFlagTargetResource(event.data), + context: { + clientId: event.context.client_id, + actor: event.context.actor, + }, + }; case 'flag.created': case 'flag.updated': case 'flag.deleted': diff --git a/src/common/serializers/feature-flag-event.serializer.ts b/src/common/serializers/feature-flag-event.serializer.ts new file mode 100644 index 000000000..778c17b6e --- /dev/null +++ b/src/common/serializers/feature-flag-event.serializer.ts @@ -0,0 +1,103 @@ +import { + FeatureFlagEventData, + FeatureFlagEventResponseData, + FeatureFlagRuleContext, + FeatureFlagRuleContextResponse, + FeatureFlagRuleUpdatedContext, + FeatureFlagRuleUpdatedContextResponse, + FeatureFlagUpdatedContext, + FeatureFlagUpdatedContextResponse, +} from '../interfaces'; + +const deserializeFeatureFlagOwner = ( + owner: FeatureFlagEventResponseData['owner'], +): FeatureFlagEventData['owner'] => + owner === null + ? null + : { + email: owner.email, + firstName: owner.first_name, + lastName: owner.last_name, + }; + +export const deserializeFeatureFlagEventData = ( + flag: FeatureFlagEventResponseData, +): FeatureFlagEventData => ({ + object: flag.object, + id: flag.id, + environmentId: flag.environment_id, + slug: flag.slug, + name: flag.name, + description: flag.description, + owner: deserializeFeatureFlagOwner(flag.owner), + tags: flag.tags, + enabled: flag.enabled, + defaultValue: flag.default_value, + createdAt: flag.created_at, + updatedAt: flag.updated_at, +}); + +export const deserializeFeatureFlagRuleContext = ( + context: FeatureFlagRuleContextResponse, +): FeatureFlagRuleContext => ({ + clientId: context.client_id, + ...(context.actor === undefined ? {} : { actor: context.actor }), +}); + +export const deserializeFeatureFlagRuleUpdatedContext = ( + context: FeatureFlagRuleUpdatedContextResponse, +): FeatureFlagRuleUpdatedContext => { + const previous = context.previous_attributes; + const data = previous?.data; + return { + ...deserializeFeatureFlagRuleContext(context), + ...(previous === undefined + ? {} + : { + previousAttributes: { + ...(data === undefined + ? {} + : { + data: data.value === undefined ? {} : { value: data.value }, + }), + }, + }), + }; +}; + +export const deserializeFeatureFlagUpdatedContext = ( + context: FeatureFlagUpdatedContextResponse, +): FeatureFlagUpdatedContext => { + const previous = context.previous_attributes; + const data = previous?.data; + return { + clientId: context.client_id, + actor: context.actor, + ...(previous === undefined + ? {} + : { + previousAttributes: { + ...(data === undefined + ? {} + : { + data: { + ...(data.name === undefined ? {} : { name: data.name }), + ...(data.description === undefined + ? {} + : { description: data.description }), + ...(data.tags === undefined ? {} : { tags: data.tags }), + ...(data.owner === undefined + ? {} + : { owner: deserializeFeatureFlagOwner(data.owner) }), + ...(data.enabled === undefined + ? {} + : { enabled: data.enabled }), + ...(data.default_value === undefined + ? {} + : { defaultValue: data.default_value }), + }, + }), + }, + }), + }; +}; diff --git a/src/events/feature-flag-events.spec.ts b/src/events/feature-flag-events.spec.ts new file mode 100644 index 000000000..54ca64e8a --- /dev/null +++ b/src/events/feature-flag-events.spec.ts @@ -0,0 +1,409 @@ +import crypto from 'crypto'; +import fetch from 'jest-fetch-mock'; +import { WorkOS } from '../workos'; +import { + EventResponse, + EventName, + FeatureFlagActor, + FeatureFlagEventResponseData, +} from '../common/interfaces'; +import { FlagRuleResponse } from '../feature-flags/interfaces'; +import { fetchOnce, fetchSearchParams } from '../common/utils/test-utils'; + +const actor: FeatureFlagActor = { + id: 'user_actor', + source: 'dashboard', + name: null, +}; +const timestamps = { + created_at: '2026-10-07T12:00:00.000Z', + updated_at: '2026-10-07T12:01:00.000Z', +}; +const flag: FeatureFlagEventResponseData = { + object: 'feature_flag', + id: 'flag_test', + environment_id: 'environment_test', + slug: 'new-checkout', + name: 'New checkout', + description: null, + owner: { email: 'owner@example.com', first_name: null, last_name: 'Owner' }, + tags: ['beta'], + enabled: true, + default_value: false, + ...timestamps, +}; +const expectedFlag = { + object: 'feature_flag', + id: 'flag_test', + environmentId: 'environment_test', + slug: 'new-checkout', + name: 'New checkout', + description: null, + owner: { email: 'owner@example.com', firstName: null, lastName: 'Owner' }, + tags: ['beta'], + enabled: true, + defaultValue: false, + createdAt: '2026-10-07T12:00:00.000Z', + updatedAt: '2026-10-07T12:01:00.000Z', +}; +const rule: FlagRuleResponse = { + object: 'flag_rule', + id: 'flag_rule_test', + flag_id: 'flag_test', + flag_slug: 'new-checkout', + environment_id: 'environment_test', + target_type: null, + position: 0, + value_type: 'boolean', + value: false, + ...timestamps, +}; +const expectedRule = { + object: 'flag_rule', + id: 'flag_rule_test', + flagId: 'flag_test', + flagSlug: 'new-checkout', + environmentId: 'environment_test', + targetType: null, + position: 0, + valueType: 'boolean', + value: false, + createdAt: '2026-10-07T12:00:00.000Z', + updatedAt: '2026-10-07T12:01:00.000Z', +}; +const target = { + object: 'flag_target', + id: 'flag_target_test', + flag_id: 'flag_test', + flag_slug: 'new-checkout', + environment_id: 'environment_test', + target_type: 'organization', + target_id: 'org_test', + ...timestamps, +}; +const expectedTarget = { + object: 'flag_target', + id: 'flag_target_test', + flagId: 'flag_test', + flagSlug: 'new-checkout', + environmentId: 'environment_test', + targetType: 'organization', + targetId: 'org_test', + createdAt: '2026-10-07T12:00:00.000Z', + updatedAt: '2026-10-07T12:01:00.000Z', +}; + +// API schema fixtures assert the public event boundary, including fields that +// differ from REST flags and the historical value-bearing target contract. +interface EventCase { + name: string; + response: EventResponse; + expected: object; +} +const eventBase = { id: 'event_test', created_at: timestamps.created_at }; +const responseContext = { client_id: 'client_test', actor }; +const expectedContext = { clientId: 'client_test', actor }; +const ruleEvents: Array< + | 'feature_flags.flag_rule.created' + | 'feature_flags.flag_rule.updated' + | 'feature_flags.flag_rule.deleted' +> = [ + 'feature_flags.flag_rule.created', + 'feature_flags.flag_rule.updated', + 'feature_flags.flag_rule.deleted', +]; +const targetEvents: Array< + 'feature_flags.flag_target.created' | 'feature_flags.flag_target.deleted' +> = ['feature_flags.flag_target.created', 'feature_flags.flag_target.deleted']; +const cases: EventCase[] = [ + { + name: 'feature_flags.flag.created', + response: { + ...eventBase, + event: 'feature_flags.flag.created', + data: flag, + context: responseContext, + }, + expected: { data: expectedFlag, context: expectedContext }, + }, + { + name: 'feature_flags.flag.deleted with no owner', + response: { + ...eventBase, + event: 'feature_flags.flag.deleted', + data: { ...flag, owner: null }, + context: responseContext, + }, + expected: { + data: { ...expectedFlag, owner: null }, + context: expectedContext, + }, + }, + { + name: 'feature_flags.flag.updated with previous attributes', + response: { + ...eventBase, + event: 'feature_flags.flag.updated', + data: flag, + context: { + ...responseContext, + previous_attributes: { + data: { + name: 'Old checkout', + description: null, + tags: [], + enabled: false, + default_value: false, + }, + }, + }, + }, + expected: { + data: expectedFlag, + context: { + ...expectedContext, + previousAttributes: { + data: { + name: 'Old checkout', + description: null, + tags: [], + enabled: false, + defaultValue: false, + }, + }, + }, + }, + }, + { + name: 'feature_flags.flag.updated with a previous owner', + response: { + ...eventBase, + event: 'feature_flags.flag.updated', + data: flag, + context: { + ...responseContext, + previous_attributes: { + data: { + owner: { + email: 'previous@example.com', + first_name: null, + last_name: 'Owner', + }, + }, + }, + }, + }, + expected: { + data: expectedFlag, + context: { + ...expectedContext, + previousAttributes: { + data: { + owner: { + email: 'previous@example.com', + firstName: null, + lastName: 'Owner', + }, + }, + }, + }, + }, + }, + { + name: 'feature_flags.flag.updated with no previous owner', + response: { + ...eventBase, + event: 'feature_flags.flag.updated', + data: flag, + context: { + ...responseContext, + previous_attributes: { data: { owner: null } }, + }, + }, + expected: { + data: expectedFlag, + context: { + ...expectedContext, + previousAttributes: { data: { owner: null } }, + }, + }, + }, + ...ruleEvents.map((event): EventCase => { + const withActor = event !== 'feature_flags.flag_rule.created'; + return { + name: event, + response: { + ...eventBase, + event, + data: rule, + context: { client_id: 'client_test', ...(withActor ? { actor } : {}) }, + }, + expected: { + data: expectedRule, + context: { clientId: 'client_test', ...(withActor ? { actor } : {}) }, + }, + }; + }), + ...targetEvents.flatMap((event): EventCase[] => [ + { + name: `${event} membership`, + response: { + ...eventBase, + event, + data: { ...target, object: 'flag_target', rule_id: 'flag_rule_test' }, + context: responseContext, + }, + expected: { + data: { ...expectedTarget, ruleId: 'flag_rule_test' }, + context: expectedContext, + }, + }, + { + name: `${event} legacy`, + response: { + ...eventBase, + event, + data: { + ...target, + object: 'flag_target', + value_type: 'boolean', + value: false, + }, + context: responseContext, + }, + expected: { + data: { ...expectedTarget, valueType: 'boolean', value: false }, + context: expectedContext, + }, + }, + ]), + ...[undefined, {}, { data: {} }].map((previous): EventCase => ({ + name: `feature_flags.flag.updated sparse previous ${JSON.stringify(previous)}`, + response: { + ...eventBase, + event: 'feature_flags.flag.updated', + data: flag, + context: { + ...responseContext, + ...(previous === undefined ? {} : { previous_attributes: previous }), + }, + }, + expected: { + data: expectedFlag, + context: { + ...expectedContext, + ...(previous === undefined ? {} : { previousAttributes: previous }), + }, + }, + })), + ...[{ data: { value: true } }, {}, { data: {} }].map( + (previous): EventCase => ({ + name: `feature_flags.flag_rule.updated previous ${JSON.stringify(previous)}`, + response: { + ...eventBase, + event: 'feature_flags.flag_rule.updated', + data: rule, + context: { ...responseContext, previous_attributes: previous }, + }, + expected: { + data: expectedRule, + context: { ...expectedContext, previousAttributes: previous }, + }, + }), + ), +]; + +const workos = new WorkOS('sk_test_feature_flag_events'); +const eventNames: EventName[] = cases.map(({ response }) => response.event); + +describe('Namespaced feature flag events', () => { + beforeEach(() => fetch.resetMocks()); + + it.each(cases)( + 'listEvents deserializes $name', + async ({ response, expected }) => { + fetchOnce({ + object: 'list', + data: [response], + list_metadata: { before: null, after: null }, + }); + const result = await workos.events.listEvents({ events: eventNames }); + expect(fetchSearchParams().events).toContain(response.event); + expect(result.data).toEqual([ + { + id: 'event_test', + event: response.event, + createdAt: timestamps.created_at, + ...expected, + }, + ]); + }, + ); + + it.each(cases)( + 'constructEvent verifies and deserializes $name', + async ({ response, expected }) => { + const payload = JSON.stringify(response); + const timestamp = Date.now(); + const secret = 'test_webhook_secret'; + const signature = crypto + .createHmac('sha256', secret) + .update(`${timestamp}.${payload}`) + .digest('hex'); + const event = await workos.webhooks.constructEvent({ + payload, + sigHeader: `t=${timestamp}, v1=${signature}`, + secret, + }); + expect(event).toEqual({ + id: 'event_test', + event: response.event, + createdAt: timestamps.created_at, + ...expected, + }); + }, + ); + + const historicalNames: Array< + 'flag.created' | 'flag.updated' | 'flag.deleted' | 'flag.rule_updated' + > = ['flag.created', 'flag.updated', 'flag.deleted', 'flag.rule_updated']; + it.each(historicalNames)( + 'preserves historical %s data and context', + async (name) => { + const context = { + client_id: 'client_test', + actor, + access_type: 'some', + configured_targets: { organizations: [], users: [] }, + }; + fetchOnce({ + object: 'list', + data: [ + { + id: 'event_test', + event: name, + data: { ...flag, description: 'Historical flag' }, + created_at: timestamps.created_at, + context, + }, + ], + list_metadata: { before: null, after: null }, + }); + const result = await workos.events.listEvents({ events: [name] }); + const { + environmentId: _environmentId, + owner: _owner, + ...historicalFlag + } = expectedFlag; + expect(result.data).toEqual([ + { + id: 'event_test', + event: name, + createdAt: timestamps.created_at, + data: { ...historicalFlag, description: 'Historical flag' }, + context, + }, + ]); + }, + ); +}); 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..276e86926 --- /dev/null +++ b/src/feature-flags/create-rule-with-targets-error.ts @@ -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 }); + } +} diff --git a/src/feature-flags/evaluator.spec.ts b/src/feature-flags/evaluator.spec.ts index eaac42b61..eebfed3d0 100644 --- a/src/feature-flags/evaluator.spec.ts +++ b/src/feature-flags/evaluator.spec.ts @@ -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; @@ -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(() => { @@ -68,7 +64,6 @@ describe('Evaluator', () => { 'enabled-flag': enabledFlag, 'disabled-flag': disabledFlag, 'targeted-flag': targetedFlag, - 'default-on-flag': defaultOnFlag, }); }); @@ -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): 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); @@ -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', @@ -245,7 +251,6 @@ describe('Evaluator', () => { 'enabled-flag': true, 'disabled-flag': false, 'targeted-flag': true, - 'default-on-flag': true, }); }); @@ -256,7 +261,6 @@ describe('Evaluator', () => { 'enabled-flag': true, 'disabled-flag': false, 'targeted-flag': true, - 'default-on-flag': true, }); }); @@ -273,7 +277,6 @@ describe('Evaluator', () => { 'enabled-flag': true, 'disabled-flag': false, 'targeted-flag': false, - 'default-on-flag': true, }); }); }); diff --git a/src/feature-flags/evaluator.ts b/src/feature-flags/evaluator.ts index 1089293a7..68fd6b88f 100644 --- a/src/feature-flags/evaluator.ts +++ b/src/feature-flags/evaluator.ts @@ -44,13 +44,12 @@ export class Evaluator { // per flag. const normalizedContext = this.normalizeContext(context); const flags = this.store.getAll(); - const result: Record = {}; - - 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( @@ -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); + } + + private servedValue(value: unknown, defaultValue: boolean): boolean { + return typeof value === 'boolean' ? value : defaultValue; } /** @@ -85,6 +97,14 @@ export class Evaluator { */ private normalizeContext(context: EvaluationContext): Map { const normalized = new Map(); + if ( + typeof context !== 'object' || + context === null || + Array.isArray(context) + ) { + this.logger?.warn('Ignoring invalid evaluation context'); + return normalized; + } const record: Record = context; const legacyEntries: Array<[string, string]> = []; @@ -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, - ); - } } diff --git a/src/feature-flags/feature-flags.ts b/src/feature-flags/feature-flags.ts index 707f72336..b3acc06ae 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, + FlagTarget, + FlagTargetResponse, + FlagTargetResource, + FlagTargetResourceResponse, + ListFlagRulesOptions, + ListFlagTargetsOptions, ListFeatureFlagsOptions, RemoveFlagTargetOptions, RuntimeClientOptions, } from './interfaces'; -import { deserializeFeatureFlag } from './serializers'; +import { + deserializeFeatureFlag, + deserializeFlagRule, + deserializeFlagTarget, + 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 deserializeFlagTarget(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: FlagTarget[] = []; + 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/in-memory-store.spec.ts b/src/feature-flags/in-memory-store.spec.ts index c567dd25e..ca1179191 100644 --- a/src/feature-flags/in-memory-store.spec.ts +++ b/src/feature-flags/in-memory-store.spec.ts @@ -8,17 +8,16 @@ describe('InMemoryStore', () => { slug: 'flag-a', enabled: true, default_value: true, - targets: { users: [], organizations: [] }, + off_value: false, + rules: [], }; const flagB: FlagPollEntry = { slug: 'flag-b', enabled: false, default_value: false, - targets: { - users: [{ id: 'user_123', enabled: true }], - organizations: [], - }, + off_value: false, + rules: [], }; beforeEach(() => { diff --git a/src/feature-flags/in-memory-store.ts b/src/feature-flags/in-memory-store.ts index d93c009c7..ff54cf2b7 100644 --- a/src/feature-flags/in-memory-store.ts +++ b/src/feature-flags/in-memory-store.ts @@ -1,17 +1,17 @@ import { FlagPollEntry, FlagPollResponse } from './interfaces'; export class InMemoryStore { - private flags: FlagPollResponse = {}; + private flags: FlagPollResponse['flags'] = {}; - swap(newFlags: FlagPollResponse): void { + swap(newFlags: FlagPollResponse['flags']): void { this.flags = { ...newFlags }; } get(slug: string): FlagPollEntry | undefined { - return this.flags[slug]; + return Object.hasOwn(this.flags, slug) ? this.flags[slug] : undefined; } - getAll(): FlagPollResponse { + getAll(): FlagPollResponse['flags'] { return { ...this.flags }; } 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..d652ffbbc --- /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 { FlagTarget } from './flag-target.interface'; + +export interface CreateRuleWithTargetsOptions extends CreateFlagRuleOptions { + targetIds: string[]; +} + +export interface CreateRuleWithTargetsResult { + rule: FlagRule; + targets: FlagTarget[]; +} diff --git a/src/feature-flags/interfaces/flag-poll-response.interface.ts b/src/feature-flags/interfaces/flag-poll-response.interface.ts index 762f53ed4..62e404462 100644 --- a/src/feature-flags/interfaces/flag-poll-response.interface.ts +++ b/src/feature-flags/interfaces/flag-poll-response.interface.ts @@ -1,24 +1,29 @@ -export interface FlagTarget { - id: string; - enabled: boolean; +export interface FlagPollCondition { + // Future operators may carry different operands. Unsupported conditions + // never match, even if another condition in the rule matches. + operator: string; + target_type?: string; + values?: unknown; } -export interface FlagCustomTarget { - type: string; +export interface FlagPollRule { id: string; - enabled: boolean; + kind: string; + // Future multivariate values must fall back to the caller's boolean default. + value: unknown; + conditions?: FlagPollCondition[]; } export interface FlagPollEntry { slug: string; enabled: boolean; - default_value: boolean; - targets: { - users: FlagTarget[]; - organizations: FlagTarget[]; - /** Absent until the API's custom-targets rollout flag is enabled. */ - custom_targets?: FlagCustomTarget[]; - }; + default_value: unknown; + off_value: unknown; + /** Evaluated in array order; the first matching rule wins. */ + rules: FlagPollRule[]; } -export type FlagPollResponse = Record; +export interface FlagPollResponse { + version: 2; + flags: Record; +} 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.interface.ts b/src/feature-flags/interfaces/flag-target.interface.ts new file mode 100644 index 000000000..16f45329c --- /dev/null +++ b/src/feature-flags/interfaces/flag-target.interface.ts @@ -0,0 +1,44 @@ +/** A target's membership in a flag rule. */ +export interface FlagTarget { + object: 'flag_target'; + id: string; + ruleId: string; + flagId: string; + flagSlug: string; + environmentId: string; + targetType: string; + targetId: string; + createdAt: string; + updatedAt: string; +} + +export interface FlagTargetResponse { + 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< + FlagTargetResponse, + 'rule_id' +> { + value_type: 'boolean'; + value: boolean; +} + +export type FlagTargetResource = FlagTarget | LegacyFlagTarget; +export type FlagTargetResourceResponse = + FlagTargetResponse | LegacyFlagTargetResponse; diff --git a/src/feature-flags/interfaces/index.ts b/src/feature-flags/interfaces/index.ts index c677b1f20..bc3e26385 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.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/interfaces/runtime-client-options.interface.ts b/src/feature-flags/interfaces/runtime-client-options.interface.ts index 02726bd61..4fe9fbcb1 100644 --- a/src/feature-flags/interfaces/runtime-client-options.interface.ts +++ b/src/feature-flags/interfaces/runtime-client-options.interface.ts @@ -1,4 +1,4 @@ -import { FlagPollEntry } from './flag-poll-response.interface'; +import { FlagPollResponse } from './flag-poll-response.interface'; export interface RuntimeClientLogger { debug(...args: unknown[]): void; @@ -9,7 +9,8 @@ export interface RuntimeClientLogger { export interface RuntimeClientOptions { pollingIntervalMs?: number; - bootstrapFlags?: Record; + /** A versioned envelope, as returned by polling. */ + bootstrapFlags?: FlagPollResponse; requestTimeoutMs?: number; logger?: RuntimeClientLogger; } diff --git a/src/feature-flags/payload.ts b/src/feature-flags/payload.ts new file mode 100644 index 000000000..0990d1eb7 --- /dev/null +++ b/src/feature-flags/payload.ts @@ -0,0 +1,66 @@ +import { + FlagPollCondition, + FlagPollEntry, + FlagPollResponse, + FlagPollRule, +} from './interfaces'; + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function isCondition(value: unknown): value is FlagPollCondition { + return ( + isRecord(value) && + typeof value.operator === 'string' && + (value.target_type === undefined || + typeof value.target_type === 'string') && + // `one_of` always carries a list of IDs. Other operators may carry + // different operands; they pass through and never match. + (value.operator !== 'one_of' || Array.isArray(value.values)) + ); +} + +function isRule(value: unknown): value is FlagPollRule { + return ( + isRecord(value) && + typeof value.id === 'string' && + typeof value.kind === 'string' && + (value.conditions === undefined || + (Array.isArray(value.conditions) && value.conditions.every(isCondition))) + ); +} + +function isEntry(value: unknown): value is FlagPollEntry { + return ( + isRecord(value) && + typeof value.slug === 'string' && + typeof value.enabled === 'boolean' && + 'default_value' in value && + 'off_value' in value && + Array.isArray(value.rules) && + value.rules.every(isRule) + ); +} + +/** Validate a payload before swapping the store. One invalid entry rejects the + * whole payload, so the store never holds a partial snapshot and the last good + * one stays intact; bootstrap callers can still be ready. */ +export function parseFlagPollResponse( + response: unknown, +): FlagPollResponse | undefined { + if ( + !isRecord(response) || + response.version !== 2 || + !isRecord(response.flags) + ) { + return undefined; + } + + const entries: Array<[string, FlagPollEntry]> = []; + for (const [slug, entry] of Object.entries(response.flags)) { + if (!isEntry(entry)) return undefined; + entries.push([slug, entry]); + } + return { version: 2, flags: Object.fromEntries(entries) }; +} diff --git a/src/feature-flags/rule-management.spec.ts b/src/feature-flags/rule-management.spec.ts new file mode 100644 index 000000000..8946488fc --- /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, + FlagTargetResponse, + 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: FlagTargetResponse = { + 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/runtime-client-rules.spec.ts b/src/feature-flags/runtime-client-rules.spec.ts new file mode 100644 index 000000000..ebd761ccd --- /dev/null +++ b/src/feature-flags/runtime-client-rules.spec.ts @@ -0,0 +1,721 @@ +import fetch from 'jest-fetch-mock'; +import { fetchOnce, fetchURL } from '../common/utils/test-utils'; +import { WorkOS } from '../workos'; +import { + EvaluationContext, + FlagChange, + FlagPollEntry, + FlagPollResponse, + RuntimeClientLogger, +} from './interfaces'; + +const workos = new WorkOS('sk_test_example'); +const exclusion = { + id: 'rule_org', + kind: 'conditions', + value: false, + conditions: [ + { target_type: 'organization', operator: 'one_of', values: ['org_1'] }, + ], +}; +const inclusion = { + id: 'rule_user', + kind: 'conditions', + value: true, + conditions: [{ target_type: 'user', operator: 'one_of', values: ['user_1'] }], +}; +const flag: FlagPollEntry = { + slug: 'flag', + enabled: true, + default_value: true, + off_value: false, + rules: [exclusion, inclusion], +}; +const payload: FlagPollResponse = { version: 2, flags: { flag } }; +const legacyPayload = { + flag: { + slug: 'flag', + enabled: true, + default_value: true, + targets: { users: [{ id: 'user_1', enabled: true }], organizations: [] }, + }, +}; +const malformedMembership = { + version: 2, + flags: { + flag: { + ...flag, + rules: [ + { + ...inclusion, + conditions: [ + { target_type: 'user', operator: 'one_of', values: 'user_1' }, + ], + }, + ], + }, + }, +}; +const membershipWithoutValues = { + version: 2, + flags: { + flag: { + ...flag, + rules: [ + { + ...inclusion, + conditions: [{ target_type: 'user', operator: 'one_of' }], + }, + ], + }, + }, +}; +const createLogger = (): jest.Mocked => ({ + debug: jest.fn(), + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), +}); + +describe('runtime client rules', () => { + beforeEach(() => { + fetch.resetMocks(); + jest.useFakeTimers(); + jest.spyOn(Math, 'random').mockReturnValue(0.5); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it('requests v2 and evaluates exclusions in payload order', async () => { + fetchOnce(payload); + const client = workos.featureFlags.createRuntimeClient(); + await jest.advanceTimersByTimeAsync(0); + await client.waitUntilReady(); + + expect( + new URL(String(fetchURL())).searchParams.get('payload_version'), + ).toBe('2'); + expect( + client.isEnabled('flag', { userId: 'user_1', organizationId: 'org_1' }), + ).toBe(false); + expect(client.isEnabled('flag', { userId: 'user_1' })).toBe(true); + expect(client.getFlag('flag')).toEqual(flag); + expect(client.getStats().flagCount).toBe(1); + client.close(); + }); + + it.each([ + ['future version', { ...payload, version: 3 }], + ['invalid version', { ...payload, version: '2' }], + ['missing version', { flags: { flag } }], + ['invalid flags', { version: 2, flags: null }], + [ + 'invalid entry', + { version: 2, flags: { flag: { ...flag, rules: null } } }, + ], + ['legacy flat', legacyPayload], + ['bare entry map', { flag }], + ['malformed membership', malformedMembership], + ['membership without values', membershipWithoutValues], + ['null', null], + ['array', []], + ['scalar', false], + ])( + 'ignores %s bootstrap data with a warning, resolves readiness, and then polls', + async (_name, input) => { + const bootstrapFlags: FlagPollResponse = JSON.parse( + JSON.stringify(input), + ); + const logger = createLogger(); + const client = workos.featureFlags.createRuntimeClient({ + bootstrapFlags, + logger, + }); + await client.waitUntilReady(); + + expect(logger.warn).toHaveBeenCalledTimes(1); + expect(logger.warn).toHaveBeenCalledWith( + expect.stringContaining('Ignoring bootstrapFlags'), + ); + expect(client.getStats().flagCount).toBe(0); + expect(client.isEnabled('flag', {}, true)).toBe(true); + + fetchOnce(payload); + await jest.advanceTimersByTimeAsync(0); + expect(client.getStats().flagCount).toBe(1); + client.close(); + }, + ); + + const cases: Array<{ + name: string; + config: Partial; + context?: EvaluationContext; + fallback?: boolean; + expected: boolean; + }> = [ + { + name: 'off value overrides matching rules and both defaults', + config: { enabled: false }, + context: { userId: 'user_1' }, + fallback: true, + expected: false, + }, + { + name: 'a boolean off value is honored', + config: { enabled: false, off_value: true }, + expected: true, + }, + { + name: 'an empty rule list serves the flag default', + config: { rules: [], default_value: false }, + fallback: true, + expected: false, + }, + { + name: 'no match serves the flag default, not the caller default', + config: {}, + expected: true, + }, + { + name: 'a missing context type does not match', + config: { default_value: false, rules: [inclusion] }, + expected: false, + }, + { + name: 'a matching false rule stops evaluation', + config: {}, + context: { user: { id: 'user_1' }, organization: { id: 'org_1' } }, + fallback: true, + expected: false, + }, + { + name: 'reordering conflicting target types changes the winner', + config: { rules: [inclusion, exclusion] }, + context: { userId: 'user_1', organizationId: 'org_1' }, + expected: true, + }, + { + name: 'a later rule can match', + config: { default_value: false }, + context: { userId: 'user_1' }, + expected: true, + }, + { + name: 'custom target membership can match any listed ID', + config: { + default_value: false, + rules: [ + { + ...inclusion, + conditions: [ + { + target_type: 'workspace', + operator: 'one_of', + values: ['ws_0', 'ws_1'], + }, + ], + }, + ], + }, + context: { workspace: { id: 'ws_1' } }, + expected: true, + }, + { + name: 'membership uses exact, case-sensitive IDs', + config: { default_value: false, rules: [inclusion] }, + context: { userId: 'USER_1' }, + expected: false, + }, + { + name: 'an ID from another type does not match', + config: { default_value: false, rules: [inclusion] }, + context: { workspace: { id: 'user_1' } }, + expected: false, + }, + { + name: 'all conditions must match', + config: { + default_value: false, + rules: [ + { + ...inclusion, + conditions: [...inclusion.conditions, ...exclusion.conditions], + }, + ], + }, + context: { userId: 'user_1', organizationId: 'org_1' }, + expected: true, + }, + { + name: 'one matching condition cannot override a failed condition', + config: { + default_value: false, + rules: [ + { + ...inclusion, + conditions: [...inclusion.conditions, ...exclusion.conditions], + }, + ], + }, + context: { userId: 'user_1', organizationId: 'org_other' }, + expected: false, + }, + { + name: 'one matching condition cannot override a missing context type', + config: { + default_value: false, + rules: [ + { + ...inclusion, + conditions: [...inclusion.conditions, ...exclusion.conditions], + }, + ], + }, + context: { userId: 'user_1' }, + expected: false, + }, + { + name: 'empty conditions never match', + config: { + default_value: false, + rules: [{ ...inclusion, conditions: [] }], + }, + expected: false, + }, + { + name: 'empty membership never matches', + config: { + default_value: false, + rules: [ + { + ...inclusion, + conditions: [ + { target_type: 'user', operator: 'one_of', values: [] }, + ], + }, + ], + }, + context: { userId: 'user_1' }, + expected: false, + }, + { + name: 'unknown rule kinds are skipped even with matching conditions', + config: { + default_value: false, + rules: [{ ...inclusion, kind: 'rollout' }], + }, + context: { userId: 'user_1' }, + expected: false, + }, + { + name: 'unknown rules without conditions allow later rules to match', + config: { + default_value: false, + rules: [{ id: 'future', kind: 'rollout', value: false }, inclusion], + }, + context: { userId: 'user_1' }, + expected: true, + }, + { + name: 'unknown operators invalidate the whole rule', + config: { + default_value: false, + rules: [ + { + ...inclusion, + conditions: [...inclusion.conditions, { operator: 'segment' }], + }, + ], + }, + context: { userId: 'user_1' }, + fallback: true, + expected: false, + }, + { + name: 'unknown operators allow later rules to match', + config: { + default_value: false, + rules: [ + { + ...exclusion, + conditions: [ + { + target_type: 'user', + operator: 'not_one_of', + values: ['user_1'], + }, + ], + }, + inclusion, + ], + }, + context: { userId: 'user_1' }, + expected: true, + }, + { + name: 'unparseable matching values use the caller default and stop', + config: { + default_value: true, + rules: [{ ...inclusion, value: 'true' }, inclusion], + }, + context: { userId: 'user_1' }, + fallback: false, + expected: false, + }, + { + name: 'unparseable off values use a true caller default', + config: { enabled: false, off_value: 0, default_value: false }, + fallback: true, + expected: true, + }, + { + name: 'unparseable defaults use the caller default', + config: { rules: [], default_value: 'false' }, + expected: false, + }, + { + name: 'unparseable values in unmatched rules have no effect', + config: { rules: [{ ...exclusion, value: null }] }, + context: { organizationId: 'org_other' }, + expected: true, + }, + { + name: 'membership IDs that are not strings never match', + config: { + default_value: false, + rules: [ + { + ...inclusion, + conditions: [ + { target_type: 'user', operator: 'one_of', values: [1] }, + ], + }, + ], + }, + context: { userId: '1' }, + fallback: true, + expected: false, + }, + ]; + + it.each(cases)('$name', async ({ config, context, fallback, expected }) => { + const client = workos.featureFlags.createRuntimeClient({ + bootstrapFlags: { version: 2, flags: { flag: { ...flag, ...config } } }, + }); + await client.waitUntilReady(); + expect(client.isEnabled('flag', context, fallback)).toBe(expected); + client.close(); + }); + + it('ignores extra payload keys and evaluates all flags with a false fallback', async () => { + fetchOnce({ + ...payload, + future: { version: 9 }, + flags: { flag, unknown: { ...flag, slug: 'unknown', default_value: 1 } }, + }); + const client = workos.featureFlags.createRuntimeClient(); + await jest.advanceTimersByTimeAsync(0); + expect(client.getAllFlags({ organization: { id: 'org_1' } })).toEqual({ + flag: false, + unknown: false, + }); + expect(client.getAllFlags()).toEqual({ flag: true, unknown: false }); + expect(client.isEnabled('missing', {}, true)).toBe(true); + client.close(); + }); + + it('replaces bootstrap data wholesale on the first poll without change events', async () => { + const client = workos.featureFlags.createRuntimeClient({ + bootstrapFlags: payload, + }); + const changes: FlagChange[] = []; + client.on('change', (change) => changes.push(change)); + expect(client.isEnabled('flag')).toBe(true); + fetchOnce({ version: 2, flags: {} }); + await jest.advanceTimersByTimeAsync(0); + expect(client.getAllFlags()).toEqual({}); + expect(client.getStats().flagCount).toBe(0); + expect(changes).toEqual([]); + client.close(); + }); + + it.each([ + ['rule order', { ...flag, rules: [inclusion, exclusion] }], + [ + 'rule value', + { ...flag, rules: [{ ...exclusion, value: true }, inclusion] }, + ], + [ + 'membership', + { + ...flag, + rules: [ + { + ...inclusion, + conditions: [ + { target_type: 'user', operator: 'one_of', values: ['user_2'] }, + ], + }, + ], + }, + ], + ['off value', { ...flag, off_value: true }], + ['default value', { ...flag, default_value: false }], + ['enabled', { ...flag, enabled: false }], + [ + 'rule kind', + { ...flag, rules: [{ ...exclusion, kind: 'rollout' }, inclusion] }, + ], + [ + 'condition operator', + { + ...flag, + rules: [ + { + ...exclusion, + conditions: [ + { ...exclusion.conditions[0], operator: 'not_one_of' }, + ], + }, + inclusion, + ], + }, + ], + [ + 'target type', + { + ...flag, + rules: [ + { + ...exclusion, + conditions: [ + { ...exclusion.conditions[0], target_type: 'workspace' }, + ], + }, + inclusion, + ], + }, + ], + ['removed rule', { ...flag, rules: [inclusion] }], + ])('emits complete snapshots when %s changes', async (_name, current) => { + fetchOnce(payload); + const client = workos.featureFlags.createRuntimeClient(); + const changes: FlagChange[] = []; + client.on('change', (change) => changes.push(change)); + await jest.advanceTimersByTimeAsync(0); + expect(changes).toEqual([]); + fetchOnce({ version: 2, flags: { flag: current } }); + await jest.advanceTimersByTimeAsync(30_000); + expect(changes).toEqual([{ key: 'flag', previous: flag, current }]); + client.close(); + }); + + it('ignores membership and condition ordering when detecting changes', async () => { + const conditions = [ + { target_type: 'user', operator: 'one_of', values: ['user_1', 'user_2'] }, + { target_type: 'workspace', operator: 'one_of', values: ['ws_1'] }, + ]; + fetchOnce({ + version: 2, + flags: { flag: { ...flag, rules: [{ ...inclusion, conditions }] } }, + }); + const client = workos.featureFlags.createRuntimeClient(); + const changes: FlagChange[] = []; + client.on('change', (change) => changes.push(change)); + await jest.advanceTimersByTimeAsync(0); + fetchOnce({ + version: 2, + flags: { + flag: { + ...flag, + rules: [ + { + ...inclusion, + conditions: [ + conditions[1], + { ...conditions[0], values: ['user_2', 'user_1'] }, + ], + }, + ], + }, + }, + }); + await jest.advanceTimersByTimeAsync(30_000); + expect(changes).toEqual([]); + client.close(); + }); + + it('emits additions and removals while replacing the entire snapshot', async () => { + fetchOnce(payload); + const client = workos.featureFlags.createRuntimeClient(); + const changes: FlagChange[] = []; + client.on('change', (change) => changes.push(change)); + await jest.advanceTimersByTimeAsync(0); + const added = { ...flag, slug: 'added' }; + fetchOnce({ version: 2, flags: { added } }); + await jest.advanceTimersByTimeAsync(30_000); + expect(changes).toEqual([ + { key: 'flag', previous: flag, current: null }, + { key: 'added', previous: null, current: added }, + ]); + expect(client.getAllFlags()).toEqual({ added: true }); + client.close(); + }); + + it.each([ + ['network failure', 'network'], + ['request timeout', 'timeout'], + ['unknown version', { ...payload, version: 3 }], + [ + 'malformed payload', + { version: 2, flags: { flag: { ...flag, rules: null } } }, + ], + ['legacy flat payload', legacyPayload], + ['malformed membership', malformedMembership], + ])( + 'keeps the last good snapshot after %s and recovers on the next poll', + async (_name, failure) => { + fetchOnce(payload); + const client = workos.featureFlags.createRuntimeClient({ + requestTimeoutMs: 50, + }); + const changes: FlagChange[] = []; + const errors: Error[] = []; + client.on('change', (change) => changes.push(change)); + client.on('error', (error) => errors.push(error)); + await jest.advanceTimersByTimeAsync(0); + const lastSuccessfulPollAt = client.getStats().lastSuccessfulPollAt; + if (failure === 'network') fetch.mockRejectOnce(new Error('offline')); + else if (failure === 'timeout') + fetch.mockResponseOnce(() => new Promise(() => {})); + else fetchOnce(failure); + await jest.advanceTimersByTimeAsync(30_050); + expect(client.isEnabled('flag', { organizationId: 'org_1' })).toBe(false); + expect(client.getAllFlags()).toEqual({ flag: true }); + expect(client.getStats()).toMatchObject({ + flagCount: 1, + pollErrorCount: 1, + lastSuccessfulPollAt, + }); + expect(errors).toHaveLength(1); + if (typeof failure !== 'string') { + expect(errors[0].message).toContain("can't read"); + } + expect(changes).toEqual([]); + fetchOnce({ version: 2, flags: {} }); + await jest.advanceTimersByTimeAsync(30_000); + expect(client.getAllFlags()).toEqual({}); + expect(changes).toEqual([{ key: 'flag', previous: flag, current: null }]); + client.close(); + }, + ); + + it.each<[string, FlagPollResponse]>([ + ['an empty', { version: 2, flags: {} }], + ['a populated', payload], + ])( + 'accepts %s bootstrap snapshot without a warning', + async (_name, input) => { + const logger = createLogger(); + const client = workos.featureFlags.createRuntimeClient({ + bootstrapFlags: input, + logger, + }); + await client.waitUntilReady(); + expect(logger.warn).not.toHaveBeenCalled(); + expect(client.getStats().flagCount).toBe(Object.keys(input.flags).length); + client.close(); + }, + ); + + it('serves defaults from an empty bootstrap snapshot', async () => { + const client = workos.featureFlags.createRuntimeClient({ + bootstrapFlags: { version: 2, flags: {} }, + }); + await client.waitUntilReady(); + expect(client.getAllFlags()).toEqual({}); + expect(client.isEnabled('absent', {}, true)).toBe(true); + client.close(); + }); + + it('preserves reserved-looking flag slugs', async () => { + const entries = ['version', 'flags', '__proto__', 'constructor'].map( + (slug) => [slug, { ...flag, slug }], + ); + fetchOnce({ version: 2, flags: Object.fromEntries(entries) }); + const client = workos.featureFlags.createRuntimeClient(); + const changes: FlagChange[] = []; + client.on('change', (change) => changes.push(change)); + expect(client.getFlag('constructor')).toBeUndefined(); + expect(client.isEnabled('toString', {}, true)).toBe(true); + await jest.advanceTimersByTimeAsync(0); + expect(client.getAllFlags()).toEqual( + JSON.parse( + '{"version":true,"flags":true,"__proto__":true,"constructor":true}', + ), + ); + expect(client.getFlag('__proto__')?.slug).toBe('__proto__'); + fetchOnce({ version: 2, flags: {} }); + await jest.advanceTimersByTimeAsync(30_000); + expect(changes).toHaveLength(4); + expect(changes.every((change) => change.current === null)).toBe(true); + expect(client.getFlag('constructor')).toBeUndefined(); + client.close(); + }); + + it('does not mark a malformed initial poll ready or emit changes on recovery', async () => { + fetchOnce({ version: 99, flags: {} }); + const client = workos.featureFlags.createRuntimeClient(); + client.on('error', () => {}); + const changes: FlagChange[] = []; + client.on('change', (change) => changes.push(change)); + let ready = false; + const readiness = client.waitUntilReady().then(() => { + ready = true; + }); + await jest.advanceTimersByTimeAsync(0); + expect(ready).toBe(false); + expect(client.getStats().lastSuccessfulPollAt).toBeNull(); + fetchOnce(payload); + await jest.advanceTimersByTimeAsync(30_000); + await readiness; + expect(ready).toBe(true); + expect(changes).toEqual([]); + client.close(); + }); + + it('retains the snapshot and stops polling after a 401', async () => { + fetchOnce(payload); + const client = workos.featureFlags.createRuntimeClient(); + const failures: Error[] = []; + client.on('error', () => {}); + client.on('failed', (error) => failures.push(error)); + await jest.advanceTimersByTimeAsync(0); + fetchOnce({ message: 'Unauthorized' }, { status: 401 }); + await jest.advanceTimersByTimeAsync(30_000); + expect(failures).toHaveLength(1); + expect(client.getAllFlags({ organizationId: 'org_1' })).toEqual({ + flag: false, + }); + await client.waitUntilReady(); + await jest.advanceTimersByTimeAsync(120_000); + expect(fetch.mock.calls).toHaveLength(2); + client.close(); + }); + + it.each([null, false, 'invalid', []])( + 'never throws on a malformed top-level context: %j', + (input) => { + const context: EvaluationContext = JSON.parse(JSON.stringify(input)); + const client = workos.featureFlags.createRuntimeClient({ + bootstrapFlags: payload, + }); + expect(client.isEnabled('flag', context)).toBe(true); + expect(client.getAllFlags(context)).toEqual({ flag: true }); + client.close(); + }, + ); +}); diff --git a/src/feature-flags/runtime-client.spec.ts b/src/feature-flags/runtime-client.spec.ts index 249112565..5e9d0cda4 100644 --- a/src/feature-flags/runtime-client.spec.ts +++ b/src/feature-flags/runtime-client.spec.ts @@ -8,22 +8,34 @@ import { FlagPollResponse } from './interfaces'; const workos = new WorkOS('sk_test_Sz3IQjepeSWaI4cMS4ms4sMuU'); const pollResponse: FlagPollResponse = { - 'flag-a': { - slug: 'flag-a', - enabled: true, - default_value: true, - targets: { users: [], organizations: [] }, - }, - 'flag-b': { - slug: 'flag-b', - enabled: true, - default_value: false, - targets: { - users: [{ id: 'user_123', enabled: true }], - organizations: [], + version: 2, + flags: { + 'flag-a': { + slug: 'flag-a', + enabled: true, + default_value: true, + off_value: false, + rules: [], + }, + 'flag-b': { + slug: 'flag-b', + enabled: true, + default_value: false, + off_value: false, + rules: [ + { + id: 'rule_user', + kind: 'conditions', + value: true, + conditions: [ + { target_type: 'user', operator: 'one_of', values: ['user_123'] }, + ], + }, + ], }, }, }; +const { 'flag-a': flagA, 'flag-b': flagB } = pollResponse.flags; describe('FeatureFlagsRuntimeClient', () => { beforeEach(() => { @@ -178,12 +190,12 @@ describe('FeatureFlagsRuntimeClient', () => { }); describe('getFlag', () => { - it('returns raw flag entry', async () => { + it('returns flag configuration', async () => { const client = createClientAndWait(); await jest.advanceTimersByTimeAsync(0); await client.waitUntilReady(); - expect(client.getFlag('flag-a')).toEqual(pollResponse['flag-a']); + expect(client.getFlag('flag-a')).toEqual(flagA); expect(client.getFlag('unknown')).toBeUndefined(); client.close(); @@ -251,13 +263,8 @@ describe('FeatureFlagsRuntimeClient', () => { client.on('change', (change) => changes.push(change)); const updatedResponse: FlagPollResponse = { - 'flag-a': { - slug: 'flag-a', - enabled: false, - default_value: true, - targets: { users: [], organizations: [] }, - }, - 'flag-b': pollResponse['flag-b'], + version: 2, + flags: { 'flag-a': { ...flagA, enabled: false }, 'flag-b': flagB }, }; fetchOnce(updatedResponse); @@ -266,15 +273,15 @@ describe('FeatureFlagsRuntimeClient', () => { expect(changes).toEqual([ { key: 'flag-a', - previous: pollResponse['flag-a'], - current: updatedResponse['flag-a'], + previous: flagA, + current: { ...flagA, enabled: false }, }, ]); client.close(); }); - it('emits change when only custom targets change', async () => { + it('emits change when only custom target rules change', async () => { const client = createClientAndWait(); await jest.advanceTimersByTimeAsync(0); await client.waitUntilReady(); @@ -282,28 +289,33 @@ describe('FeatureFlagsRuntimeClient', () => { const changes: unknown[] = []; client.on('change', (change) => changes.push(change)); - const updatedResponse: FlagPollResponse = { - 'flag-a': pollResponse['flag-a'], - 'flag-b': { - ...pollResponse['flag-b'], - targets: { - ...pollResponse['flag-b'].targets, - custom_targets: [ - { type: 'workspace', id: 'ws_123', enabled: true }, + const updatedFlagB = { + ...flagB, + rules: [ + ...flagB.rules, + { + id: 'rule_workspace', + kind: 'conditions', + value: true, + conditions: [ + { + target_type: 'workspace', + operator: 'one_of', + values: ['ws_123'], + }, ], }, - }, + ], }; - fetchOnce(updatedResponse); + fetchOnce({ + version: 2, + flags: { 'flag-a': flagA, 'flag-b': updatedFlagB }, + }); await jest.advanceTimersByTimeAsync(35_000); expect(changes).toEqual([ - { - key: 'flag-b', - previous: pollResponse['flag-b'], - current: updatedResponse['flag-b'], - }, + { key: 'flag-b', previous: flagB, current: updatedFlagB }, ]); client.close(); @@ -318,13 +330,13 @@ describe('FeatureFlagsRuntimeClient', () => { client.on('change', (change) => changes.push(change)); // Second poll returns only flag-a - fetchOnce({ 'flag-a': pollResponse['flag-a'] }); + fetchOnce({ version: 2, flags: { 'flag-a': flagA } }); await jest.advanceTimersByTimeAsync(35_000); expect(changes).toEqual([ { key: 'flag-b', - previous: pollResponse['flag-b'], + previous: flagB, current: null, }, ]); @@ -336,11 +348,15 @@ describe('FeatureFlagsRuntimeClient', () => { describe('bootstrap flags', () => { it('first poll replaces bootstrap data', async () => { const bootstrapFlags: FlagPollResponse = { - 'bootstrap-flag': { - slug: 'bootstrap-flag', - enabled: true, - default_value: true, - targets: { users: [], organizations: [] }, + version: 2, + flags: { + 'bootstrap-flag': { + slug: 'bootstrap-flag', + enabled: true, + default_value: true, + off_value: false, + rules: [], + }, }, }; diff --git a/src/feature-flags/runtime-client.ts b/src/feature-flags/runtime-client.ts index e13841597..616522863 100644 --- a/src/feature-flags/runtime-client.ts +++ b/src/feature-flags/runtime-client.ts @@ -3,13 +3,12 @@ import { WorkOS } from '../workos'; import { UnauthorizedException } from '../common/exceptions'; import { InMemoryStore } from './in-memory-store'; import { Evaluator } from './evaluator'; +import { parseFlagPollResponse } from './payload'; import { EvaluationContext, FlagChange, - FlagCustomTarget, FlagPollEntry, FlagPollResponse, - FlagTarget, RuntimeClientOptions, RuntimeClientLogger, RuntimeClientStats, @@ -80,8 +79,15 @@ export class FeatureFlagsRuntimeClient extends EventEmitter // Prevent unhandled rejection if no one awaits waitUntilReady this.readyPromise.catch(() => {}); - if (options.bootstrapFlags) { - this.store.swap(options.bootstrapFlags); + if (options.bootstrapFlags !== undefined) { + const bootstrap = parseFlagPollResponse(options.bootstrapFlags); + if (bootstrap) { + this.store.swap(bootstrap.flags); + } else { + this.logger?.warn( + 'Ignoring bootstrapFlags: expected a v2 flag payload ({ version: 2, flags }). Flags will load on the first successful poll.', + ); + } this.stats.flagCount = this.store.size; this.resolveReady(); } @@ -164,15 +170,20 @@ export class FeatureFlagsRuntimeClient extends EventEmitter this.stats.pollCount++; this.stats.lastPollAt = new Date(); - const data = await this.fetchWithTimeout(); + const data = parseFlagPollResponse(await this.fetchWithTimeout()); + if (!data) { + throw new Error( + "Feature flag poll returned a payload this SDK can't read; keeping the last good flags", + ); + } - this.store.swap(data); + this.store.swap(data.flags); this.stats.lastSuccessfulPollAt = new Date(); this.stats.flagCount = this.store.size; this.consecutiveErrors = 0; if (this.initialized) { - this.emitChanges(previousFlags, data); + this.emitChanges(previousFlags, data.flags); } this.initialized = true; this.resolveReady(); @@ -201,14 +212,14 @@ export class FeatureFlagsRuntimeClient extends EventEmitter this.scheduleNextPoll(); } - private async fetchWithTimeout(): Promise { + private async fetchWithTimeout(): Promise { this.pollAbortController = new AbortController(); const { signal } = this.pollAbortController; let timeoutId: ReturnType; const fetchPromise = this.workos - .get('/sdk/feature-flags') + .get('/sdk/feature-flags?payload_version=2') .then(({ data }) => data); const timeoutPromise = new Promise((_, reject) => { @@ -262,8 +273,8 @@ export class FeatureFlagsRuntimeClient extends EventEmitter } private emitChanges( - previous: FlagPollResponse, - current: FlagPollResponse, + previous: FlagPollResponse['flags'], + current: FlagPollResponse['flags'], ): void { if (!previous || !current) { return; @@ -275,8 +286,8 @@ export class FeatureFlagsRuntimeClient extends EventEmitter ]); for (const key of allKeys) { - const prev = previous[key]; - const curr = current[key]; + const prev = Object.hasOwn(previous, key) ? previous[key] : undefined; + const curr = Object.hasOwn(current, key) ? current[key] : undefined; if (this.hasEntryChanged(prev, curr)) { this.emit('change', { @@ -292,38 +303,31 @@ export class FeatureFlagsRuntimeClient extends EventEmitter a: FlagPollEntry | undefined, b: FlagPollEntry | undefined, ): boolean { - if (!a || !b) { - return a !== b; - } - - if (a.enabled !== b.enabled || a.default_value !== b.default_value) { - return true; - } - - const targetsChanged = (xs: FlagTarget[], ys: FlagTarget[]): boolean => { - if (xs.length !== ys.length) return true; - const map = new Map(ys.map((t) => [t.id, t.enabled])); - return xs.some((t) => map.get(t.id) !== t.enabled); - }; - - // Type slugs cannot contain ':', so the first ':' unambiguously ends the - // type in this composite key even though target IDs may contain ':'. - const customTargetsChanged = ( - xs: FlagCustomTarget[], - ys: FlagCustomTarget[], - ): boolean => { - if (xs.length !== ys.length) return true; - const map = new Map(ys.map((t) => [`${t.type}:${t.id}`, t.enabled])); - return xs.some((t) => map.get(`${t.type}:${t.id}`) !== t.enabled); - }; - - return ( - targetsChanged(a.targets.users, b.targets.users) || - targetsChanged(a.targets.organizations, b.targets.organizations) || - customTargetsChanged( - a.targets.custom_targets ?? [], - b.targets.custom_targets ?? [], - ) - ); + if (!a || !b) return a !== b; + + // Rule order determines precedence. Condition and membership order do not. + const signature = (entry: FlagPollEntry): string => + JSON.stringify([ + entry.enabled, + entry.default_value, + entry.off_value, + entry.rules.map((rule) => [ + rule.id, + rule.kind, + rule.value, + rule.conditions + ?.map((condition) => + JSON.stringify([ + condition.operator, + condition.target_type, + Array.isArray(condition.values) + ? [...new Set(condition.values)].sort() + : condition.values, + ]), + ) + .sort(), + ]), + ]); + return signature(a) !== signature(b); } } 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.serializer.ts b/src/feature-flags/serializers/flag-target.serializer.ts new file mode 100644 index 000000000..5eae4cd0b --- /dev/null +++ b/src/feature-flags/serializers/flag-target.serializer.ts @@ -0,0 +1,36 @@ +import { + FlagTarget, + FlagTargetResponse, + 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 deserializeFlagTarget = ( + target: FlagTargetResponse, +): FlagTarget => ({ + ...deserializeTargetFields(target), + ruleId: target.rule_id, +}); + +export const deserializeFlagTargetResource = ( + target: FlagTargetResourceResponse, +): FlagTargetResource => + 'rule_id' in target + ? deserializeFlagTarget(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..24323be96 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.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';