From db3cd4f067043dd062cbb3bee355fe25a9a671bc Mon Sep 17 00:00:00 2001 From: Stanley Phu Date: Wed, 7 Oct 2026 14:49:20 -0700 Subject: [PATCH 1/4] feat(feature-flags): Handle all feature flag lifecycle events --- README.md | 43 +++ src/common/interfaces/event.interface.ts | 179 +++++++++ src/common/serializers/event.serializer.ts | 47 +++ .../feature-flag-event.serializer.ts | 73 ++++ src/events/feature-flag-events.spec.ts | 340 ++++++++++++++++++ 5 files changed, 682 insertions(+) create mode 100644 src/common/serializers/feature-flag-event.serializer.ts create mode 100644 src/events/feature-flag-events.spec.ts diff --git a/README.md b/README.md index 46bfeb60c..660c230a9 100644 --- a/README.md +++ b/README.md @@ -280,3 +280,46 @@ can move to using the stable version. - [Admin Portal Guide](https://workos.com/docs/admin-portal/guide) - [Magic Link Guide](https://workos.com/docs/magic-link/guide) - [Domain Verification Guide](https://workos.com/docs/domain-verification/guide) + +### 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`. Rule context can omit `actor` for system +changes. Target events preserve both legacy values and rule memberships, including +persisted events emitted before the membership API rollout. Historical `flag.*` +event data and context remain unchanged. + +SDK parsing support does not enable event delivery. Coordinate availability with +the feature flag event launch. Webhook endpoint subscription enums must be +regenerated after these names are published in the API specification; rule update +webhook registration is also a launch prerequisite. diff --git a/src/common/interfaces/event.interface.ts b/src/common/interfaces/event.interface.ts index 53ae0f37c..25b6b17a8 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,165 @@ 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 FeatureFlagUpdatedContext = FeatureFlagContext & { + previousAttributes?: { + data?: Partial< + Pick< + FeatureFlagEventData, + 'name' | 'description' | 'tags' | 'enabled' | 'defaultValue' + > + >; + }; +}; +export type FeatureFlagUpdatedContextResponse = FeatureFlagContextResponse & { + previous_attributes?: { + data?: Partial< + Pick< + FeatureFlagEventResponseData, + 'name' | 'description' | 'tags' | '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: FeatureFlagRuleContext; +} + +export interface FeatureFlagsFlagRuleUpdatedEventResponse extends EventResponseBase { + event: 'feature_flags.flag_rule.updated'; + data: FlagRuleResponse; + context: FeatureFlagRuleContextResponse; +} + +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 +1242,14 @@ export type Event = | OrganizationDomainDeletedEvent | ApiKeyCreatedEvent | ApiKeyRevokedEvent + | FeatureFlagsFlagCreatedEvent + | FeatureFlagsFlagUpdatedEvent + | FeatureFlagsFlagDeletedEvent + | FeatureFlagsFlagRuleCreatedEvent + | FeatureFlagsFlagRuleUpdatedEvent + | FeatureFlagsFlagRuleDeletedEvent + | FeatureFlagsFlagTargetCreatedEvent + | FeatureFlagsFlagTargetDeletedEvent | FlagCreatedEvent | FlagUpdatedEvent | FlagDeletedEvent @@ -1165,6 +1336,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..5590f5048 100644 --- a/src/common/serializers/event.serializer.ts +++ b/src/common/serializers/event.serializer.ts @@ -33,6 +33,15 @@ 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, + deserializeFeatureFlagUpdatedContext, +} from './feature-flag-event.serializer'; import { deserializeFeatureFlag } from '../../feature-flags/serializers/feature-flag.serializer'; import { deserializeGroup } from '../../groups/serializers'; import { @@ -272,6 +281,44 @@ 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.updated': + case 'feature_flags.flag_rule.deleted': + return { + ...eventBase, + event: event.event, + data: deserializeFlagRule(event.data), + context: deserializeFeatureFlagRuleContext(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..d96bfda02 --- /dev/null +++ b/src/common/serializers/feature-flag-event.serializer.ts @@ -0,0 +1,73 @@ +import { + FeatureFlagEventData, + FeatureFlagEventResponseData, + FeatureFlagRuleContext, + FeatureFlagRuleContextResponse, + FeatureFlagUpdatedContext, + FeatureFlagUpdatedContextResponse, +} from '../interfaces'; + +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: + flag.owner === null + ? null + : { + email: flag.owner.email, + firstName: flag.owner.first_name, + lastName: flag.owner.last_name, + }, + 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 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.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..bab2875a9 --- /dev/null +++ b/src/events/feature-flag-events.spec.ts @@ -0,0 +1,340 @@ +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, + }, + }, + }, + }, + }, + ...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 }), + }, + }, + })), +]; + +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, + }, + ]); + }, + ); +}); From c90dd2c3f024749117c50bd0cfa53929eaf0a3af Mon Sep 17 00:00:00 2001 From: Stanley Phu Date: Mon, 19 Oct 2026 00:15:23 -0700 Subject: [PATCH 2/4] fix(feature-flags): Keep the previous owner in flag updated events Co-Authored-By: Claude Opus 5.5 --- src/common/interfaces/event.interface.ts | 4 +- .../feature-flag-event.serializer.ts | 23 +++++--- src/events/feature-flag-events.spec.ts | 54 +++++++++++++++++++ 3 files changed, 71 insertions(+), 10 deletions(-) diff --git a/src/common/interfaces/event.interface.ts b/src/common/interfaces/event.interface.ts index 25b6b17a8..9c049553c 100644 --- a/src/common/interfaces/event.interface.ts +++ b/src/common/interfaces/event.interface.ts @@ -769,7 +769,7 @@ export type FeatureFlagUpdatedContext = FeatureFlagContext & { data?: Partial< Pick< FeatureFlagEventData, - 'name' | 'description' | 'tags' | 'enabled' | 'defaultValue' + 'name' | 'description' | 'tags' | 'owner' | 'enabled' | 'defaultValue' > >; }; @@ -779,7 +779,7 @@ export type FeatureFlagUpdatedContextResponse = FeatureFlagContextResponse & { data?: Partial< Pick< FeatureFlagEventResponseData, - 'name' | 'description' | 'tags' | 'enabled' | 'default_value' + 'name' | 'description' | 'tags' | 'owner' | 'enabled' | 'default_value' > >; }; diff --git a/src/common/serializers/feature-flag-event.serializer.ts b/src/common/serializers/feature-flag-event.serializer.ts index d96bfda02..7537d78bd 100644 --- a/src/common/serializers/feature-flag-event.serializer.ts +++ b/src/common/serializers/feature-flag-event.serializer.ts @@ -7,6 +7,17 @@ import { 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 => ({ @@ -16,14 +27,7 @@ export const deserializeFeatureFlagEventData = ( slug: flag.slug, name: flag.name, description: flag.description, - owner: - flag.owner === null - ? null - : { - email: flag.owner.email, - firstName: flag.owner.first_name, - lastName: flag.owner.last_name, - }, + owner: deserializeFeatureFlagOwner(flag.owner), tags: flag.tags, enabled: flag.enabled, defaultValue: flag.default_value, @@ -59,6 +63,9 @@ export const deserializeFeatureFlagUpdatedContext = ( ? {} : { description: data.description }), ...(data.tags === undefined ? {} : { tags: data.tags }), + ...(data.owner === undefined + ? {} + : { owner: deserializeFeatureFlagOwner(data.owner) }), ...(data.enabled === undefined ? {} : { enabled: data.enabled }), diff --git a/src/events/feature-flag-events.spec.ts b/src/events/feature-flag-events.spec.ts index bab2875a9..a02ce0131 100644 --- a/src/events/feature-flag-events.spec.ts +++ b/src/events/feature-flag-events.spec.ts @@ -174,6 +174,60 @@ const cases: EventCase[] = [ }, }, }, + { + 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 { From a24ee3e169e6f257b09d736eee3033a9d3a9f430 Mon Sep 17 00:00:00 2001 From: Stanley Phu Date: Mon, 19 Oct 2026 00:16:07 -0700 Subject: [PATCH 3/4] docs: Move feature flag events into their own README section Co-Authored-By: Claude Opus 5.5 --- README.md | 63 +++++++++++++++++++++++++------------------------------ 1 file changed, 29 insertions(+), 34 deletions(-) diff --git a/README.md b/README.md index 660c230a9..32bfa1d35 100644 --- a/README.md +++ b/README.md @@ -256,32 +256,7 @@ rules; existing integrations do not need to change. `valueType` while the environment still uses the legacy target contract. Narrow using `'ruleId' in target` before accessing the contract-specific fields. -## SDK Versioning - -For our SDKs WorkOS follows a Semantic Versioning ([SemVer](https://semver.org/)) process where all releases will have a version X.Y.Z (like 1.0.0) pattern wherein Z would be a bug fix (e.g., 1.0.1), Y would be a minor release (1.1.0) and X would be a major release (2.0.0). We permit any breaking changes to only be released in major versions and strongly recommend reading changelogs before making any major version upgrades. - -## Beta Releases - -WorkOS has features in Beta that can be accessed via Beta releases. We would love for you to try these -and share feedback with us before these features reach general availability (GA). To install a Beta version, -please follow the [installation steps](#installation) above using the Beta release version. - -> Note: there can be breaking changes between Beta versions. Therefore, we recommend pinning the package version to a -> specific version. This way you can install the same version each time without breaking changes unless you are -> intentionally looking for the latest Beta version. - -We highly recommend keeping an eye on when the Beta feature you are interested in goes from Beta to stable so that you -can move to using the stable version. - -## More Information - -- [Single Sign-On Guide](https://workos.com/docs/sso/guide) -- [Directory Sync Guide](https://workos.com/docs/directory-sync/guide) -- [Admin Portal Guide](https://workos.com/docs/admin-portal/guide) -- [Magic Link Guide](https://workos.com/docs/magic-link/guide) -- [Domain Verification Guide](https://workos.com/docs/domain-verification/guide) - -### Feature flag events +## Feature flag events The Events API and `webhooks.constructEvent` support the namespaced feature flag lifecycle events: @@ -315,11 +290,31 @@ for (const event of events) { Flag events include `environmentId`, nullable `description`, and a nullable `owner` with `email`, `firstName`, and `lastName`. Updated flag context includes optional `previousAttributes.data`. Rule context can omit `actor` for system -changes. Target events preserve both legacy values and rule memberships, including -persisted events emitted before the membership API rollout. Historical `flag.*` -event data and context remain unchanged. - -SDK parsing support does not enable event delivery. Coordinate availability with -the feature flag event launch. Webhook endpoint subscription enums must be -regenerated after these names are published in the API specification; rule update -webhook registration is also a launch prerequisite. +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. + +## Beta Releases + +WorkOS has features in Beta that can be accessed via Beta releases. We would love for you to try these +and share feedback with us before these features reach general availability (GA). To install a Beta version, +please follow the [installation steps](#installation) above using the Beta release version. + +> Note: there can be breaking changes between Beta versions. Therefore, we recommend pinning the package version to a +> specific version. This way you can install the same version each time without breaking changes unless you are +> intentionally looking for the latest Beta version. + +We highly recommend keeping an eye on when the Beta feature you are interested in goes from Beta to stable so that you +can move to using the stable version. + +## More Information + +- [Single Sign-On Guide](https://workos.com/docs/sso/guide) +- [Directory Sync Guide](https://workos.com/docs/directory-sync/guide) +- [Admin Portal Guide](https://workos.com/docs/admin-portal/guide) +- [Magic Link Guide](https://workos.com/docs/magic-link/guide) +- [Domain Verification Guide](https://workos.com/docs/domain-verification/guide) From ea2336c27f25eb8ae3c20ef23bd65103f9a302b8 Mon Sep 17 00:00:00 2001 From: Stanley Phu Date: Mon, 19 Oct 2026 00:51:38 -0700 Subject: [PATCH 4/4] fix(feature-flags): Keep the previous value in flag rule updated events Co-Authored-By: Claude Opus 5.5 --- README.md | 8 +++---- src/common/interfaces/event.interface.ts | 11 +++++++-- src/common/serializers/event.serializer.ts | 9 +++++++- .../feature-flag-event.serializer.ts | 23 +++++++++++++++++++ src/events/feature-flag-events.spec.ts | 15 ++++++++++++ 5 files changed, 59 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 32bfa1d35..ffb275054 100644 --- a/README.md +++ b/README.md @@ -289,10 +289,10 @@ for (const event of events) { Flag events include `environmentId`, nullable `description`, and a nullable `owner` with `email`, `firstName`, and `lastName`. Updated flag context includes -optional `previousAttributes.data`. 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. +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 diff --git a/src/common/interfaces/event.interface.ts b/src/common/interfaces/event.interface.ts index 9c049553c..292f892b8 100644 --- a/src/common/interfaces/event.interface.ts +++ b/src/common/interfaces/event.interface.ts @@ -764,6 +764,13 @@ 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< @@ -836,13 +843,13 @@ export interface FeatureFlagsFlagRuleCreatedEventResponse extends EventResponseB export interface FeatureFlagsFlagRuleUpdatedEvent extends EventBase { event: 'feature_flags.flag_rule.updated'; data: FlagRule; - context: FeatureFlagRuleContext; + context: FeatureFlagRuleUpdatedContext; } export interface FeatureFlagsFlagRuleUpdatedEventResponse extends EventResponseBase { event: 'feature_flags.flag_rule.updated'; data: FlagRuleResponse; - context: FeatureFlagRuleContextResponse; + context: FeatureFlagRuleUpdatedContextResponse; } export interface FeatureFlagsFlagRuleDeletedEvent extends EventBase { diff --git a/src/common/serializers/event.serializer.ts b/src/common/serializers/event.serializer.ts index 5590f5048..00991d31b 100644 --- a/src/common/serializers/event.serializer.ts +++ b/src/common/serializers/event.serializer.ts @@ -40,6 +40,7 @@ import { import { deserializeFeatureFlagEventData, deserializeFeatureFlagRuleContext, + deserializeFeatureFlagRuleUpdatedContext, deserializeFeatureFlagUpdatedContext, } from './feature-flag-event.serializer'; import { deserializeFeatureFlag } from '../../feature-flags/serializers/feature-flag.serializer'; @@ -300,7 +301,6 @@ export const deserializeEvent = (event: EventResponse): Event => { context: deserializeFeatureFlagUpdatedContext(event.context), }; case 'feature_flags.flag_rule.created': - case 'feature_flags.flag_rule.updated': case 'feature_flags.flag_rule.deleted': return { ...eventBase, @@ -308,6 +308,13 @@ export const deserializeEvent = (event: EventResponse): 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 { diff --git a/src/common/serializers/feature-flag-event.serializer.ts b/src/common/serializers/feature-flag-event.serializer.ts index 7537d78bd..778c17b6e 100644 --- a/src/common/serializers/feature-flag-event.serializer.ts +++ b/src/common/serializers/feature-flag-event.serializer.ts @@ -3,6 +3,8 @@ import { FeatureFlagEventResponseData, FeatureFlagRuleContext, FeatureFlagRuleContextResponse, + FeatureFlagRuleUpdatedContext, + FeatureFlagRuleUpdatedContextResponse, FeatureFlagUpdatedContext, FeatureFlagUpdatedContextResponse, } from '../interfaces'; @@ -42,6 +44,27 @@ export const deserializeFeatureFlagRuleContext = ( ...(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 => { diff --git a/src/events/feature-flag-events.spec.ts b/src/events/feature-flag-events.spec.ts index a02ce0131..54ca64e8a 100644 --- a/src/events/feature-flag-events.spec.ts +++ b/src/events/feature-flag-events.spec.ts @@ -296,6 +296,21 @@ const cases: EventCase[] = [ }, }, })), + ...[{ 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');