From a2ac0ef2135ce2db18882f2fc5426f095c0fec33 Mon Sep 17 00:00:00 2001 From: Stanley Phu Date: Wed, 7 Oct 2026 14:49:20 -0700 Subject: [PATCH] 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 bf97734c9..78320c20d 100644 --- a/README.md +++ b/README.md @@ -264,3 +264,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, + }, + ]); + }, + ); +});