Skip to content

feat(feature-flags): align with production contract, add seeding and SDK polling - #96

Merged
gjtorikian merged 3 commits into
workos:mainfrom
danielloader:feat/feature-flags-parity
Sep 3, 2026
Merged

feat(feature-flags): align with production contract, add seeding and SDK polling#96
gjtorikian merged 3 commits into
workos:mainfrom
danielloader:feat/feature-flags-parity

Conversation

@danielloader

Copy link
Copy Markdown
Contributor

Closes #95.

Feature flags were the one emulated product with no way to create data. Endpoint coverage read ✅ 4/4 because the spec's four GETs existed, but nothing could put a flag in the store, the two evaluation endpoints returned a shape no SDK deserializes, and the endpoint the Node SDK's runtime client actually polls was unimplemented. This makes the subsystem usable for local development.

Context: we are adopting WorkOS feature flagging and run our test suite and local environments against this emulator, so this is on the path to adoption rather than a cleanup.

Contract

Before After
Flag object invented type field, no owner/tags, default_value: unknown the spec's exact 11-key required set
Flag ids ff_… flag_…, as production emits
enable / disable POST only PUT (spec + SDK), POST kept as an alias
create target PUT + {value} body → 200/201 + body POST, no body → 204; PUT kept as an alias
org / user endpoints {slug, type, value, enabled}, unpaginated paginated FlagList of whole Flag objects, enabled-only
user endpoint ignored org membership inherits targets from the user's organizations, as documented
target routes no validation 400 invalid_resource_id_format, 404 for unknown flag/user/org on both

Targeting is now membership rather than assignment, matching production: the create route carries no body, so a target turns a flag on and cannot turn one off.

Seeding

New featureFlags seed key — the only way a flag can exist, since production has no create-flag endpoint:

featureFlags:
  - slug: beta-dashboard
    name: Beta Dashboard
    tags: [ui, beta]
    owner: { email: jane@acme.com, first_name: Jane, last_name: Doe }
    default_value: false
    targets:
      users: [alice@acme.com]      # joined by email
      organizations: [Acme Corp]   # joined by name

Validated before the emulator starts: pinned-id uniqueness and charset, duplicate slugs, duplicate targets, unresolvable user/organization references, and field types. A non-array targets.users reports a validation error rather than throwing a TypeError out of --validate-config.

GET /sdk/feature-flags

workos.featureFlags.createRuntimeClient() — the client behind isEnabled() and getAllFlags() — does not use either list endpoint. It polls GET /sdk/feature-flags, which is not in @workos/openapi-spec; I found it by reading the compiled SDK. Without it waitUntilReady() never resolves.

Because it is undocumented, spec-derived coverage cannot see it. The SUPPORTED.md note now calls it out explicitly so the table does not imply SDK support it would not have.

Evaluation

One rule behind all three surfaces, transcribed from the SDK's own Evaluator.evaluate(): a disabled flag is off for everyone; otherwise a matching enabled target wins; otherwise default_value.

The token claim and the user list endpoint are deliberately scoped differently — the claim covers the session's organization, the list endpoint every organization the user belongs to — which is how production scopes them. seed-feature-flags.spec.ts pins that divergence with a two-organization user rather than leaving it as an undocumented surprise.

Events

Flag payloads gain environment_id. flag.rule_updated — previously in the generated catalog but never emitted — now fires on target changes with access_type, configured_targets and previous_attributes. access_type/configured_targets are scoped to that event only, since the spec does not define them on the other three. This required a small optional context field on WorkOSEvent; flag events are the only ones that populate it so far.

Verification

Against the real @workos-inc/node@10.13.0, not only the emulator's own tests:

listUserFeatureFlags : [ 'beta-dashboard', 'new-billing' ]
getAllFlags(alice)   : { 'new-billing': true, 'beta-dashboard': true,
                         'partner-portal': false, unreleased: false }
enableFeatureFlag / addFlagTarget / removeFlagTarget : ok
removeFlagTarget(unknown user) : NotFoundException 404
change event         : unreleased false -> true

bun test: 1010 pass. One pre-existing unrelated failure, normalizeRedirectHost > rejects patterns that look plausible…, reproduces identically on unmodified main (a punycode/ICU difference in the local Node build) and is untouched here.

SUPPORTED.md regenerates clean: Feature Flags goes ✅ 4/4 · ⚠️ 1/4 · ⚠️ API only → ✅ 4/4 · ✅ 4/4 · ✅ seed featureFlags; 157 → 160 endpoints overall.

Breaking changes

Pre-1.0, but worth being explicit:

  • WorkOSFeatureFlag drops type and gains owner/tags; default_value narrows to boolean. WorkOSFlagTarget drops value and gains enabled. Callers inserting flags directly via getWorkOSStore() need updating — there was no other way to create one, so that is the whole affected population.
  • Flag ids change prefix. Anything asserting on ff_… literals will need to change.
  • The org/user evaluation endpoints change response shape. The previous shape matched no SDK, so consumers were necessarily hand-rolled.
  • POST on enable/disable and PUT on target creation still work, so callers on the old verbs are unaffected. Both are documented as emulator-only, since production rejects them.

Not addressed

Two adjacent issues found while reviewing, both pre-existing and out of scope here:

  • cursorPaginate's before cursor returns the head of the list rather than the page preceding the cursor, affecting every paginated endpoint.
  • limit/order query parameters are coerced rather than validated, so no endpoint produces the spec's 400 invalid_request_parameters.

Happy to split either into its own PR.

🤖 Generated with Claude Code

@greptile-apps

greptile-apps Bot commented Sep 1, 2026

Copy link
Copy Markdown

Greptile Summary

The PR aligns feature flags with the production-facing contract and makes them usable in seeded local environments.

  • Adds validated feature-flag seeding, target resolution, and lifecycle cleanup.
  • Aligns flag objects, route verbs, evaluation responses, token claims, events, and generated compatibility metadata.
  • Implements the polling endpoint used by the Node SDK runtime client.

Confidence Score: 5/5

The PR appears safe to merge.

No blocking failure remains.

Important Files Changed

Filename Overview
src/workos/routes/feature-flags.ts Implements shared flag evaluation, production-compatible management routes, paginated user and organization listings, and SDK polling behavior; the prior membership-status issue is fixed.
src/workos/config-validator.ts Adds comprehensive feature-flag seed validation, including safe handling of malformed top-level entries and nested target values; the prior null-entry crash is fixed.
src/workos/index.ts Adds feature-flag and target seeding after validation and resolves target references against seeded users and organizations.
src/workos/flag-context.ts Centralizes environment and actor context used by feature-flag events.
src/workos/event-bus.ts Propagates optional event context through stored events and webhook deliveries.
src/workos/entities.ts Aligns stored feature-flag, target, owner, and event-context shapes with the new contract.
src/workos/routes/users.ts Extends user deletion cleanup to remove feature-flag targets that reference the deleted user.
src/workos/routes/organizations.ts Extends organization deletion cleanup to remove feature-flag targets that reference the deleted organization.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart LR
  Seed[featureFlags seed configuration] --> Validate[Validate flags and target references]
  Validate --> Store[Shared feature flag and target state]
  Store --> Lists[User and organization list endpoints]
  Store --> Token[Access-token feature_flags claim]
  Store --> SDK[SDK polling endpoint]
  Mutations[Enable, disable, add, or remove target] --> Store
  Mutations --> Events[Flag lifecycle and rule-updated events]
Loading

Reviews (3): Last reviewed commit: "fix(feature-flags): pin Flag shape to sp..." | Re-trigger Greptile

Comment thread src/workos/routes/feature-flags.ts Outdated
Comment thread src/workos/config-validator.ts
danielloader added a commit to danielloader/emulate that referenced this pull request Sep 1, 2026
…null seed entries

Review feedback on workos#96.

`organizationIdsForUser` counted every membership regardless of status, so the user
list endpoint reported flags inherited from an organization the user had been removed
from or had not yet joined. authenticate gates organization scoping on
`status === 'active'` in three places, so those flags could never reach that user's
token claim — the list endpoint was the only surface reporting them.

The featureFlags validator dereferenced each entry before checking its shape, so a
YAML list item left empty (parsing as null) threw a raw TypeError out of
`--validate-config` instead of reporting a path-based error. The same crash exists
for `users` and `organizations` on main; left alone here as out of scope.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
danielloader and others added 3 commits September 3, 2026 14:34
…SDK polling

Feature flags were the one emulated product with no way to create data — production
has no create-flag endpoint, and the emulator had no seed key — so the subsystem was
unusable for local development regardless of endpoint coverage.

Contract:
- Flag objects carry the spec's exact key set: adds `owner` and `tags`, drops the
  non-production `type`, narrows `default_value` to boolean.
- ID prefixes `ff_`/`ff_target` become `flag_`/`flag_target`, as production emits.
- enable/disable move to PUT and target creation to POST returning 204, per the spec
  and the Node SDK. The previous verbs stay as emulator-only aliases.
- Org and user endpoints return a paginated FlagList of whole Flag objects rather than
  an ad-hoc `{slug,type,value,enabled}` shape no SDK can deserialize; the user endpoint
  inherits targets from the user's organizations, as documented.
- Adds 400 `invalid_resource_id_format` and the spec's 404s on both target routes.

Seeding:
- New `featureFlags` seed key with targets joined to users by email and organizations
  by name, validated for pinned-id uniqueness, duplicate targets and unresolvable
  references before the emulator starts.

Evaluation:
- One rule shared by the `feature_flags` token claim, the list endpoints and the poll
  endpoint, matching the SDK evaluator: disabled is off for everyone, otherwise a
  matching enabled target wins, otherwise `default_value`.

SDK polling:
- Implements `GET /sdk/feature-flags`, which the SDK runtime client polls behind
  `createRuntimeClient()`/`isEnabled()`. It is absent from the OpenAPI spec, so
  spec-derived coverage cannot surface it; without it the runtime client never leaves
  its bootstrap state.

Events:
- Flag payloads gain `environment_id`; `flag.rule_updated` is emitted on target changes
  with `access_type`, `configured_targets` and `previous_attributes`.

Verified against @workos-inc/node 10.13.0: listUserFeatureFlags,
listOrganizationFeatureFlags, enable/disable, add/removeFlagTarget, and the runtime
client's polling, local evaluation and change events all work unmodified.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…null seed entries

Review feedback on workos#96.

`organizationIdsForUser` counted every membership regardless of status, so the user
list endpoint reported flags inherited from an organization the user had been removed
from or had not yet joined. authenticate gates organization scoping on
`status === 'active'` in three places, so those flags could never reach that user's
token claim — the list endpoint was the only surface reporting them.

The featureFlags validator dereferenced each entry before checking its shape, so a
YAML list item left empty (parsing as null) threw a raw TypeError out of
`--validate-config` instead of reporting a path-based error. The same crash exists
for `users` and `organizations` on main; left alone here as out of scope.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Review follow-ups on workos#96.

The Flag key set was hand-copied into the formatter and again into a
test, so a spec change to `Flag` would drift past the conformance
harness that guards every other resource. The generated shape catalog
now owns it, envelopes included.

`flag.rule_updated` reported a placeholder actor even though the
target routes know the calling key, and it is the one flag event
emitted with a request in hand. Inventing a value the emulator can
recover contradicts the no-fabrication rule, and Vault already
resolves `updated_by` from the same key record. Its
`previous_attributes.data` likewise restated the flag's unchanged
attributes as "previous", so only the rule context is reported now.

Deleting a user or organization left its flag targets behind, where
they could never be removed over the API (the target routes 404 on
the missing resource first) and surfaced as a blank email in
`configured_targets`.

A slug needing percent-encoding seeded fine, but no route could ever
address it, and a non-string organization target was reported as an
unknown name rather than a type error.
@gjtorikian
gjtorikian force-pushed the feat/feature-flags-parity branch from c4f8c35 to c27aa36 Compare September 3, 2026 18:54
@danielloader

Copy link
Copy Markdown
Contributor Author

Wonderful fixes

@gjtorikian

Copy link
Copy Markdown
Collaborator

thanks!

@gjtorikian
gjtorikian merged commit ae19616 into workos:main Sep 3, 2026
9 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

Feature flags are not usable for local development: no way to create one, non-SDK response shapes, and the SDK's polling endpoint is unimplemented

2 participants