Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,30 @@ const { accessToken } = await workos.userManagement.authenticateWithCode({
});
```

## Feature flag runtime

The runtime client requests the v2 flag payload and evaluates rules in array order:
when a flag is off it serves `off_value`; otherwise the first matching rule wins,
then `default_value`. A matching rule can serve `false`. Conditions within a rule
must all match; `one_of` matches an exact ID for its target type.

`isEnabled(key, context, defaultValue)` keeps the same API. Unknown rule kinds and
condition operators do not match. If the selected value is not a boolean, the
caller-provided default applies (`false` when omitted). `getAllFlags` also uses
`false` for values this SDK cannot interpret.

Older APIs that return the flat v1 payload remain supported. `bootstrapFlags`
accepts either that legacy map or the complete `{ version: 2, flags: { ... } }`
envelope. Unrecognized bootstrap data is ignored and readiness resolves; polling
can populate the cache afterward. Failed polls retain the last good snapshot.

**Configuration shape change:** `getFlag()` and the `previous` / `current`
snapshots in `change` events now return v2 entries with `off_value` and `rules`,
including when the API returns v1. Code inspecting `targets` must switch to
`rules`. Keep the versioned envelope when saving v2 bootstrap data; a bare map of
v2 entries is not a bootstrap payload. Reordering rules emits a change event;
reordering IDs within the same condition does not.

## SDK Versioning

For our SDKs WorkOS follows a Semantic Versioning ([SemVer](https://semver.org/)) process where all releases will have a version X.Y.Z (like 1.0.0) pattern wherein Z would be a bug fix (e.g., 1.0.1), Y would be a minor release (1.1.0) and X would be a major release (2.0.0). We permit any breaking changes to only be released in major versions and strongly recommend reading changelogs before making any major version upgrades.
Expand Down
5 changes: 4 additions & 1 deletion src/feature-flags/evaluator.spec.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { Evaluator } from './evaluator';
import { InMemoryStore } from './in-memory-store';
import { toV2 } from './payload';
import {
EvaluationContext,
FlagPollEntry,
Expand Down Expand Up @@ -64,12 +65,14 @@ describe('Evaluator', () => {
error: jest.fn(),
};
evaluator = new Evaluator(store, logger);
store.swap({
const payload = toV2({
'enabled-flag': enabledFlag,
'disabled-flag': disabledFlag,
'targeted-flag': targetedFlag,
'default-on-flag': defaultOnFlag,
});
if (!payload) throw new Error('Invalid legacy test payload');
store.swap(payload.flags);
});

describe('isEnabled', () => {
Expand Down
77 changes: 36 additions & 41 deletions src/feature-flags/evaluator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { InMemoryStore } from './in-memory-store';
import {
EvaluationContext,
EvaluationResource,
FlagPollEntry,
FlagPollEntryV2,
RuntimeClientLogger,
} from './interfaces';

Expand Down Expand Up @@ -44,17 +44,16 @@ export class Evaluator {
// per flag.
const normalizedContext = this.normalizeContext(context);
const flags = this.store.getAll();
const result: Record<string, boolean> = {};

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(
entry: FlagPollEntry | undefined,
entry: FlagPollEntryV2 | undefined,
normalizedContext: Map<string, string>,
defaultValue: boolean,
): boolean {
Expand All @@ -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);
Comment thread
Deborah-Digges marked this conversation as resolved.
}

private servedValue(value: unknown, defaultValue: boolean): boolean {
return typeof value === 'boolean' ? value : defaultValue;
}

/**
Expand All @@ -85,6 +97,14 @@ export class Evaluator {
*/
private normalizeContext(context: EvaluationContext): Map<string, string> {
const normalized = new Map<string, string>();
if (
typeof context !== 'object' ||
context === null ||
Array.isArray(context)
) {
this.logger?.warn('Ignoring invalid evaluation context');
return normalized;
}
const record: Record<string, unknown> = context;

const legacyEntries: Array<[string, string]> = [];
Expand Down Expand Up @@ -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,
);
}
}
15 changes: 7 additions & 8 deletions src/feature-flags/in-memory-store.spec.ts
Original file line number Diff line number Diff line change
@@ -1,24 +1,23 @@
import { InMemoryStore } from './in-memory-store';
import { FlagPollEntry } from './interfaces';
import { FlagPollEntryV2 } from './interfaces';

describe('InMemoryStore', () => {
let store: InMemoryStore;

const flagA: FlagPollEntry = {
const flagA: FlagPollEntryV2 = {
slug: 'flag-a',
enabled: true,
default_value: true,
targets: { users: [], organizations: [] },
off_value: false,
rules: [],
};

const flagB: FlagPollEntry = {
const flagB: FlagPollEntryV2 = {
slug: 'flag-b',
enabled: false,
default_value: false,
targets: {
users: [{ id: 'user_123', enabled: true }],
organizations: [],
},
off_value: false,
rules: [],
};

beforeEach(() => {
Expand Down
12 changes: 6 additions & 6 deletions src/feature-flags/in-memory-store.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,17 @@
import { FlagPollEntry, FlagPollResponse } from './interfaces';
import { FlagPollEntryV2, FlagPollResponseV2 } from './interfaces';

export class InMemoryStore {
private flags: FlagPollResponse = {};
private flags: FlagPollResponseV2['flags'] = {};

swap(newFlags: FlagPollResponse): void {
swap(newFlags: FlagPollResponseV2['flags']): void {
this.flags = { ...newFlags };
}

get(slug: string): FlagPollEntry | undefined {
return this.flags[slug];
get(slug: string): FlagPollEntryV2 | undefined {
return Object.hasOwn(this.flags, slug) ? this.flags[slug] : undefined;
}

getAll(): FlagPollResponse {
getAll(): FlagPollResponseV2['flags'] {
return { ...this.flags };
}

Expand Down
6 changes: 3 additions & 3 deletions src/feature-flags/interfaces/flag-change.interface.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { FlagPollEntry } from './flag-poll-response.interface';
import { FlagPollEntryV2 } from './flag-poll-response.interface';

export interface FlagChange {
key: string;
previous: FlagPollEntry | null;
current: FlagPollEntry | null;
previous: FlagPollEntryV2 | null;
current: FlagPollEntryV2 | null;
}
34 changes: 33 additions & 1 deletion src/feature-flags/interfaces/flag-poll-response.interface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,4 +21,36 @@ export interface FlagPollEntry {
};
}

export type FlagPollResponse = Record<string, FlagPollEntry>;
export type FlagPollResponseV1 = Record<string, FlagPollEntry>;

export interface FlagConditionV2 {
// 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 FlagRuleV2 {
id: string;
kind: string;
// Future multivariate values must fall back to the caller's boolean default.
value: unknown;
conditions?: FlagConditionV2[];
}

export interface FlagPollEntryV2 {
slug: string;
enabled: boolean;
default_value: unknown;
off_value: unknown;
/** Evaluated in array order; the first matching rule wins. */
rules: FlagRuleV2[];
}

export interface FlagPollResponseV2 {
version: 2;
flags: Record<string, FlagPollEntryV2>;
}

export type FlagPollResponse = FlagPollResponseV1 | FlagPollResponseV2;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2 Response alias breaks indexing

FlagPollResponse now includes a v2 envelope without a string index signature. Existing TypeScript code that accepts this exported type and indexes it by flag slug will no longer compile. The README describes the getFlag() and change-event migration, but not this additional type change, leaving those callers without migration guidance.

Knowledge Base Used:

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/feature-flags/interfaces/flag-poll-response.interface.ts
Line: 56

Comment:
**Response alias breaks indexing**

`FlagPollResponse` now includes a v2 envelope without a string index signature. Existing TypeScript code that accepts this exported type and indexes it by flag slug will no longer compile. The README describes the `getFlag()` and change-event migration, but not this additional type change, leaving those callers without migration guidance.

**Knowledge Base Used:**
- [Feature flag evaluation](https://app.greptile.com/workos/-/custom-context/knowledge-base/workos/workos-node/-/docs/feature-flags.md)
- [SDK initialization and public entrypoints](https://app.greptile.com/workos/-/custom-context/knowledge-base/workos/workos-node/-/docs/sdk-foundation.md)

---

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

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

The type change is intentional as part of the major SDK update, but agreed that we’re missing migration guidance here. I’ll add a note to the README’s configuration migration section to use FlagPollResponseV1 for legacy maps, or FlagPollResponseV2 with .flags[slug] for v2.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

That migration note addresses the concern. Please make the distinction explicit: legacy flat maps should use FlagPollResponseV1, while v2 consumers should access entries through FlagPollResponseV2.flags[slug]; then callers can update their indexing without relying on the union’s shape.

Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -9,7 +9,8 @@ export interface RuntimeClientLogger {

export interface RuntimeClientOptions {
pollingIntervalMs?: number;
bootstrapFlags?: Record<string, FlagPollEntry>;
/** A legacy flat payload or a versioned v2 envelope, as returned by polling. */
bootstrapFlags?: FlagPollResponse;
requestTimeoutMs?: number;
logger?: RuntimeClientLogger;
}
Loading
Loading