From 063a1d5b3b6c1324a2ee850e7fd82f1e45fa3ebb Mon Sep 17 00:00:00 2001 From: Daniel Loader Date: Tue, 1 Sep 2026 14:45:47 +0100 Subject: [PATCH 1/3] feat(feature-flags): align with production contract, add seeding and SDK polling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- README.md | 112 ++++++ SUPPORTED.md | 54 +-- scripts/gen-supported-lib.ts | 3 +- src/core/id.ts | 4 +- src/index.ts | 1 + src/workos/config-validator.ts | 171 +++++++++ src/workos/entities.ts | 31 +- src/workos/event-bus.ts | 4 + src/workos/flag-context.ts | 68 ++++ src/workos/helpers.ts | 29 +- src/workos/index.ts | 126 ++++++- src/workos/routes/auth.spec.ts | 37 +- src/workos/routes/feature-flags.spec.ts | 470 +++++++++++++++++++----- src/workos/routes/feature-flags.ts | 275 +++++++++----- src/workos/seed-feature-flags.spec.ts | 279 ++++++++++++++ 15 files changed, 1425 insertions(+), 239 deletions(-) create mode 100644 src/workos/flag-context.ts create mode 100644 src/workos/seed-feature-flags.spec.ts diff --git a/README.md b/README.md index e061928..9e05633 100644 --- a/README.md +++ b/README.md @@ -512,6 +512,118 @@ only registers values for authentication without creating resources. A map-form requests but has no `api_key` resource behind it, so validating one returns `{"api_key": null}` — use the array form for keys your code validates. +### Feature Flags + +Production has no create-flag endpoint — flags are made in the dashboard — so the `featureFlags` +seed key is how a flag comes into existence at all. Targets join to `users` by email and to +`organizations` by name, the same way memberships do; an entry naming neither fails at startup. + +```yaml +users: + - email: alice@acme.com + password: test123 + email_verified: true + - email: bob@acme.com + password: test123 + email_verified: true + +organizations: + - name: Acme Corp + memberships: + - email: alice@acme.com + - email: bob@acme.com + - name: Other Inc + +featureFlags: + # On for everyone: nothing matches a target, so default_value decides. + - slug: new-billing + name: New Billing + default_value: true + + # Off by default, on for exactly the listed targets. + - slug: beta-dashboard + name: Beta Dashboard + description: The rebuilt analytics dashboard + tags: [ui, beta] + owner: + email: jane@acme.com + first_name: Jane + last_name: Doe + targets: + users: [alice@acme.com] + organizations: [Acme Corp] + + # Targeted at an organization Alice is not a member of. + - slug: partner-portal + targets: + organizations: [Other Inc] + + # Built but not switched on in this environment: off for everyone, targets included. + - slug: unreleased + id: flag_01PINNEDUNRELEASED + enabled: false + default_value: true + targets: + users: [alice@acme.com] +``` + +Signing Alice in against that config gives `feature_flags: ["new-billing", "beta-dashboard"]`; +Bob gets `["new-billing", "beta-dashboard"]` too via Acme Corp, but would lose `beta-dashboard` +if the organization target were removed. `partner-portal` is off for both, and `unreleased` is +off for everyone until something enables it. + +A flag is on for a resource when it is `enabled` **and** either a target names that resource or +`default_value` is true. Targeting is additive, as it is in production: `POST +/feature-flags/{slug}/targets/{resourceId}` carries no body, so a target can turn a flag on but +never off. Flags also accept an optional `id` to pin (`flag_01ABC…`). + +Three surfaces read the result, and all three resolve through the same rule: + +- **The `feature_flags` access-token claim** — the slugs on for the signed-in user, re-resolved at + every mint (refresh grants included), so a toggle lands in the next token. Scoped to the + session's organization: a flag targeted at some _other_ org the user belongs to is not in the + claim. Omitted rather than minted as `[]` when nothing is on. +- **The list endpoints an SDK polls** — `GET /user_management/users/{id}/feature-flags` and + `GET /organizations/{id}/feature-flags`. Both return a paginated list of whole `feature_flag` + objects, and both list only the flags that are on. The user endpoint includes flags from every + organization that user is a member of, as production documents. + +```ts +const { data } = await workos.featureFlags.listUserFeatureFlags({ userId: user.id }); +const enabled = new Set(data.map((flag) => flag.slug)); +// ...or the organization-scoped equivalent +await workos.featureFlags.listOrganizationFeatureFlags({ organizationId: org.id }); +``` + +- **The SDK runtime client** — `workos.featureFlags.createRuntimeClient()` does not use either + list endpoint. It polls `GET /sdk/feature-flags`, which is **not in the WorkOS OpenAPI spec**; + the emulator implements it anyway, because without it the runtime client never leaves its + bootstrap state. The response is a bare slug-keyed map of every flag and its targets, and the + client evaluates locally, so `isEnabled` answers without a round trip: + +```ts +const flags = workos.featureFlags.createRuntimeClient({ pollingIntervalMs: 5_000 }); +await flags.waitUntilReady(); +flags.isEnabled('beta-dashboard', { userId: user.id, organizationId: org.id }); +flags.on('change', ({ key, current }) => console.log(key, current)); +``` + +All three apply the same rule — a disabled flag is off for everyone; otherwise a matching enabled +target wins; otherwise `default_value` — so for one resource they agree. They are scoped +differently on purpose, and that is the one case where they legitimately differ: the token claim +covers the user plus the session's organization, while the user list endpoint covers the user plus +_every_ organization they belong to. A user in two organizations can therefore have a flag in the +list endpoint that is absent from a token scoped to the other organization. Production scopes them +the same way. + +Toggling at runtime works too. The verbs match the spec: `PUT /feature-flags/{slug}/enable` and +`/disable`, `POST /feature-flags/{slug}/targets/{resourceId}` and its `DELETE`. The emulator also +accepts `POST` on enable/disable and `PUT` on target creation, for callers written against its +earlier shape — those aliases are **emulator-only**, and production rejects them, so do not rely +on them in code you intend to run against real WorkOS. Changes emit `flag.updated`; adding or +removing a target emits `flag.rule_updated`, carrying the flag's `access_type`, its configured +targets, and the previous rule state. + ## Widgets `POST /widgets/token` mints the session token the `@workos-inc/widgets` components authenticate diff --git a/SUPPORTED.md b/SUPPORTED.md index 106347a..3e7e9cc 100644 --- a/SUPPORTED.md +++ b/SUPPORTED.md @@ -2,7 +2,7 @@ # Supported Features -The emulator implements **157 of 212** endpoints in the WorkOS OpenAPI spec (`@workos/openapi-spec@0.59.0`) (**74.1%**). +The emulator implements **160 of 212** endpoints in the WorkOS OpenAPI spec (`@workos/openapi-spec@0.59.0`) (**75.5%**). Endpoint coverage says whether a route exists, not whether a feature is usable; for example, Directory Sync implements every endpoint the spec defines for it and is @@ -17,32 +17,32 @@ answers "can I actually emulate this?". ✅ full · ⚠️ partial · ❌ none · — not applicable -| Feature | Read | Write | Set up | Notes | -| ------------------------ | -------- | -------- | ------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Organizations | ✅ 5/5 | ✅ 6/6 | ✅ seed `organizations` | | -| User Management | ✅ 8/8 | ⚠️ 7/9 | ✅ seed `users` | Email-change confirm/send endpoints are not implemented. | -| Authentication | ⚠️ 3/4 | ⚠️ 4/5 | ⚠️ API only | All grant types are hand-written rather than generated from the spec. Refresh tokens always rotate, which is stricter than production. | -| Organization Memberships | ✅ 3/3 | ✅ 5/5 | ✅ seed `memberships` | Seeded via `memberships` nested under an organization. | -| Groups | ✅ 3/3 | ✅ 5/5 | ✅ seed `groups` | Seeded via `groups` nested under an organization. Members reference a seeded membership by email. | -| Invitations | ✅ 3/3 | ✅ 4/4 | ✅ seed `invitations` | | -| SSO | ✅ 5/5 | ✅ 3/3 | ✅ seed `connections` | Seeded connections carry `profiles`, which drive the SSO login flow. | -| Directory Sync | ✅ 6/6 | ✅ 1/1 | ❌ none | Read-only. Every spec endpoint is implemented and all `dsync.*` events are wired, but nothing can create a directory: there is no POST route and no seed key. Node callers can insert directly via `getWorkOSStore(emulator.store)`, which does emit the events. `dsync.group.user_added` / `user_removed` are never emitted — there is no group membership mutation surface. | -| Multi-Factor Auth | ✅ 2/2 | ✅ 5/5 | ⚠️ API only | TOTP codes are accepted without verifying the shared secret. | -| FGA / Authorization | ⚠️ 15/19 | ⚠️ 13/26 | ✅ seed `roles`, `permissions` | Checks and effective-permission listings honor resource-scoped role assignments and ancestor inheritance (`parent_resource_id`); group role assignments are not implemented. | -| Audit Logs | ⚠️ 3/4 | ⚠️ 3/4 | ⚠️ API only | Events are stored and queryable. Export generation is not implemented. | -| Vault | ✅ 5/5 | ⚠️ 3/6 | ⚠️ API only | Object CRUD is implemented; data-key encryption endpoints are not. | -| Feature Flags | ✅ 4/4 | ⚠️ 1/4 | ⚠️ API only | Enable/disable and targeting exist, but under different verbs than the spec (`POST /feature-flags/:slug/enable` where the spec says `PUT`), so they do not count toward coverage. | -| API Keys | ✅ 2/2 | ✅ 5/5 | ✅ seed `apiKeys` | Created and seeded keys authenticate real requests. | -| Pipes / Connected Apps | ⚠️ 2/5 | ⚠️ 4/12 | ✅ seed `connectedAccounts` | Connection CRUD and access-token minting are emulator-specific routes under `/pipes/connections`. | -| Applications | ⚠️ 4/5 | ⚠️ 4/8 | ✅ seed `connectApplications` | | -| JWT Templates | ✅ 1/1 | ✅ 1/1 | ✅ seed `jwtTemplate` | Claims render into every access token. Filters, conditionals, and loops are not supported. | -| Webhooks | ✅ 1/1 | ⚠️ 2/3 | ✅ seed `webhookEndpoints` | Delivery is fire-and-forget with a 5s timeout and no retries. Endpoints registered in a seed file do not receive events from that same seed file. | -| Events | ✅ 1/1 | — | ✅ automatic | Emitted as a side effect of every other operation. All are queryable at `GET /events`, including those with no registered webhook endpoint. | -| AuthKit Configuration | ❌ 0/2 | ⚠️ 2/3 | ⚠️ API only | Redirect URIs are accepted but not enforced against authorize requests. | -| Admin Portal | — | ✅ 1/1 | ⚠️ API only | Generates a portal link; the portal itself is not served. | -| Widgets | — | ✅ 1/1 | ⚠️ API only | Mints widget tokens and serves the private `/_widgets/ApiKeys/*` routes the org-scope `` widget calls; that surface is outside the public spec, so it is not counted here. Other widgets and `scope="user"` API keys are not implemented. | -| Radar | — | ⚠️ 1/4 | ⚠️ API only | Attempt listing only; no risk signals are computed. | -| Agents | ❌ 0/1 | ❌ 0/2 | ❌ none | Not implemented. | +| Feature | Read | Write | Set up | Notes | +| ------------------------ | -------- | -------- | ------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| Organizations | ✅ 5/5 | ✅ 6/6 | ✅ seed `organizations` | | +| User Management | ✅ 8/8 | ⚠️ 7/9 | ✅ seed `users` | Email-change confirm/send endpoints are not implemented. | +| Authentication | ⚠️ 3/4 | ⚠️ 4/5 | ⚠️ API only | All grant types are hand-written rather than generated from the spec. Refresh tokens always rotate, which is stricter than production. | +| Organization Memberships | ✅ 3/3 | ✅ 5/5 | ✅ seed `memberships` | Seeded via `memberships` nested under an organization. | +| Groups | ✅ 3/3 | ✅ 5/5 | ✅ seed `groups` | Seeded via `groups` nested under an organization. Members reference a seeded membership by email. | +| Invitations | ✅ 3/3 | ✅ 4/4 | ✅ seed `invitations` | | +| SSO | ✅ 5/5 | ✅ 3/3 | ✅ seed `connections` | Seeded connections carry `profiles`, which drive the SSO login flow. | +| Directory Sync | ✅ 6/6 | ✅ 1/1 | ❌ none | Read-only. Every spec endpoint is implemented and all `dsync.*` events are wired, but nothing can create a directory: there is no POST route and no seed key. Node callers can insert directly via `getWorkOSStore(emulator.store)`, which does emit the events. `dsync.group.user_added` / `user_removed` are never emitted — there is no group membership mutation surface. | +| Multi-Factor Auth | ✅ 2/2 | ✅ 5/5 | ⚠️ API only | TOTP codes are accepted without verifying the shared secret. | +| FGA / Authorization | ⚠️ 15/19 | ⚠️ 13/26 | ✅ seed `roles`, `permissions` | Checks and effective-permission listings honor resource-scoped role assignments and ancestor inheritance (`parent_resource_id`); group role assignments are not implemented. | +| Audit Logs | ⚠️ 3/4 | ⚠️ 3/4 | ⚠️ API only | Events are stored and queryable. Export generation is not implemented. | +| Vault | ✅ 5/5 | ⚠️ 3/6 | ⚠️ API only | Object CRUD is implemented; data-key encryption endpoints are not. | +| Feature Flags | ✅ 4/4 | ✅ 4/4 | ✅ seed `featureFlags` | Every spec endpoint is implemented at its documented verb; the emulator additionally accepts `POST` on enable/disable and `PUT` on target creation as aliases, which production rejects. Flags resolve into the `feature_flags` access-token claim, the per-user and per-organization list endpoints, and `GET /sdk/feature-flags` — the Node SDK runtime client's polling endpoint, which the spec does not define. Production has no create-flag endpoint, so flags come from the `featureFlags` seed key. | +| API Keys | ✅ 2/2 | ✅ 5/5 | ✅ seed `apiKeys` | Created and seeded keys authenticate real requests. | +| Pipes / Connected Apps | ⚠️ 2/5 | ⚠️ 4/12 | ✅ seed `connectedAccounts` | Connection CRUD and access-token minting are emulator-specific routes under `/pipes/connections`. | +| Applications | ⚠️ 4/5 | ⚠️ 4/8 | ✅ seed `connectApplications` | | +| JWT Templates | ✅ 1/1 | ✅ 1/1 | ✅ seed `jwtTemplate` | Claims render into every access token. Filters, conditionals, and loops are not supported. | +| Webhooks | ✅ 1/1 | ⚠️ 2/3 | ✅ seed `webhookEndpoints` | Delivery is fire-and-forget with a 5s timeout and no retries. Endpoints registered in a seed file do not receive events from that same seed file. | +| Events | ✅ 1/1 | — | ✅ automatic | Emitted as a side effect of every other operation. All are queryable at `GET /events`, including those with no registered webhook endpoint. | +| AuthKit Configuration | ❌ 0/2 | ⚠️ 2/3 | ⚠️ API only | Redirect URIs are accepted but not enforced against authorize requests. | +| Admin Portal | — | ✅ 1/1 | ⚠️ API only | Generates a portal link; the portal itself is not served. | +| Widgets | — | ✅ 1/1 | ⚠️ API only | Mints widget tokens and serves the private `/_widgets/ApiKeys/*` routes the org-scope `` widget calls; that surface is outside the public spec, so it is not counted here. Other widgets and `scope="user"` API keys are not implemented. | +| Radar | — | ⚠️ 1/4 | ⚠️ API only | Attempt listing only; no risk signals are computed. | +| Agents | ❌ 0/1 | ❌ 0/2 | ❌ none | Not implemented. | ## How this file is generated diff --git a/scripts/gen-supported-lib.ts b/scripts/gen-supported-lib.ts index ba03d0a..c7df38c 100644 --- a/scripts/gen-supported-lib.ts +++ b/scripts/gen-supported-lib.ts @@ -175,8 +175,9 @@ export const FEATURES: FeatureDef[] = [ 'organizations.feature-flags', 'user-management.users.feature-flags', ], + seedKeys: ['featureFlags'], notes: - 'Enable/disable and targeting exist, but under different verbs than the spec (`POST /feature-flags/:slug/enable` where the spec says `PUT`), so they do not count toward coverage.', + "Every spec endpoint is implemented at its documented verb; the emulator additionally accepts `POST` on enable/disable and `PUT` on target creation as aliases, which production rejects. Flags resolve into the `feature_flags` access-token claim, the per-user and per-organization list endpoints, and `GET /sdk/feature-flags` — the Node SDK runtime client's polling endpoint, which the spec does not define. Production has no create-flag endpoint, so flags come from the `featureFlags` seed key.", }, { name: 'API Keys', diff --git a/src/core/id.ts b/src/core/id.ts index 9857443..0afb913 100644 --- a/src/core/id.ts +++ b/src/core/id.ts @@ -82,8 +82,8 @@ export const ID_PREFIXES = { audit_log_action: 'audit_action', audit_log_event: 'audit_event', audit_log_export: 'audit_export', - feature_flag: 'ff', - flag_target: 'ff_target', + feature_flag: 'flag', + flag_target: 'flag_target', connect_application: 'connect_app', client_secret: 'client_secret', data_integration_auth: 'di_auth', diff --git a/src/index.ts b/src/index.ts index 4f0bbf6..1726245 100644 --- a/src/index.ts +++ b/src/index.ts @@ -39,6 +39,7 @@ export interface EmulatorSeedConfig { webhookEndpoints?: WorkOSSeedConfig['webhookEndpoints']; connectApplications?: WorkOSSeedConfig['connectApplications']; jwtTemplate?: WorkOSSeedConfig['jwtTemplate']; + featureFlags?: WorkOSSeedConfig['featureFlags']; errorHooks?: ErrorHookSeedConfig[]; } diff --git a/src/workos/config-validator.ts b/src/workos/config-validator.ts index d12102f..1377df1 100644 --- a/src/workos/config-validator.ts +++ b/src/workos/config-validator.ts @@ -802,6 +802,177 @@ export function validateSeedConfig(config: WorkOSSeedConfig): ConfigValidationRe }); } + // Validate feature flags. Seeding is the only way a flag exists at all — production has no + // create-flag endpoint — so an unresolvable target here is a flag that silently reaches + // nobody, which is exactly the bug a test suite would blame on its own code. + if (config.featureFlags) { + if (!Array.isArray(config.featureFlags)) { + errors.push({ + path: 'featureFlags', + message: 'featureFlags must be an array', + value: config.featureFlags, + }); + } else { + const orgNames = new Set( + Array.isArray(config.organizations) + ? config.organizations.map((o) => o.name).filter((n): n is string => typeof n === 'string') + : [], + ); + const seenSlugs = new Set(); + // A pinned flag id is the primary key in the store; two flags sharing one would silently + // overwrite each other on insert while both slugs stay in the slug index, so a lookup by + // the losing slug would resolve to the surviving flag's values. + const seenFlagIds = new Set(); + + config.featureFlags.forEach((flag, index) => { + const at = (field: string) => `featureFlags[${index}].${field}`; + + if (flag.id !== undefined) { + if (typeof flag.id !== 'string' || !PINNED_ID_PATTERN.test(flag.id)) { + errors.push({ + path: at('id'), + message: 'id must be a string of letters, numbers, hyphens or underscores if provided', + value: flag.id, + }); + } else if (seenFlagIds.has(flag.id)) { + errors.push({ path: at('id'), message: 'id must be unique across featureFlags', value: flag.id }); + } else { + seenFlagIds.add(flag.id); + } + } + + if (!flag.slug || typeof flag.slug !== 'string') { + errors.push({ path: at('slug'), message: 'slug is required and must be a string', value: flag.slug }); + } else if (seenSlugs.has(flag.slug)) { + // Every lookup is by slug, so a duplicate is a flag no route can ever resolve. + errors.push({ path: at('slug'), message: 'slug must be unique across featureFlags', value: flag.slug }); + } else { + seenSlugs.add(flag.slug); + } + + for (const field of ['enabled', 'default_value'] as const) { + if (flag[field] !== undefined && typeof flag[field] !== 'boolean') { + errors.push({ path: at(field), message: `${field} must be a boolean if provided`, value: flag[field] }); + } + } + + if (flag.tags !== undefined && (!Array.isArray(flag.tags) || !flag.tags.every((t) => typeof t === 'string'))) { + errors.push({ path: at('tags'), message: 'tags must be an array of strings if provided', value: flag.tags }); + } + + // `name` and `description` are passed through to the spec's Flag object verbatim, where + // both are typed. A YAML scalar that happens to parse as a number would otherwise be + // served as a type the real API never sends. + if (flag.name !== undefined && typeof flag.name !== 'string') { + errors.push({ path: at('name'), message: 'name must be a string if provided', value: flag.name }); + } + if (flag.description !== undefined && flag.description !== null && typeof flag.description !== 'string') { + errors.push({ + path: at('description'), + message: 'description must be a string or null if provided', + value: flag.description, + }); + } + + if (flag.owner !== undefined && flag.owner !== null) { + const ownerEmail = seedEmail(flag.owner.email); + if (!ownerEmail.ok) { + errors.push({ + path: at('owner.email'), + message: + ownerEmail.problem === 'malformed' + ? 'owner.email must be a valid email address' + : 'owner.email is required and must be a string', + value: flag.owner.email, + }); + } + for (const part of ['first_name', 'last_name'] as const) { + const value = flag.owner[part]; + if (value !== undefined && value !== null && typeof value !== 'string') { + errors.push({ + path: at(`owner.${part}`), + message: `owner.${part} must be a string or null if provided`, + value, + }); + } + } + } + + const targets = flag.targets; + if (targets !== undefined && (typeof targets !== 'object' || targets === null || Array.isArray(targets))) { + errors.push({ path: at('targets'), message: 'targets must be an object if provided', value: targets }); + return; + } + + // Guarded before iterating: a scalar under `targets.users` would otherwise throw a raw + // TypeError out of validateSeedConfig instead of reporting a validation error. + for (const field of ['users', 'organizations'] as const) { + const value = targets?.[field]; + if (value !== undefined && !Array.isArray(value)) { + errors.push({ + path: at(`targets.${field}`), + message: `targets.${field} must be an array if provided`, + value, + }); + } + } + + const targetUsers = Array.isArray(targets?.users) ? targets.users : []; + const targetOrgs = Array.isArray(targets?.organizations) ? targets.organizations : []; + + const seenTargetUsers = new Set(); + targetUsers.forEach((email, i) => { + const parsed = seedEmail(email); + if (!parsed.ok) { + errors.push({ + path: at(`targets.users[${i}]`), + message: 'targets.users entries must be email addresses of users defined in `users`', + value: email, + }); + return; + } + const normalized = parsed.email.toLowerCase(); + if (!userEmails.has(normalized)) { + errors.push({ + path: at(`targets.users[${i}]`), + message: 'targets.users references an email not defined in `users`', + value: email, + }); + } else if (seenTargetUsers.has(normalized)) { + // Two rows for one resource: removing the target over the API deletes only the + // first match, so the flag would stay on for a user the caller just untargeted. + errors.push({ + path: at(`targets.users[${i}]`), + message: 'targets.users lists the same user twice', + value: email, + }); + } else { + seenTargetUsers.add(normalized); + } + }); + + const seenTargetOrgs = new Set(); + targetOrgs.forEach((name, i) => { + if (!orgNames.has(name)) { + errors.push({ + path: at(`targets.organizations[${i}]`), + message: 'targets.organizations references a name not defined in `organizations`', + value: name, + }); + } else if (seenTargetOrgs.has(name)) { + errors.push({ + path: at(`targets.organizations[${i}]`), + message: 'targets.organizations lists the same organization twice', + value: name, + }); + } else { + seenTargetOrgs.add(name); + } + }); + }); + } + } + // Validating the template here means `--validate-config` catches a broken one, rather // than leaving it to fail at the first sign-in. if (config.jwtTemplate !== undefined) { diff --git a/src/workos/entities.ts b/src/workos/entities.ts index 46daf7b..a96e5ee 100644 --- a/src/workos/entities.ts +++ b/src/workos/entities.ts @@ -418,22 +418,40 @@ export interface WorkOSAuditLogExport extends Entity { filters: Record; } +export interface WorkOSFeatureFlagOwner { + email: string; + first_name: string | null; + last_name: string | null; +} + export interface WorkOSFeatureFlag extends Entity { object: 'feature_flag'; slug: string; name: string; description: string | null; - type: 'boolean' | 'string' | 'number'; - default_value: unknown; + owner: WorkOSFeatureFlagOwner | null; + tags: string[]; enabled: boolean; + /** Value returned for resources matching no target. Production flags are boolean-only. */ + default_value: boolean; } +/** + * A resource the flag is switched on for. Production targeting is membership, not assignment: + * `POST /feature-flags/{slug}/targets/{resourceId}` takes no body, so a target's mere existence + * means "on for this resource" and there is no value to store or to turn a flag back off with. + */ export interface WorkOSFlagTarget extends Entity { object: 'flag_target'; flag_slug: string; resource_id: string; - resource_type: string; - value: unknown; + resource_type: 'user' | 'organization'; + /** + * Reported to the SDK runtime client, whose evaluator skips a target unless this is true. + * Always true for targets created over the API — the create route carries no body — but + * the field is real on the wire, so it is stored rather than hardcoded at serialization. + */ + enabled: boolean; } export interface WorkOSConnectApplication extends Entity { @@ -517,6 +535,11 @@ export interface WorkOSEvent extends Entity { event: string; data: Record; environment_id: string | null; + /** + * The spec's per-event `context` envelope. Only flag events populate it so far — the + * emulator has no actor model for the rest — so it is omitted rather than faked elsewhere. + */ + context?: Record; } export interface WorkOSWebhookEndpoint extends Entity { diff --git a/src/workos/event-bus.ts b/src/workos/event-bus.ts index e8cbb87..05954d8 100644 --- a/src/workos/event-bus.ts +++ b/src/workos/event-bus.ts @@ -8,6 +8,8 @@ export interface EventPayload { event: WorkOSEventName | string; data: Record; environment_id?: string; + /** Spec `context` envelope, delivered alongside `data` to webhook endpoints. */ + context?: Record; } export interface WebhookRetryConfig { @@ -71,6 +73,7 @@ export class EventBus { event: payload.event, data: payload.data, environment_id: payload.environment_id ?? null, + ...(payload.context ? { context: payload.context } : {}), }); // Pre-filtered: only endpoints that care about this event @@ -92,6 +95,7 @@ export class EventBus { event: event.event, data: event.data, created_at: event.created_at, + ...(event.context ? { context: event.context } : {}), }); const signature = signWebhookPayload(body, endpoint.secret); diff --git a/src/workos/flag-context.ts b/src/workos/flag-context.ts new file mode 100644 index 0000000..e629768 --- /dev/null +++ b/src/workos/flag-context.ts @@ -0,0 +1,68 @@ +import type { WorkOSFeatureFlag } from './entities.js'; +import type { WorkOSStore } from './store.js'; + +/** Same `environment_` convention Vault objects are scoped by. */ +export function environmentIdFor(environment?: string): string { + return `environment_${environment ?? 'test'}`; +} + +/** + * `access_type` summarises a flag's reach in one word, the way the dashboard shows it: + * `all` when the default value carries every resource, `some` when only targets are on, + * `none` when the flag reaches nobody (disabled, or enabled with neither). + */ +export function flagAccessType(ws: WorkOSStore, flag: WorkOSFeatureFlag): 'none' | 'some' | 'all' { + if (!flag.enabled) return 'none'; + if (flag.default_value) return 'all'; + return ws.flagTargets.findBy('flag_slug', flag.slug).length > 0 ? 'some' : 'none'; +} + +/** + * The base `context` every flag webhook carries. `client_id` is the emulator's standing + * placeholder — it has no environment/client identity to report — and the actor is always + * the API surface, since the emulator serves no dashboard or admin portal. + */ +export function flagEventContext(): Record { + return { + client_id: 'workos-emulate', + actor: { id: 'api_key_emulator', source: 'api', name: 'Emulator API key' }, + }; +} + +/** + * The targeting half of a `flag.rule_updated` context. Only that event defines `access_type` + * and `configured_targets`, so the other three flag events must not carry them. + */ +export function flagRuleState(ws: WorkOSStore, flag: WorkOSFeatureFlag): Record { + const targets = ws.flagTargets.findBy('flag_slug', flag.slug); + return { + access_type: flagAccessType(ws, flag), + configured_targets: { + organizations: targets + .filter((t) => t.resource_type === 'organization') + .map((t) => ({ id: t.resource_id, name: ws.organizations.get(t.resource_id)?.name ?? t.resource_id })), + users: targets + .filter((t) => t.resource_type === 'user') + .map((t) => ({ id: t.resource_id, email: ws.users.get(t.resource_id)?.email ?? '' })), + }, + }; +} + +/** + * The full `flag.rule_updated` context. `previous_attributes` is required on this event, so + * the caller has to capture `flagRuleState` before mutating the targets and hand it back here. + */ +export function flagRuleUpdatedContext( + ws: WorkOSStore, + flag: WorkOSFeatureFlag, + previous: Record, +): Record { + return { + ...flagEventContext(), + ...flagRuleState(ws, flag), + previous_attributes: { + data: { enabled: flag.enabled, default_value: flag.default_value }, + context: previous, + }, + }; +} diff --git a/src/workos/helpers.ts b/src/workos/helpers.ts index 47fb4be..d0f0924 100644 --- a/src/workos/helpers.ts +++ b/src/workos/helpers.ts @@ -48,7 +48,6 @@ import type { WorkOSAuditLogEvent, WorkOSAuditLogExport, WorkOSFeatureFlag, - WorkOSFlagTarget, WorkOSConnectApplication, WorkOSClientSecret, WorkOSRadarAttempt, @@ -976,12 +975,34 @@ export function formatAuditLogExport(ex: WorkOSAuditLogExport): Record { - return formatEntity(f); + return { + object: 'feature_flag', + id: f.id, + slug: f.slug, + name: f.name, + description: f.description, + owner: f.owner, + tags: f.tags, + enabled: f.enabled, + default_value: f.default_value, + created_at: f.created_at, + updated_at: f.updated_at, + }; } -export function formatFlagTarget(t: WorkOSFlagTarget): Record { - return formatEntity(t); +/** + * Flag webhook payloads are the REST `Flag` plus `environment_id`, which the REST object + * itself does not carry. + */ +export function formatFeatureFlagEvent(f: WorkOSFeatureFlag, environmentId: string): Record { + const { object, id, ...rest } = formatFeatureFlag(f); + return { object, id, environment_id: environmentId, ...rest }; } /** Generate a Connect Application client_id, e.g. `client_01HXYZ...`. */ diff --git a/src/workos/index.ts b/src/workos/index.ts index 4a09dfc..192b806 100644 --- a/src/workos/index.ts +++ b/src/workos/index.ts @@ -44,6 +44,7 @@ import { EventBus } from './event-bus.js'; import { STORE_KEYS, EVENTS } from './constants.js'; import { validateSeedConfig, formatValidationErrors } from './config-validator.js'; import { validateJwtTemplateContent } from './jwt-template.js'; +import { environmentIdFor, flagEventContext } from './flag-context.js'; import { generateVerificationToken, hashPassword, @@ -66,6 +67,7 @@ import { formatPasswordReset, formatApiKeyRecord, formatFeatureFlag, + formatFeatureFlagEvent, generateClientId, findUserByEmail, formatConnectedAccountEvent, @@ -298,6 +300,43 @@ export interface WorkOSSeedApiKey { /** Legacy auth allow-list: maps a raw API key value to its environment. */ export type WorkOSSeedApiKeyAuthMap = Record; +export interface WorkOSSeedFeatureFlag { + /** + * Pinned flag id (e.g. `flag_01ABC…`). Generated if omitted. Pin it to match what your + * real WorkOS environment emits, so an application storing flag ids lines up across + * emulator restarts. + */ + id?: string; + /** The key application code references. Required, and unique within the environment. */ + slug: string; + /** Display name. Defaults to the slug. */ + name?: string; + description?: string | null; + /** Whether the flag is active in this environment at all. Defaults to true. */ + enabled?: boolean; + /** + * Value for users and organizations matching no target — the flag's "on for everyone" + * switch. Defaults to false, so a flag with targets is on for exactly those targets. + */ + default_value?: boolean; + /** Labels the dashboard filters by. Purely descriptive. */ + tags?: string[]; + owner?: { email: string; first_name?: string | null; last_name?: string | null }; + /** + * Resources the flag is switched on for regardless of `default_value`. Targeting is + * additive, as it is in production: a target can turn a flag on but never off. + */ + targets?: { + /** + * Emails of users defined in `users`. Seeded user ids are generated at startup, so + * targets are joined by email — an id literal could never resolve. + */ + users?: string[]; + /** Names of organizations defined in `organizations`, joined the same way. */ + organizations?: string[]; + }; +} + export interface WorkOSSeedJwtTemplate { /** * Template string rendering to a JSON object of claims, e.g. @@ -330,6 +369,11 @@ export interface WorkOSSeedConfig { * emulator mints. Seeding it means a test suite gets custom claims without a setup call. */ jwtTemplate?: WorkOSSeedJwtTemplate; + /** + * Feature flags and their targets. Production has no create-flag endpoint — flags are + * made in the dashboard — so seeding is the only way to get one into the emulator. + */ + featureFlags?: WorkOSSeedFeatureFlag[]; } export function seedFromConfig(store: Store, _baseUrl: string, config: WorkOSSeedConfig): void { @@ -714,6 +758,67 @@ export function seedFromConfig(store: Store, _baseUrl: string, config: WorkOSSee store.setData(STORE_KEYS.apiKeyMap, authMap); } + // Seeded last: targets join to users by email and organizations by name, so both must + // already be in the store. + if (config.featureFlags) { + for (const flagConfig of config.featureFlags) { + const flag = ws.featureFlags.insert({ + object: 'feature_flag', + id: flagConfig.id, + slug: flagConfig.slug, + name: flagConfig.name ?? flagConfig.slug, + description: flagConfig.description ?? null, + owner: flagConfig.owner + ? { + // Trimmed, as seeded user emails are: the validator cross-references the trimmed + // form, so storing the padded one would serve an address it never checked. + email: flagConfig.owner.email.trim(), + first_name: flagConfig.owner.first_name ?? null, + last_name: flagConfig.owner.last_name ?? null, + } + : null, + tags: flagConfig.tags ?? [], + enabled: flagConfig.enabled !== false, + default_value: flagConfig.default_value ?? false, + }); + + // Deduped the way the create-target route is: two rows for one resource would leave the + // flag on for a resource whose target was removed, since DELETE drops only the first match. + const targeted = new Set(); + const addTarget = (resourceId: string, resourceType: 'user' | 'organization') => { + if (targeted.has(resourceId)) return; + targeted.add(resourceId); + ws.flagTargets.insert({ + object: 'flag_target', + flag_slug: flag.slug, + resource_id: resourceId, + resource_type: resourceType, + enabled: true, + }); + }; + + for (const email of flagConfig.targets?.users ?? []) { + const user = findUserByEmail(ws, email); + if (!user) { + throw new Error( + `workos seed config: featureFlags[${JSON.stringify(flagConfig.slug)}].targets.users not found: ${JSON.stringify(email)}`, + ); + } + addTarget(user.id, 'user'); + } + + for (const name of flagConfig.targets?.organizations ?? []) { + const org = ws.organizations.findOneBy('name', name); + if (!org) { + throw new Error( + `workos seed config: featureFlags[${JSON.stringify(flagConfig.slug)}].targets.organizations not found: ${JSON.stringify(name)}`, + ); + } + addTarget(org.id, 'organization'); + } + } + } + if (config.jwtTemplate) { const problems = validateJwtTemplateContent(config.jwtTemplate.content); if (problems.length > 0) { @@ -922,10 +1027,25 @@ export const workosPlugin: ServicePlugin = { onUpdate: (k) => eventBus.emit({ event: EVENTS.apiKeyUpdated, data: formatApiKeyRecord(k) }), onDelete: (k) => eventBus.emit({ event: EVENTS.apiKeyRevoked, data: formatApiKeyRecord(k) }), }); + // Flag webhook payloads carry environment_id, which the REST Flag object does not. Flags + // are not environment-scoped in the store, so every flag event reports the same default + // environment — including flag.rule_updated, emitted from the target routes. + // + // The context here is the base envelope only: access_type and configured_targets are + // defined on flag.rule_updated alone, so created/updated/deleted must not carry them. + const flagEvent = (event: string) => (f: Parameters[0]) => { + const environmentId = environmentIdFor(); + eventBus.emit({ + event, + data: formatFeatureFlagEvent(f, environmentId), + environment_id: environmentId, + context: flagEventContext(), + }); + }; ws.featureFlags.setHooks({ - onInsert: (f) => eventBus.emit({ event: EVENTS.flagCreated, data: formatFeatureFlag(f) }), - onUpdate: (f) => eventBus.emit({ event: EVENTS.flagUpdated, data: formatFeatureFlag(f) }), - onDelete: (f) => eventBus.emit({ event: EVENTS.flagDeleted, data: formatFeatureFlag(f) }), + onInsert: flagEvent(EVENTS.flagCreated), + onUpdate: flagEvent(EVENTS.flagUpdated), + onDelete: flagEvent(EVENTS.flagDeleted), }); ws.webhookEndpoints.setHooks({ onInsert: () => eventBus.rebuildIndex(), diff --git a/src/workos/routes/auth.spec.ts b/src/workos/routes/auth.spec.ts index 266bb2b..fd99178 100644 --- a/src/workos/routes/auth.spec.ts +++ b/src/workos/routes/auth.spec.ts @@ -97,28 +97,26 @@ describe('Auth routes', () => { return org; } - function createFlag( - slug: string, - opts?: { enabled?: boolean; type?: 'boolean' | 'string' | 'number'; default_value?: unknown }, - ) { + function createFlag(slug: string, opts?: { enabled?: boolean; default_value?: boolean }) { return getWorkOSStore(store).featureFlags.insert({ object: 'feature_flag', slug, name: slug, description: null, - type: opts?.type ?? 'boolean', + owner: null, + tags: [], default_value: opts?.default_value ?? true, enabled: opts?.enabled ?? true, }); } - function targetFlag(slug: string, resourceId: string, value: unknown, resourceType = 'user') { + function targetFlag(slug: string, resourceId: string, resourceType: 'user' | 'organization' = 'user') { return getWorkOSStore(store).flagTargets.insert({ object: 'flag_target', flag_slug: slug, resource_id: resourceId, resource_type: resourceType, - value, + enabled: true, }); } @@ -1551,13 +1549,14 @@ describe('Auth routes', () => { expect(decodeJwt((await json(refreshRes)).access_token).entitlements).toEqual(['audit-logs']); }); - it('mints feature_flags from flags resolving strictly true for the user', async () => { + it('mints feature_flags from flags resolving on for the user', async () => { const user = await createUser('flags@test.com'); createFlag('on-by-default'); createFlag('switched-off', { enabled: false }); - createFlag('targeted-on', { enabled: false }); - targetFlag('targeted-on', user.id, true); - createFlag('typed', { type: 'string', default_value: 'variant-a' }); + // Enabled but off by default: only the user target switches it on. + createFlag('targeted-on', { default_value: false }); + targetFlag('targeted-on', user.id); + createFlag('untargeted', { default_value: false }); const authRes = await app.request( '/user_management/authorize?redirect_uri=http://localhost:3000/callback&response_type=code&login_hint=flags@test.com&client_id=test_client', @@ -1570,17 +1569,19 @@ describe('Auth routes', () => { }); expect(tokenRes.status).toBe(200); const body = await json(tokenRes); - // Enabled default and true user target are in; disabled and non-boolean flags are not. + // Enabled default and the user target are in; a disabled flag and an untargeted + // default-false flag are not. expect(decodeJwt(body.access_token).feature_flags!.sort()).toEqual(['on-by-default', 'targeted-on']); }); - it('resolves org-targeted flags for org-scoped sessions, with user targets winning', async () => { + it('resolves org-targeted flags for org-scoped sessions', async () => { const user = await createUser('org-flags@test.com'); const org = joinOrg(user.id, 'Flag Corp'); - createFlag('org-flag', { enabled: false }); - targetFlag('org-flag', org.id, true, 'organization'); - createFlag('user-off'); - targetFlag('user-off', user.id, false); + createFlag('org-flag', { default_value: false }); + targetFlag('org-flag', org.id, 'organization'); + // Targeted at some other organization, so this session never sees it. + createFlag('other-org-flag', { default_value: false }); + targetFlag('other-org-flag', 'org_elsewhere', 'organization'); const authRes = await app.request( '/user_management/authorize?redirect_uri=http://localhost:3000/callback&response_type=code&login_hint=org-flags@test.com&client_id=test_client', @@ -1595,7 +1596,7 @@ describe('Auth routes', () => { const body = await json(tokenRes); const flags = decodeJwt(body.access_token).feature_flags!; expect(flags).toContain('org-flag'); - expect(flags).not.toContain('user-off'); + expect(flags).not.toContain('other-org-flag'); }); it('re-resolves feature_flags on refresh and omits the claim when nothing is on', async () => { diff --git a/src/workos/routes/feature-flags.spec.ts b/src/workos/routes/feature-flags.spec.ts index 81b29ef..d7aa4fa 100644 --- a/src/workos/routes/feature-flags.spec.ts +++ b/src/workos/routes/feature-flags.spec.ts @@ -6,6 +6,21 @@ import { getWorkOSStore } from '../store.js'; const apiKeys: ApiKeyMap = { sk_test_org: { environment: 'test' } }; const headers = { Authorization: 'Bearer sk_test_org', 'Content-Type': 'application/json' }; +/** Every key the spec marks required on a `Flag`. A strict SDK deserializer faults on any omission. */ +const FLAG_KEYS = [ + 'object', + 'id', + 'slug', + 'name', + 'description', + 'owner', + 'tags', + 'enabled', + 'default_value', + 'created_at', + 'updated_at', +]; + function createTestApp() { return createServer(workosPlugin, { port: 0, baseUrl: 'http://localhost:0', apiKeys }); } @@ -23,117 +38,400 @@ describe('Feature Flags routes', () => { const req = (path: string, init?: RequestInit) => app.request(path, { headers, ...init }); const json = (res: Response) => res.json() as Promise; - function seedFlag(slug = 'dark-mode', enabled = true) { - const ws = getWorkOSStore(store); - return ws.featureFlags.insert({ + function seedFlag(slug = 'dark-mode', opts?: { enabled?: boolean; default_value?: boolean }) { + return getWorkOSStore(store).featureFlags.insert({ object: 'feature_flag', slug, name: 'Dark Mode', description: 'Enable dark mode', - type: 'boolean', - default_value: true, - enabled, + owner: null, + tags: [], + enabled: opts?.enabled ?? true, + default_value: opts?.default_value ?? true, }); } - it('lists feature flags', async () => { - seedFlag(); - const res = await req('/feature-flags'); - expect(res.status).toBe(200); - const list = await json(res); - expect(list.object).toBe('list'); - expect(list.data).toHaveLength(1); - expect(list.data[0].slug).toBe('dark-mode'); - }); + function seedUser(email = 'flag@test.com') { + return getWorkOSStore(store).users.insert({ + object: 'user', + email, + name: null, + first_name: null, + last_name: null, + email_verified: true, + profile_picture_url: null, + last_sign_in_at: null, + external_id: null, + metadata: {}, + locale: null, + password_hash: null, + impersonator: null, + oauth_provider: null, + }); + } - it('gets a flag by slug', async () => { - seedFlag(); - const res = await req('/feature-flags/dark-mode'); - expect(res.status).toBe(200); - const flag = await json(res); - expect(flag.slug).toBe('dark-mode'); - expect(flag.enabled).toBe(true); - }); + function seedMembership(organizationId: string, userId: string) { + return getWorkOSStore(store).organizationMemberships.insert({ + object: 'organization_membership', + organization_id: organizationId, + user_id: userId, + role: { slug: 'member' }, + status: 'active', + external_id: null, + metadata: {}, + }); + } - it('returns 404 for nonexistent flag', async () => { - const res = await req('/feature-flags/nonexistent'); - expect(res.status).toBe(404); - }); + function seedOrg(name = 'Flag Corp') { + return getWorkOSStore(store).organizations.insert({ + object: 'organization', + name, + allow_profiles_outside_organization: false, + external_id: null, + metadata: {}, + entitlements: [], + stripe_customer_id: null, + }); + } - it('enables a flag', async () => { - seedFlag('test-flag', false); - const res = await req('/feature-flags/test-flag/enable', { method: 'POST' }); - expect(res.status).toBe(200); - expect((await json(res)).enabled).toBe(true); - }); + describe('flag objects', () => { + it('lists feature flags with the documented shape', async () => { + seedFlag(); + const res = await req('/feature-flags'); + expect(res.status).toBe(200); + const list = await json(res); + expect(list.object).toBe('list'); + expect(list.list_metadata).toEqual({ before: null, after: null }); + expect(list.data).toHaveLength(1); + expect(Object.keys(list.data[0]).sort()).toEqual([...FLAG_KEYS].sort()); + expect(list.data[0].object).toBe('feature_flag'); + expect(list.data[0].id).toStartWith('flag_'); + }); - it('disables a flag', async () => { - seedFlag('test-flag', true); - const res = await req('/feature-flags/test-flag/disable', { method: 'POST' }); - expect(res.status).toBe(200); - expect((await json(res)).enabled).toBe(false); + it('gets a flag by slug', async () => { + seedFlag(); + const res = await req('/feature-flags/dark-mode'); + expect(res.status).toBe(200); + expect(await json(res)).toMatchObject({ slug: 'dark-mode', enabled: true, tags: [], owner: null }); + }); + + it('returns 404 for a nonexistent flag', async () => { + expect((await req('/feature-flags/nonexistent')).status).toBe(404); + }); }); - it('adds and removes a target', async () => { - seedFlag(); + describe('enable / disable', () => { + // PUT is the spec's verb; POST is kept as an alias for callers written against the + // emulator's earlier shape. + for (const method of ['PUT', 'POST']) { + it(`enables a flag via ${method}`, async () => { + seedFlag('test-flag', { enabled: false }); + const res = await req('/feature-flags/test-flag/enable', { method }); + expect(res.status).toBe(200); + expect((await json(res)).enabled).toBe(true); + }); - // Add target - const addRes = await req('/feature-flags/dark-mode/targets/user_123', { - method: 'PUT', - body: JSON.stringify({ value: false, resource_type: 'user' }), + it(`disables a flag via ${method}`, async () => { + seedFlag('test-flag'); + const res = await req('/feature-flags/test-flag/disable', { method }); + expect(res.status).toBe(200); + expect((await json(res)).enabled).toBe(false); + }); + } + + it('returns 404 enabling a nonexistent flag', async () => { + expect((await req('/feature-flags/nope/enable', { method: 'PUT' })).status).toBe(404); }); - expect(addRes.status).toBe(201); - const target = await json(addRes); - expect(target.resource_id).toBe('user_123'); - expect(target.value).toBe(false); - // Update target - const updateRes = await req('/feature-flags/dark-mode/targets/user_123', { - method: 'PUT', - body: JSON.stringify({ value: true }), + it('does not emit flag.updated when the flag is already in the requested state', async () => { + seedFlag('test-flag'); + await req('/feature-flags/test-flag/enable', { method: 'PUT' }); + const updates = getWorkOSStore(store) + .events.all() + .filter((e) => e.event === 'flag.updated'); + expect(updates).toHaveLength(0); }); - expect(updateRes.status).toBe(200); - expect((await json(updateRes)).value).toBe(true); - // Remove target - const delRes = await req('/feature-flags/dark-mode/targets/user_123', { method: 'DELETE' }); - expect(delRes.status).toBe(204); + it('gives flag lifecycle events the base context only', async () => { + seedFlag('test-flag', { enabled: false }); + await req('/feature-flags/test-flag/enable', { method: 'PUT' }); + const updated = getWorkOSStore(store) + .events.all() + .find((e) => e.event === 'flag.updated')!; + expect(updated.context).toEqual({ + client_id: 'workos-emulate', + actor: { id: 'api_key_emulator', source: 'api', name: 'Emulator API key' }, + }); + // access_type / configured_targets are defined on flag.rule_updated alone. + expect(updated.context).not.toHaveProperty('access_type'); + expect(updated.data).toMatchObject({ slug: 'test-flag', enabled: true }); + expect(updated.data.environment_id).toBe('environment_test'); + }); }); - it('evaluates flags for organization', async () => { - seedFlag(); - const ws = getWorkOSStore(store); - ws.flagTargets.insert({ - object: 'flag_target', - flag_slug: 'dark-mode', - resource_id: 'org_abc', - resource_type: 'organization', - value: false, - }); - - const res = await req('/organizations/org_abc/feature-flags'); - expect(res.status).toBe(200); - const list = await json(res); - expect(list.data).toHaveLength(1); - expect(list.data[0].value).toBe(false); + describe('targets', () => { + it('creates a target with no body and answers 204', async () => { + seedFlag('beta', { default_value: false }); + const user = seedUser(); + + const res = await req(`/feature-flags/beta/targets/${user.id}`, { method: 'POST' }); + expect(res.status).toBe(204); + expect(await res.text()).toBe(''); + + const targets = getWorkOSStore(store).flagTargets.findBy('flag_slug', 'beta'); + expect(targets).toHaveLength(1); + expect(targets[0]).toMatchObject({ resource_id: user.id, resource_type: 'user', enabled: true }); + }); + + it('infers organization targets from the id prefix', async () => { + seedFlag('beta', { default_value: false }); + const org = seedOrg(); + + expect((await req(`/feature-flags/beta/targets/${org.id}`, { method: 'POST' })).status).toBe(204); + expect(getWorkOSStore(store).flagTargets.findBy('flag_slug', 'beta')[0].resource_type).toBe('organization'); + }); + + it('accepts PUT as an alias for POST', async () => { + seedFlag('beta', { default_value: false }); + const user = seedUser(); + expect((await req(`/feature-flags/beta/targets/${user.id}`, { method: 'PUT' })).status).toBe(204); + expect(getWorkOSStore(store).flagTargets.all()).toHaveLength(1); + }); + + it('is idempotent — a repeated create does not duplicate the target', async () => { + seedFlag('beta', { default_value: false }); + const user = seedUser(); + await req(`/feature-flags/beta/targets/${user.id}`, { method: 'POST' }); + expect((await req(`/feature-flags/beta/targets/${user.id}`, { method: 'POST' })).status).toBe(204); + expect(getWorkOSStore(store).flagTargets.all()).toHaveLength(1); + }); + + it('rejects a resource id with no user_/org_ prefix', async () => { + seedFlag('beta'); + const res = await req('/feature-flags/beta/targets/whatever_123', { method: 'POST' }); + expect(res.status).toBe(400); + expect(await json(res)).toMatchObject({ code: 'invalid_resource_id_format' }); + }); + + it('returns 404 for a target user or organization that does not exist', async () => { + seedFlag('beta'); + expect((await req('/feature-flags/beta/targets/user_missing', { method: 'POST' })).status).toBe(404); + expect((await req('/feature-flags/beta/targets/org_missing', { method: 'POST' })).status).toBe(404); + }); + + it('returns 404 deleting a target for a user or organization that does not exist', async () => { + seedFlag('beta'); + // Symmetry with POST: a typo'd id must not be swallowed as a successful removal. + expect((await req('/feature-flags/beta/targets/user_missing', { method: 'DELETE' })).status).toBe(404); + expect((await req('/feature-flags/beta/targets/org_missing', { method: 'DELETE' })).status).toBe(404); + }); + + it('rejects a malformed resource id on delete', async () => { + seedFlag('beta'); + const res = await req('/feature-flags/beta/targets/whatever_123', { method: 'DELETE' }); + expect(res.status).toBe(400); + expect(await json(res)).toMatchObject({ code: 'invalid_resource_id_format' }); + }); + + it('removes a target, and a repeat delete still succeeds', async () => { + seedFlag('beta', { default_value: false }); + const user = seedUser(); + await req(`/feature-flags/beta/targets/${user.id}`, { method: 'POST' }); + + expect((await req(`/feature-flags/beta/targets/${user.id}`, { method: 'DELETE' })).status).toBe(204); + expect(getWorkOSStore(store).flagTargets.all()).toHaveLength(0); + // The spec's 404 covers an unknown flag/user/org, not an already-removed target. + expect((await req(`/feature-flags/beta/targets/${user.id}`, { method: 'DELETE' })).status).toBe(204); + }); + + const ruleEvents = () => + getWorkOSStore(store) + .events.all() + .filter((e) => e.event === 'flag.rule_updated'); + + it('emits flag.rule_updated when targeting changes', async () => { + seedFlag('beta', { default_value: false }); + const org = seedOrg('Acme'); + await req(`/feature-flags/beta/targets/${org.id}`, { method: 'POST' }); + + const events = ruleEvents(); + expect(events).toHaveLength(1); + expect(events[0].data).toMatchObject({ object: 'feature_flag', slug: 'beta' }); + expect(events[0].data.environment_id).toBeString(); + expect(events[0].context).toMatchObject({ + access_type: 'some', + configured_targets: { organizations: [{ id: org.id, name: 'Acme' }], users: [] }, + }); + // The spec marks previous_attributes required on this event; before the target existed + // the flag reached nobody. + expect(events[0].context).toMatchObject({ + previous_attributes: { context: { access_type: 'none', configured_targets: { organizations: [] } } }, + }); + }); + + it('emits a second flag.rule_updated on removal, with access_type back to none', async () => { + seedFlag('beta', { default_value: false }); + const org = seedOrg('Acme'); + await req(`/feature-flags/beta/targets/${org.id}`, { method: 'POST' }); + await req(`/feature-flags/beta/targets/${org.id}`, { method: 'DELETE' }); + + const events = ruleEvents(); + expect(events).toHaveLength(2); + expect(events[1].context).toMatchObject({ + access_type: 'none', + configured_targets: { organizations: [], users: [] }, + previous_attributes: { context: { access_type: 'some' } }, + }); + }); + + it('does not emit flag.rule_updated for a target that already exists', async () => { + seedFlag('beta', { default_value: false }); + const user = seedUser(); + await req(`/feature-flags/beta/targets/${user.id}`, { method: 'POST' }); + await req(`/feature-flags/beta/targets/${user.id}`, { method: 'POST' }); + expect(ruleEvents()).toHaveLength(1); + }); + + it('reports access_type all when default_value carries every resource', async () => { + seedFlag('beta', { default_value: true }); + const org = seedOrg('Acme'); + await req(`/feature-flags/beta/targets/${org.id}`, { method: 'POST' }); + expect(ruleEvents()[0].context).toMatchObject({ access_type: 'all' }); + }); }); - it('evaluates flags for user', async () => { - seedFlag(); + describe('GET /sdk/feature-flags (runtime client polling)', () => { + it('returns a bare slug-keyed map of every flag, including disabled ones', async () => { + seedFlag('on-by-default'); + seedFlag('switched-off', { enabled: false }); + const res = await req('/sdk/feature-flags'); + expect(res.status).toBe(200); + + const body = await json(res); + // A map, not a list envelope — the SDK assigns the response body straight into its store. + expect(Object.keys(body).sort()).toEqual(['on-by-default', 'switched-off']); + expect(body['on-by-default']).toEqual({ + slug: 'on-by-default', + enabled: true, + default_value: true, + targets: { users: [], organizations: [] }, + }); + expect(body['switched-off'].enabled).toBe(false); + }); + + it('reports targets split by kind, each marked enabled', async () => { + seedFlag('beta', { default_value: false }); + const user = seedUser(); + const org = seedOrg(); + await req(`/feature-flags/beta/targets/${user.id}`, { method: 'POST' }); + await req(`/feature-flags/beta/targets/${org.id}`, { method: 'POST' }); + + const body = await json(await req('/sdk/feature-flags')); + // The SDK's evaluator skips any target whose `enabled` is not true. + expect(body.beta.targets).toEqual({ + users: [{ id: user.id, enabled: true }], + organizations: [{ id: org.id, enabled: true }], + }); + }); + + it('agrees with the server-side evaluation the list endpoints use', async () => { + seedFlag('user-targeted', { default_value: false }); + seedFlag('org-targeted', { default_value: false }); + seedFlag('everyone'); + seedFlag('off', { enabled: false, default_value: true }); + seedFlag('nobody', { default_value: false }); + const user = seedUser(); + const org = seedOrg(); + seedMembership(org.id, user.id); + await req(`/feature-flags/user-targeted/targets/${user.id}`, { method: 'POST' }); + await req(`/feature-flags/org-targeted/targets/${org.id}`, { method: 'POST' }); - const res = await req('/user_management/users/user_123/feature-flags'); - expect(res.status).toBe(200); - const list = await json(res); - expect(list.data).toHaveLength(1); - // No target for this user, should get default value since enabled - expect(list.data[0].value).toBe(true); + // A transcription of the SDK's own Evaluator.evaluate(): disabled -> false, any matching + // enabled target -> true, otherwise default_value. Both target kinds are in the context, + // so an org-inherited flag missing from the poll map would fail here. + const poll = await json(await req('/sdk/feature-flags')); + const evaluate = (entry: any) => { + if (!entry.enabled) return false; + if (entry.targets.users.some((t: any) => t.id === user.id && t.enabled)) return true; + if (entry.targets.organizations.some((t: any) => t.id === org.id && t.enabled)) return true; + return entry.default_value; + }; + const onPerPoll = Object.keys(poll) + .filter((slug) => evaluate(poll[slug])) + .sort(); + + const list = await json(await req(`/user_management/users/${user.id}/feature-flags`)); + expect(onPerPoll).toEqual(['everyone', 'org-targeted', 'user-targeted']); + expect(onPerPoll).toEqual(list.data.map((f: any) => f.slug).sort()); + }); }); - it('returns null value for disabled flag without target', async () => { - seedFlag('disabled-flag', false); + describe('evaluation', () => { + it('returns enabled flags for an organization as whole Flag objects', async () => { + seedFlag('on-by-default'); + seedFlag('targeted', { default_value: false }); + seedFlag('off', { enabled: false }); + const org = seedOrg(); + await req(`/feature-flags/targeted/targets/${org.id}`, { method: 'POST' }); + + const res = await req(`/organizations/${org.id}/feature-flags`); + expect(res.status).toBe(200); + const list = await json(res); + expect(list.data.map((f: any) => f.slug).sort()).toEqual(['on-by-default', 'targeted']); + expect(Object.keys(list.data[0]).sort()).toEqual([...FLAG_KEYS].sort()); + }); + + it('excludes a flag targeted only at a different organization', async () => { + seedFlag('targeted', { default_value: false }); + const org = seedOrg('Mine'); + const other = seedOrg('Theirs'); + await req(`/feature-flags/targeted/targets/${other.id}`, { method: 'POST' }); + + expect((await json(await req(`/organizations/${org.id}/feature-flags`))).data).toEqual([]); + }); + + it('returns enabled flags for a user, including those from their organizations', async () => { + seedFlag('user-targeted', { default_value: false }); + seedFlag('org-targeted', { default_value: false }); + seedFlag('untargeted', { default_value: false }); + const user = seedUser(); + const org = seedOrg(); + seedMembership(org.id, user.id); + await req(`/feature-flags/user-targeted/targets/${user.id}`, { method: 'POST' }); + await req(`/feature-flags/org-targeted/targets/${org.id}`, { method: 'POST' }); + + const list = await json(await req(`/user_management/users/${user.id}/feature-flags`)); + expect(list.data.map((f: any) => f.slug).sort()).toEqual(['org-targeted', 'user-targeted']); + }); + + it('omits a disabled flag even from a resource it targets', async () => { + seedFlag('disabled-flag', { enabled: false, default_value: true }); + const user = seedUser(); + // Target it while enabled, then switch the flag off environment-wide. + await req('/feature-flags/disabled-flag/enable', { method: 'PUT' }); + await req(`/feature-flags/disabled-flag/targets/${user.id}`, { method: 'POST' }); + await req('/feature-flags/disabled-flag/disable', { method: 'PUT' }); + + expect((await json(await req(`/user_management/users/${user.id}/feature-flags`))).data).toEqual([]); + }); + + it('paginates the evaluation lists', async () => { + for (let i = 0; i < 3; i++) seedFlag(`flag-${i}`); + const org = seedOrg(); - const res = await req('/user_management/users/user_123/feature-flags'); - const list = await json(res); - expect(list.data[0].value).toBe(null); + const first = await json(await req(`/organizations/${org.id}/feature-flags?limit=2`)); + expect(first.data).toHaveLength(2); + expect(first.list_metadata.after).toBeString(); + + const second = await json( + await req(`/organizations/${org.id}/feature-flags?limit=2&after=${first.list_metadata.after}`), + ); + expect(second.data).toHaveLength(1); + }); + + it('returns 404 for an unknown organization or user', async () => { + expect((await req('/organizations/org_missing/feature-flags')).status).toBe(404); + expect((await req('/user_management/users/user_missing/feature-flags')).status).toBe(404); + }); }); }); diff --git a/src/workos/routes/feature-flags.ts b/src/workos/routes/feature-flags.ts index 6ce8485..0a072c7 100644 --- a/src/workos/routes/feature-flags.ts +++ b/src/workos/routes/feature-flags.ts @@ -1,134 +1,221 @@ -import { type RouteContext, notFound, parseJsonBody, parseListParams } from '../../core/index.js'; +import { type RouteContext, WorkOSApiError, cursorPaginate, notFound, parseListParams } from '../../core/index.js'; import { getWorkOSStore, type WorkOSStore } from '../store.js'; -import { formatFeatureFlag, formatFlagTarget, formatListResponse } from '../helpers.js'; +import type { WorkOSFeatureFlag } from '../entities.js'; +import { formatFeatureFlag, formatFeatureFlagEvent, formatListResponse } from '../helpers.js'; +import { environmentIdFor, flagRuleState, flagRuleUpdatedContext } from '../flag-context.js'; +import { EVENTS, STORE_KEYS } from '../constants.js'; +import type { EventBus } from '../event-bus.js'; -function evaluateFlags(ws: WorkOSStore, resourceId: string) { - const flags = ws.featureFlags.all(); - return flags.map((flag) => { - const target = ws.flagTargets.findBy('flag_slug', flag.slug).find((t) => t.resource_id === resourceId); - return { - slug: flag.slug, - type: flag.type, - value: target ? target.value : flag.enabled ? flag.default_value : null, - enabled: flag.enabled, - }; - }); +/** + * Production targets are addressed by a prefixed resource id and nothing else — the route + * takes no body — so the prefix is the only thing that says whether a target is a user or + * an organization. + */ +function resourceKind(resourceId: string): 'user' | 'organization' | null { + if (resourceId.startsWith('user_')) return 'user'; + if (resourceId.startsWith('org_')) return 'organization'; + return null; +} + +function invalidResourceId(): WorkOSApiError { + return new WorkOSApiError(400, 'Invalid resource id', 'invalid_resource_id_format'); +} + +function isTargeted(ws: WorkOSStore, slug: string, resourceIds: string[]): boolean { + const targets = ws.flagTargets.findBy('flag_slug', slug); + return targets.some((t) => t.enabled && resourceIds.includes(t.resource_id)); +} + +/** + * Whether a flag is on for a set of resource ids (a user, an organization, or a user plus the + * organizations they belong to). A disabled flag is off for everyone; otherwise a target + * switches the flag on, and resources matching no target fall back to `default_value`. + * Targeting is additive — production's create-target route carries no body, so a target can + * only turn a flag on, never off. + */ +function flagIsOn(ws: WorkOSStore, flag: WorkOSFeatureFlag, resourceIds: string[]): boolean { + if (!flag.enabled) return false; + return isTargeted(ws, flag.slug, resourceIds) || flag.default_value === true; +} + +/** The organizations a user is a member of, whose targets a user inherits. */ +export function organizationIdsForUser(ws: WorkOSStore, userId: string): string[] { + return ws.organizationMemberships.findBy('user_id', userId).map((m) => m.organization_id); +} + +/** Flags resolving on for the given resource ids, newest first, as whole `Flag` objects. */ +function enabledFlagsFor(ws: WorkOSStore, resourceIds: string[]): WorkOSFeatureFlag[] { + return ws.featureFlags.all().filter((flag) => flagIsOn(ws, flag, resourceIds)); } /** - * Flag slugs minted into an access token: a flag counts when it resolves strictly true for - * the session's context — a user target wins over an org target, which wins over the flag's - * default value when the flag is enabled (the same target-over-default precedence - * evaluateFlags applies to a single resource). Typed flags carrying strings or numbers stay - * out of the claim rather than guessing at truthiness. + * Flag slugs minted into an access token: production's `feature_flags` claim is the same set + * `GET /user_management/users/{id}/feature-flags` returns, so it resolves through the same + * rule. The session's organization is the only one considered — the token is org-scoped, and + * a flag targeted at some *other* org the user belongs to is not on for this session. */ export function tokenFeatureFlags(ws: WorkOSStore, userId: string, organizationId: string | null): string[] { - return ws.featureFlags - .all() - .filter((flag) => { - const targets = ws.flagTargets.findBy('flag_slug', flag.slug); - const target = - targets.find((t) => t.resource_id === userId) ?? - (organizationId ? targets.find((t) => t.resource_id === organizationId) : undefined); - const value = target ? target.value : flag.enabled ? flag.default_value : null; - return value === true; - }) - .map((flag) => flag.slug); + const resourceIds = organizationId ? [userId, organizationId] : [userId]; + return enabledFlagsFor(ws, resourceIds).map((flag) => flag.slug); } export function featureFlagRoutes(ctx: RouteContext): void { const { app, store } = ctx; const ws = getWorkOSStore(store); - // List all flags - app.get('/feature-flags', (c) => { - const url = new URL(c.req.url); - const params = parseListParams(url); - const result = ws.featureFlags.list({ ...params }); - return c.json(formatListResponse(result, formatFeatureFlag)); - }); - - // Get flag by slug - app.get('/feature-flags/:slug', (c) => { - const flag = ws.featureFlags.findOneBy('slug', c.req.param('slug')); + const flagBySlug = (slug: string): WorkOSFeatureFlag => { + const flag = ws.featureFlags.findOneBy('slug', slug); if (!flag) throw notFound('FeatureFlag'); - return c.json(formatFeatureFlag(flag)); - }); + return flag; + }; + + /** + * `flag.rule_updated` is the target-change event; the flag object itself is unchanged, so + * this cannot ride on the collection's update hook. `previous` is the rule state captured + * before the mutation — the spec marks `previous_attributes` required on this event. + * + * The environment is the emulator's default rather than the caller's: flags are not + * environment-scoped in the store, so reporting the requesting key's environment here would + * make this event disagree with the collection-hook events about the same flag. + */ + const emitRuleUpdated = (flag: WorkOSFeatureFlag, previous: Record) => { + const environmentId = environmentIdFor(); + store.getData(STORE_KEYS.eventBus)?.emit({ + event: EVENTS.flagRuleUpdated, + data: formatFeatureFlagEvent(flag, environmentId), + environment_id: environmentId, + context: flagRuleUpdatedContext(ws, flag, previous), + }); + }; - // Enable flag - app.post('/feature-flags/:slug/enable', (c) => { - const flag = ws.featureFlags.findOneBy('slug', c.req.param('slug')); - if (!flag) throw notFound('FeatureFlag'); - const updated = ws.featureFlags.update(flag.id, { enabled: true }); - return c.json(formatFeatureFlag(updated!)); - }); + const setEnabled = (slug: string, enabled: boolean) => { + const flag = flagBySlug(slug); + // Already in the requested state: skip the write so a no-op call does not emit a + // spurious flag.updated webhook. + if (flag.enabled === enabled) return formatFeatureFlag(flag); + return formatFeatureFlag(ws.featureFlags.update(flag.id, { enabled })!); + }; - // Disable flag - app.post('/feature-flags/:slug/disable', (c) => { - const flag = ws.featureFlags.findOneBy('slug', c.req.param('slug')); - if (!flag) throw notFound('FeatureFlag'); - const updated = ws.featureFlags.update(flag.id, { enabled: false }); - return c.json(formatFeatureFlag(updated!)); + app.get('/feature-flags', (c) => { + const params = parseListParams(new URL(c.req.url)); + return c.json(formatListResponse(ws.featureFlags.list({ ...params }), formatFeatureFlag)); }); - // Add/update target - app.put('/feature-flags/:slug/targets/:resourceId', async (c) => { - const flag = ws.featureFlags.findOneBy('slug', c.req.param('slug')); - if (!flag) throw notFound('FeatureFlag'); + app.get('/feature-flags/:slug', (c) => c.json(formatFeatureFlag(flagBySlug(c.req.param('slug'))))); - const resourceId = c.req.param('resourceId'); - const body = await parseJsonBody(c); + // The spec's verb is PUT. POST is kept as an alias because the emulator shipped these two + // routes under it, and nothing in production claims POST for a different operation. + app.put('/feature-flags/:slug/enable', (c) => c.json(setEnabled(c.req.param('slug'), true))); + app.post('/feature-flags/:slug/enable', (c) => c.json(setEnabled(c.req.param('slug'), true))); + app.put('/feature-flags/:slug/disable', (c) => c.json(setEnabled(c.req.param('slug'), false))); + app.post('/feature-flags/:slug/disable', (c) => c.json(setEnabled(c.req.param('slug'), false))); - // Upsert: find existing target or create - const existing = ws.flagTargets.findBy('flag_slug', flag.slug).find((t) => t.resource_id === resourceId); + const addTarget = (slug: string, resourceId: string) => { + const flag = flagBySlug(slug); + const kind = resourceKind(resourceId); + if (!kind) throw invalidResourceId(); - if (existing) { - const updated = ws.flagTargets.update(existing.id, { - value: body.value, - resource_type: (body.resource_type as string) ?? existing.resource_type, - }); - return c.json(formatFlagTarget(updated!)); - } + // The spec answers 404 for a user or organization that does not exist, not just for an + // unknown flag, so a typo'd id cannot masquerade as a silently ineffective target. + if (kind === 'user' && !ws.users.get(resourceId)) throw notFound('User'); + if (kind === 'organization' && !ws.organizations.get(resourceId)) throw notFound('Organization'); - const target = ws.flagTargets.insert({ + const existing = ws.flagTargets.findBy('flag_slug', flag.slug).find((t) => t.resource_id === resourceId); + if (existing) return { flag, changed: false, previous: undefined }; + + const previous = flagRuleState(ws, flag); + ws.flagTargets.insert({ object: 'flag_target', flag_slug: flag.slug, resource_id: resourceId, - resource_type: (body.resource_type as string) ?? 'user', - value: body.value, + resource_type: kind, + enabled: true, }); + return { flag, changed: true, previous }; + }; - return c.json(formatFlagTarget(target), 201); + // Documented as POST; PUT is accepted too, as the emulator previously exposed it there. + app.post('/feature-flags/:slug/targets/:resourceId', (c) => { + const { flag, changed, previous } = addTarget(c.req.param('slug'), c.req.param('resourceId')); + if (changed && previous) emitRuleUpdated(flag, previous); + return c.body(null, 204); + }); + app.put('/feature-flags/:slug/targets/:resourceId', (c) => { + const { flag, changed, previous } = addTarget(c.req.param('slug'), c.req.param('resourceId')); + if (changed && previous) emitRuleUpdated(flag, previous); + return c.body(null, 204); }); - // Remove target app.delete('/feature-flags/:slug/targets/:resourceId', (c) => { - const flag = ws.featureFlags.findOneBy('slug', c.req.param('slug')); - if (!flag) throw notFound('FeatureFlag'); - + const flag = flagBySlug(c.req.param('slug')); const resourceId = c.req.param('resourceId'); - const target = ws.flagTargets.findBy('flag_slug', flag.slug).find((t) => t.resource_id === resourceId); - if (!target) throw notFound('FlagTarget'); + const kind = resourceKind(resourceId); + if (!kind) throw invalidResourceId(); + + // The same existence checks the create route runs. Without them a typo'd id is swallowed + // as a successful removal, so a test asserting "the target is gone" passes against the + // emulator and fails against production, which 404s. + if (kind === 'user' && !ws.users.get(resourceId)) throw notFound('User'); + if (kind === 'organization' && !ws.organizations.get(resourceId)) throw notFound('Organization'); - ws.flagTargets.delete(target.id); + const target = ws.flagTargets.findBy('flag_slug', flag.slug).find((t) => t.resource_id === resourceId); + // Idempotent: the spec's 404 covers an unknown flag, user or organization, not a target + // that was already removed, and a delete that has nothing left to do has succeeded. + if (target) { + const previous = flagRuleState(ws, flag); + ws.flagTargets.delete(target.id); + emitRuleUpdated(flag, previous); + } return c.body(null, 204); }); - // Evaluate flags for organization - app.get('/organizations/:orgId/feature-flags', (c) => { - return c.json({ - object: 'list', - data: evaluateFlags(ws, c.req.param('orgId')), - list_metadata: { before: null, after: null }, - }); + /** + * The Node SDK runtime client's polling endpoint (`createRuntimeClient()`, `isEnabled()`, + * `getAllFlags()`). It is not in the OpenAPI spec but is what the SDK actually fetches, so + * without it the runtime client never leaves its bootstrap state. + * + * The body is a bare `{ [slug]: entry }` map, not a list envelope, and carries every flag — + * the client evaluates `enabled` and the targets itself, and diffs successive polls to emit + * `change` events, so filtering here would hide transitions from it. + */ + app.get('/sdk/feature-flags', (c) => { + // Null-prototype: a flag slugged `__proto__` would otherwise be swallowed by the assignment + // rather than appearing in the map the SDK swaps into its store. + const body: Record = Object.create(null); + for (const flag of ws.featureFlags.all()) { + const targets = ws.flagTargets.findBy('flag_slug', flag.slug); + const byKind = (kind: 'user' | 'organization') => + targets.filter((t) => t.resource_type === kind).map((t) => ({ id: t.resource_id, enabled: t.enabled })); + body[flag.slug] = { + slug: flag.slug, + enabled: flag.enabled, + default_value: flag.default_value, + // `custom_targets` is omitted deliberately: the SDK documents it as absent until the + // API's custom-targets rollout, and its evaluator defaults it to []. + targets: { users: byKind('user'), organizations: byKind('organization') }, + }; + } + return c.json(body); + }); + + // Both evaluation endpoints return a FlagList of whole Flag objects and list only the flags + // that resolve on — not every flag with a value attached. + const listEnabled = (requestUrl: string, resourceIds: string[]) => { + const params = parseListParams(new URL(requestUrl)); + return formatListResponse(cursorPaginate(enabledFlagsFor(ws, resourceIds), params), formatFeatureFlag); + }; + + app.get('/organizations/:organizationId/feature-flags', (c) => { + const orgId = c.req.param('organizationId'); + if (!ws.organizations.get(orgId)) throw notFound('Organization'); + return c.json(listEnabled(c.req.url, [orgId])); }); - // Evaluate flags for user app.get('/user_management/users/:userId/feature-flags', (c) => { - return c.json({ - object: 'list', - data: evaluateFlags(ws, c.req.param('userId')), - list_metadata: { before: null, after: null }, - }); + const userId = c.req.param('userId'); + if (!ws.users.get(userId)) throw notFound('User'); + // Documented as including "any organizations that the user is a member of", so every org + // membership contributes its targets — unlike the org-scoped access token claim. + return c.json(listEnabled(c.req.url, [userId, ...organizationIdsForUser(ws, userId)])); }); } diff --git a/src/workos/seed-feature-flags.spec.ts b/src/workos/seed-feature-flags.spec.ts new file mode 100644 index 0000000..ee4bd07 --- /dev/null +++ b/src/workos/seed-feature-flags.spec.ts @@ -0,0 +1,279 @@ +/** + * Seeding feature flags. Production has no create-flag endpoint — flags are made in the + * dashboard — so a seed file is the only way one exists in the emulator at all. The two + * surfaces a consumer reads them from are the `feature_flags` access-token claim and the + * per-user / per-organization list endpoints an SDK polls. + */ +import { describe, it, expect, afterEach } from 'bun:test'; +import { createEmulator, type Emulator } from '../index.js'; +import { validateSeedConfig } from './config-validator.js'; + +describe('Seeding feature flags', () => { + let emulator: Emulator | undefined; + + afterEach(async () => { + await emulator?.close(); + emulator = undefined; + }); + + const decode = (token: string) => + JSON.parse(Buffer.from(token.split('.')[1], 'base64url').toString('utf-8')) as Record; + + const seed = { + users: [ + { email: 'alice@acme.com', password: 'test123', email_verified: true }, + { email: 'bob@acme.com', password: 'test123', email_verified: true }, + ], + organizations: [ + { name: 'Acme Corp', memberships: [{ email: 'alice@acme.com' }, { email: 'bob@acme.com' }] }, + { name: 'Other Inc' }, + ], + featureFlags: [ + { slug: 'everyone', name: 'On For Everyone', default_value: true }, + { slug: 'alice-only', targets: { users: ['alice@acme.com'] } }, + { slug: 'acme-only', targets: { organizations: ['Acme Corp'] } }, + { slug: 'other-only', targets: { organizations: ['Other Inc'] } }, + { slug: 'switched-off', enabled: false, default_value: true }, + { + slug: 'documented', + description: 'Has every optional field set', + tags: ['ui', 'beta'], + owner: { email: 'jane@acme.com', first_name: 'Jane', last_name: 'Doe' }, + default_value: true, + }, + ], + }; + + const signIn = async (email: string) => { + const res = await fetch(`${emulator!.url}/user_management/authenticate`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ grant_type: 'password', email, password: 'test123', client_id: 'client_test' }), + }); + expect(res.status).toBe(200); + return (await res.json()) as { access_token: string; organization_id?: string; user: { id: string } }; + }; + + const get = async (path: string) => { + const res = await fetch(`${emulator!.url}${path}`, { headers: { Authorization: `Bearer ${emulator!.apiKey}` } }); + expect(res.status).toBe(200); + return (await res.json()) as { data: Array> }; + }; + + it('mints seeded flags into the access token claim, scoped to the session organization', async () => { + emulator = await createEmulator({ port: 0, seed }); + + const alice = await signIn('alice@acme.com'); + const flags = decode(alice.access_token).feature_flags as string[]; + // 'other-only' targets an org Alice is not in; 'switched-off' is disabled environment-wide. + expect([...flags].sort()).toEqual(['acme-only', 'alice-only', 'documented', 'everyone']); + }); + + it('leaves a user-targeted flag off for a colleague in the same organization', async () => { + emulator = await createEmulator({ port: 0, seed }); + + const bob = await signIn('bob@acme.com'); + const flags = decode(bob.access_token).feature_flags as string[]; + expect(flags).toContain('acme-only'); + expect(flags).not.toContain('alice-only'); + }); + + it('serves the same set over the user list endpoint an SDK polls', async () => { + emulator = await createEmulator({ port: 0, seed }); + + const alice = await signIn('alice@acme.com'); + const list = await get(`/user_management/users/${alice.user.id}/feature-flags`); + expect(list.data.map((f) => f.slug).sort()).toEqual(['acme-only', 'alice-only', 'documented', 'everyone']); + }); + + it('carries every seeded field onto the flag object', async () => { + emulator = await createEmulator({ port: 0, seed }); + + const flags = await get('/feature-flags'); + const documented = flags.data.find((f) => f.slug === 'documented')!; + expect(documented).toMatchObject({ + object: 'feature_flag', + name: 'documented', + description: 'Has every optional field set', + tags: ['ui', 'beta'], + owner: { email: 'jane@acme.com', first_name: 'Jane', last_name: 'Doe' }, + enabled: true, + default_value: true, + }); + expect(documented.id as string).toStartWith('flag_'); + }); + + it('honours a pinned flag id', async () => { + emulator = await createEmulator({ + port: 0, + seed: { featureFlags: [{ id: 'flag_01PINNED', slug: 'pinned' }] }, + }); + + const flags = await get('/feature-flags'); + expect(flags.data[0].id).toBe('flag_01PINNED'); + }); + + it('lists organization flags for the seeded organization only', async () => { + emulator = await createEmulator({ port: 0, seed }); + + const orgs = await get('/organizations'); + const acme = orgs.data.find((o) => o.name === 'Acme Corp')!; + const list = await get(`/organizations/${acme.id}/feature-flags`); + // No user targets apply to an organization, so 'alice-only' is absent here. + expect(list.data.map((f) => f.slug).sort()).toEqual(['acme-only', 'documented', 'everyone']); + }); + + it('scopes the token claim to the session organization while the list endpoint unions all of them', async () => { + // The one configuration where the two surfaces intentionally differ, and the reason the + // README documents the scoping rather than claiming the three surfaces are identical. + emulator = await createEmulator({ + port: 0, + seed: { + users: [{ email: 'multi@acme.com', password: 'test123', email_verified: true }], + organizations: [ + { name: 'First Org', memberships: [{ email: 'multi@acme.com' }] }, + { name: 'Second Org', memberships: [{ email: 'multi@acme.com' }] }, + ], + featureFlags: [{ slug: 'second-org-only', targets: { organizations: ['Second Org'] } }], + }, + }); + + // A user in two organizations must choose one before a session exists. The pending token is + // single-use, so each selection starts its own sign-in. + const startAuth = async () => { + const res = await fetch(`${emulator!.url}/user_management/authenticate`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + grant_type: 'password', + email: 'multi@acme.com', + password: 'test123', + client_id: 'client_test', + }), + }); + expect(res.status).toBe(403); + return (await res.json()) as { + pending_authentication_token: string; + organizations: Array<{ id: string; name: string }>; + user: { id: string }; + }; + }; + + const claimFor = async (orgName: string) => { + const pending = await startAuth(); + const org = pending.organizations.find((o) => o.name === orgName)!; + const res = await fetch(`${emulator!.url}/user_management/authenticate`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + grant_type: 'urn:workos:oauth:grant-type:organization-selection', + pending_authentication_token: pending.pending_authentication_token, + organization_id: org.id, + client_id: 'client_test', + }), + }); + expect(res.status).toBe(200); + const body = (await res.json()) as { access_token: string }; + return (decode(body.access_token).feature_flags as string[] | undefined) ?? []; + }; + + expect(await claimFor('Second Org')).toEqual(['second-org-only']); + // Same user, same flag, different session scope — the claim drops it. + expect(await claimFor('First Org')).toEqual([]); + + const pending = await startAuth(); + + // The list endpoint is not session-scoped, so it unions both memberships and keeps it. + const list = await get(`/user_management/users/${pending.user.id}/feature-flags`); + expect(list.data.map((f) => f.slug)).toEqual(['second-org-only']); + }); + + it('rejects two flags pinned to the same id', () => { + const { valid, errors } = validateSeedConfig({ + featureFlags: [ + { id: 'flag_dup', slug: 'a' }, + { id: 'flag_dup', slug: 'b' }, + ], + }); + expect(valid).toBe(false); + expect(errors.find((e) => e.path === 'featureFlags[1].id')).toBeDefined(); + }); + + it('rejects a pinned id that is not a plain identifier', () => { + const { valid, errors } = validateSeedConfig({ featureFlags: [{ id: 'flag/../boom !', slug: 'a' }] }); + expect(valid).toBe(false); + expect(errors.find((e) => e.path === 'featureFlags[0].id')).toBeDefined(); + }); + + it('reports a non-array targets sub-field rather than throwing', () => { + // A YAML author writing `users: alice@acme.com` instead of a list previously crashed the + // validator with a raw TypeError, which --validate-config surfaced as a stack trace. + const run = () => + validateSeedConfig({ + users: [{ email: 'alice@acme.com' }], + featureFlags: [{ slug: 'a', targets: { users: 'alice@acme.com' as unknown as string[] } }], + }); + expect(run).not.toThrow(); + const { valid, errors } = run(); + expect(valid).toBe(false); + expect(errors.find((e) => e.path === 'featureFlags[0].targets.users')).toBeDefined(); + }); + + it('rejects the same target listed twice', () => { + const { valid, errors } = validateSeedConfig({ + users: [{ email: 'alice@acme.com' }], + organizations: [{ name: 'Acme Corp' }], + featureFlags: [ + { + slug: 'a', + targets: { + users: ['alice@acme.com', 'ALICE@acme.com'], + organizations: ['Acme Corp', 'Acme Corp'], + }, + }, + ], + }); + expect(valid).toBe(false); + expect(errors.map((e) => e.path)).toEqual([ + 'featureFlags[0].targets.users[1]', + 'featureFlags[0].targets.organizations[1]', + ]); + }); + + it('rejects a non-string name or description', () => { + const { valid, errors } = validateSeedConfig({ + featureFlags: [{ slug: 'a', name: 42 as unknown as string, description: 7 as unknown as string }], + }); + expect(valid).toBe(false); + expect(errors.map((e) => e.path).sort()).toEqual(['featureFlags[0].description', 'featureFlags[0].name']); + }); + + it('rejects a target naming a user or organization the config does not define', () => { + const { valid, errors } = validateSeedConfig({ + users: [{ email: 'alice@acme.com' }], + organizations: [{ name: 'Acme Corp' }], + featureFlags: [{ slug: 'typo', targets: { users: ['alcie@acme.com'], organizations: ['Acme Crop'] } }], + }); + expect(valid).toBe(false); + expect(errors.map((e) => e.path)).toEqual([ + 'featureFlags[0].targets.users[0]', + 'featureFlags[0].targets.organizations[0]', + ]); + }); + + it('rejects a duplicate slug', () => { + const { valid, errors } = validateSeedConfig({ + featureFlags: [{ slug: 'dupe' }, { slug: 'dupe' }], + }); + expect(valid).toBe(false); + expect(errors.find((e) => e.path === 'featureFlags[1].slug')).toBeDefined(); + }); + + it('rejects a non-boolean default_value', () => { + const { valid, errors } = validateSeedConfig({ + featureFlags: [{ slug: 'typed', default_value: 'variant-a' as unknown as boolean }], + }); + expect(valid).toBe(false); + expect(errors.find((e) => e.path === 'featureFlags[0].default_value')).toBeDefined(); + }); +}); From c96ed7edca792da95254fae576ba935df9924f0c Mon Sep 17 00:00:00 2001 From: Daniel Loader Date: Tue, 1 Sep 2026 15:09:53 +0100 Subject: [PATCH 2/3] fix(feature-flags): gate org inheritance on active membership, guard null seed entries MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review feedback on #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) --- README.md | 6 ++++-- src/workos/config-validator.ts | 7 +++++++ src/workos/routes/feature-flags.spec.ts | 25 +++++++++++++++++++++++-- src/workos/routes/feature-flags.ts | 12 ++++++++++-- src/workos/seed-feature-flags.spec.ts | 9 +++++++++ 5 files changed, 53 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 9e05633..d47216b 100644 --- a/README.md +++ b/README.md @@ -586,7 +586,9 @@ Three surfaces read the result, and all three resolve through the same rule: - **The list endpoints an SDK polls** — `GET /user_management/users/{id}/feature-flags` and `GET /organizations/{id}/feature-flags`. Both return a paginated list of whole `feature_flag` objects, and both list only the flags that are on. The user endpoint includes flags from every - organization that user is a member of, as production documents. + organization the user is an _active_ member of, as production documents — a `pending` or + `inactive` membership grants nothing, matching the status check `authenticate` applies before + scoping a session to an organization. ```ts const { data } = await workos.featureFlags.listUserFeatureFlags({ userId: user.id }); @@ -612,7 +614,7 @@ All three apply the same rule — a disabled flag is off for everyone; otherwise target wins; otherwise `default_value` — so for one resource they agree. They are scoped differently on purpose, and that is the one case where they legitimately differ: the token claim covers the user plus the session's organization, while the user list endpoint covers the user plus -_every_ organization they belong to. A user in two organizations can therefore have a flag in the +_every_ organization they are an active member of. A user in two organizations can therefore have a flag in the list endpoint that is absent from a token scoped to the other organization. Production scopes them the same way. diff --git a/src/workos/config-validator.ts b/src/workos/config-validator.ts index 1377df1..244cd06 100644 --- a/src/workos/config-validator.ts +++ b/src/workos/config-validator.ts @@ -827,6 +827,13 @@ export function validateSeedConfig(config: WorkOSSeedConfig): ConfigValidationRe config.featureFlags.forEach((flag, index) => { const at = (field: string) => `featureFlags[${index}].${field}`; + // A YAML list item left empty parses as null, which every field check below would + // dereference. Reported as a validation error so `--validate-config` stays useful. + if (flag === null || typeof flag !== 'object' || Array.isArray(flag)) { + errors.push({ path: `featureFlags[${index}]`, message: 'each feature flag must be an object', value: flag }); + return; + } + if (flag.id !== undefined) { if (typeof flag.id !== 'string' || !PINNED_ID_PATTERN.test(flag.id)) { errors.push({ diff --git a/src/workos/routes/feature-flags.spec.ts b/src/workos/routes/feature-flags.spec.ts index d7aa4fa..b46c6f8 100644 --- a/src/workos/routes/feature-flags.spec.ts +++ b/src/workos/routes/feature-flags.spec.ts @@ -70,13 +70,17 @@ describe('Feature Flags routes', () => { }); } - function seedMembership(organizationId: string, userId: string) { + function seedMembership( + organizationId: string, + userId: string, + status: 'active' | 'inactive' | 'pending' = 'active', + ) { return getWorkOSStore(store).organizationMemberships.insert({ object: 'organization_membership', organization_id: organizationId, user_id: userId, role: { slug: 'member' }, - status: 'active', + status, external_id: null, metadata: {}, }); @@ -404,6 +408,23 @@ describe('Feature Flags routes', () => { expect(list.data.map((f: any) => f.slug).sort()).toEqual(['org-targeted', 'user-targeted']); }); + for (const status of ['inactive', 'pending'] as const) { + it(`does not inherit organization targets through a ${status} membership`, async () => { + seedFlag('org-targeted', { default_value: false }); + const user = seedUser(); + const org = seedOrg(); + seedMembership(org.id, user.id, status); + await req(`/feature-flags/org-targeted/targets/${org.id}`, { method: 'POST' }); + + // authenticate refuses to scope a session to a non-active membership, so a flag + // reported here could never appear in that user's token claim. + expect((await json(await req(`/user_management/users/${user.id}/feature-flags`))).data).toEqual([]); + // The organization itself still sees it — only the inheritance is gated. + const orgList = await json(await req(`/organizations/${org.id}/feature-flags`)); + expect(orgList.data.map((f: any) => f.slug)).toEqual(['org-targeted']); + }); + } + it('omits a disabled flag even from a resource it targets', async () => { seedFlag('disabled-flag', { enabled: false, default_value: true }); const user = seedUser(); diff --git a/src/workos/routes/feature-flags.ts b/src/workos/routes/feature-flags.ts index 0a072c7..decefd9 100644 --- a/src/workos/routes/feature-flags.ts +++ b/src/workos/routes/feature-flags.ts @@ -38,9 +38,17 @@ function flagIsOn(ws: WorkOSStore, flag: WorkOSFeatureFlag, resourceIds: string[ return isTargeted(ws, flag.slug, resourceIds) || flag.default_value === true; } -/** The organizations a user is a member of, whose targets a user inherits. */ +/** + * The organizations whose targets a user inherits: active memberships only. A `pending` member + * has not joined yet and an `inactive` one has been removed, and authenticate gates organization + * scoping on the same status — so counting either here would report a flag through an + * organization no session of that user's can ever be scoped to. + */ export function organizationIdsForUser(ws: WorkOSStore, userId: string): string[] { - return ws.organizationMemberships.findBy('user_id', userId).map((m) => m.organization_id); + return ws.organizationMemberships + .findBy('user_id', userId) + .filter((m) => m.status === 'active') + .map((m) => m.organization_id); } /** Flags resolving on for the given resource ids, newest first, as whole `Flag` objects. */ diff --git a/src/workos/seed-feature-flags.spec.ts b/src/workos/seed-feature-flags.spec.ts index ee4bd07..34a4852 100644 --- a/src/workos/seed-feature-flags.spec.ts +++ b/src/workos/seed-feature-flags.spec.ts @@ -261,6 +261,15 @@ describe('Seeding feature flags', () => { ]); }); + it('reports a null feature-flag entry rather than throwing', () => { + // An empty YAML list item parses as null; --validate-config must report it, not stack-trace. + const run = () => validateSeedConfig({ featureFlags: [null as unknown as { slug: string }] }); + expect(run).not.toThrow(); + const { valid, errors } = run(); + expect(valid).toBe(false); + expect(errors.find((e) => e.path === 'featureFlags[0]')).toBeDefined(); + }); + it('rejects a duplicate slug', () => { const { valid, errors } = validateSeedConfig({ featureFlags: [{ slug: 'dupe' }, { slug: 'dupe' }], From c27aa36b82e5313743011da0ebfc8d67f9161350 Mon Sep 17 00:00:00 2001 From: "Garen J. Torikian" Date: Thu, 3 Sep 2026 14:19:16 -0400 Subject: [PATCH 3/3] fix(feature-flags): pin Flag shape to spec, name real actors Review follow-ups on #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. --- README.md | 10 +++- scripts/gen-shapes-lib.ts | 17 ++++++ src/workos/config-validator.ts | 16 +++++ src/workos/flag-context.ts | 47 +++++++++++---- src/workos/generated/response-shapes.ts | 44 ++++++++++++++ src/workos/helpers.ts | 11 ++++ src/workos/index.ts | 5 +- src/workos/response-envelopes.spec.ts | 11 ++++ src/workos/response-shapes.spec.ts | 17 ++++++ src/workos/routes/feature-flags.spec.ts | 79 +++++++++++++++++++------ src/workos/routes/feature-flags.ts | 16 +++-- src/workos/routes/organizations.ts | 3 + src/workos/routes/users.ts | 4 ++ src/workos/routes/vault.ts | 6 +- src/workos/seed-feature-flags.spec.ts | 23 +++++++ 15 files changed, 269 insertions(+), 40 deletions(-) diff --git a/README.md b/README.md index d47216b..46f8583 100644 --- a/README.md +++ b/README.md @@ -575,7 +575,8 @@ off for everyone until something enables it. A flag is on for a resource when it is `enabled` **and** either a target names that resource or `default_value` is true. Targeting is additive, as it is in production: `POST /feature-flags/{slug}/targets/{resourceId}` carries no body, so a target can turn a flag on but -never off. Flags also accept an optional `id` to pin (`flag_01ABC…`). +never off. Flags also accept an optional `id` to pin (`flag_01ABC…`). Slugs must be URL-safe, since +every route addresses a flag by slug in the path. Three surfaces read the result, and all three resolve through the same rule: @@ -626,6 +627,13 @@ on them in code you intend to run against real WorkOS. Changes emit `flag.update removing a target emits `flag.rule_updated`, carrying the flag's `access_type`, its configured targets, and the previous rule state. +`flag.rule_updated` names the API key that made the request as its `actor` when that key has a +record behind it (an array-form `apiKeys` entry, or a key created over the API); a map-form key +authenticates but has no record, so the actor falls back to the emulator's placeholder key. The +collection-level `flag.created` / `flag.updated` / `flag.deleted` events run without request +context and always report the placeholder. Flags are not environment-scoped, so every flag event +reports `environment_test`. Deleting a user or organization removes its flag targets. + ## Widgets `POST /widgets/token` mints the session token the `@workos-inc/widgets` components authenticate diff --git a/scripts/gen-shapes-lib.ts b/scripts/gen-shapes-lib.ts index ba2dd28..588e085 100644 --- a/scripts/gen-shapes-lib.ts +++ b/scripts/gen-shapes-lib.ts @@ -56,6 +56,7 @@ export const OBJECT_SCHEMA_MAP: readonly ShapeMapEntry[] = [ { objectType: 'permission', schemaName: 'AuthorizationPermission' }, { objectType: 'api_key', schemaName: 'ApiKey' }, { objectType: 'password_reset', schemaName: 'PasswordReset' }, + { objectType: 'feature_flag', schemaName: 'Flag' }, ]; export interface EnvelopeMapEntry { @@ -123,6 +124,22 @@ export const ENVELOPE_SCHEMA_MAP: readonly EnvelopeMapEntry[] = [ status: '200', schemaName: 'OrganizationApiKeyList', }, + // Three routes wrap a FlagList, and the two evaluation routes assemble theirs from a + // filtered, re-paginated array rather than Collection.list() — a different code path + // from the plain listing, so each is checked on its own. + { method: 'GET', path: '/feature-flags', status: '200', schemaName: 'FlagList' }, + { + method: 'GET', + path: '/organizations/{organizationId}/feature-flags', + status: '200', + schemaName: 'FlagList', + }, + { + method: 'GET', + path: '/user_management/users/{userId}/feature-flags', + status: '200', + schemaName: 'FlagList', + }, ]; export interface ParsedShape { diff --git a/src/workos/config-validator.ts b/src/workos/config-validator.ts index 244cd06..9a08844 100644 --- a/src/workos/config-validator.ts +++ b/src/workos/config-validator.ts @@ -850,6 +850,14 @@ export function validateSeedConfig(config: WorkOSSeedConfig): ConfigValidationRe if (!flag.slug || typeof flag.slug !== 'string') { errors.push({ path: at('slug'), message: 'slug is required and must be a string', value: flag.slug }); + } else if (encodeURIComponent(flag.slug) !== flag.slug) { + // Every route addresses a flag by slug in the URL path, so a slug that needs + // percent-encoding seeds fine and is then unreachable. + errors.push({ + path: at('slug'), + message: 'slug must be URL-safe (no spaces, slashes or characters that need percent-encoding)', + value: flag.slug, + }); } else if (seenSlugs.has(flag.slug)) { // Every lookup is by slug, so a duplicate is a flag no route can ever resolve. errors.push({ path: at('slug'), message: 'slug must be unique across featureFlags', value: flag.slug }); @@ -960,6 +968,14 @@ export function validateSeedConfig(config: WorkOSSeedConfig): ConfigValidationRe const seenTargetOrgs = new Set(); targetOrgs.forEach((name, i) => { + if (typeof name !== 'string') { + errors.push({ + path: at(`targets.organizations[${i}]`), + message: 'targets.organizations entries must be names of organizations defined in `organizations`', + value: name, + }); + return; + } if (!orgNames.has(name)) { errors.push({ path: at(`targets.organizations[${i}]`), diff --git a/src/workos/flag-context.ts b/src/workos/flag-context.ts index e629768..27b64c8 100644 --- a/src/workos/flag-context.ts +++ b/src/workos/flag-context.ts @@ -6,6 +6,16 @@ export function environmentIdFor(environment?: string): string { return `environment_${environment ?? 'test'}`; } +/** `{ id, name }` of the API key acting on a flag; see `apiKeyActor` for how it is resolved. */ +export type FlagActor = { id: string; name: string }; + +/** + * The emulator's standing placeholder, for events that fire with no request behind them. + * The collection hooks behind `flag.created/updated/deleted` run without request context — + * a direct `getWorkOSStore()` insert fires them too — so there is no caller to name. + */ +const PLACEHOLDER_ACTOR: FlagActor = { id: 'api_key_emulator', name: 'Emulator API key' }; + /** * `access_type` summarises a flag's reach in one word, the way the dashboard shows it: * `all` when the default value carries every resource, `some` when only targets are on, @@ -19,19 +29,26 @@ export function flagAccessType(ws: WorkOSStore, flag: WorkOSFeatureFlag): 'none' /** * The base `context` every flag webhook carries. `client_id` is the emulator's standing - * placeholder — it has no environment/client identity to report — and the actor is always - * the API surface, since the emulator serves no dashboard or admin portal. + * placeholder — an API-key request has no client identity to report — and `source` is + * always `api`, since the emulator serves no dashboard or admin portal. + * + * The actor is the API key that made the request when the emitting code has one (the target + * routes do — see `apiKeyActor`), and the placeholder otherwise. */ -export function flagEventContext(): Record { +export function flagEventContext(actor: FlagActor = PLACEHOLDER_ACTOR): Record { return { client_id: 'workos-emulate', - actor: { id: 'api_key_emulator', source: 'api', name: 'Emulator API key' }, + actor: { id: actor.id, source: 'api', name: actor.name }, }; } /** * The targeting half of a `flag.rule_updated` context. Only that event defines `access_type` * and `configured_targets`, so the other three flag events must not carry them. + * + * A target whose user or organization no longer resolves is left out rather than reported + * with a blank name or email: the spec marks both required, and the delete routes cascade + * targets, so such a row only exists when a caller has edited the store directly. */ export function flagRuleState(ws: WorkOSStore, flag: WorkOSFeatureFlag): Record { const targets = ws.flagTargets.findBy('flag_slug', flag.slug); @@ -40,10 +57,16 @@ export function flagRuleState(ws: WorkOSStore, flag: WorkOSFeatureFlag): Record< configured_targets: { organizations: targets .filter((t) => t.resource_type === 'organization') - .map((t) => ({ id: t.resource_id, name: ws.organizations.get(t.resource_id)?.name ?? t.resource_id })), + .flatMap((t) => { + const org = ws.organizations.get(t.resource_id); + return org ? [{ id: org.id, name: org.name }] : []; + }), users: targets .filter((t) => t.resource_type === 'user') - .map((t) => ({ id: t.resource_id, email: ws.users.get(t.resource_id)?.email ?? '' })), + .flatMap((t) => { + const user = ws.users.get(t.resource_id); + return user ? [{ id: user.id, email: user.email }] : []; + }), }, }; } @@ -51,18 +74,20 @@ export function flagRuleState(ws: WorkOSStore, flag: WorkOSFeatureFlag): Record< /** * The full `flag.rule_updated` context. `previous_attributes` is required on this event, so * the caller has to capture `flagRuleState` before mutating the targets and hand it back here. + * + * Only the rule changed: a target mutation leaves the flag object's own attributes as they + * were, so `previous_attributes.data` is omitted rather than restating current values as + * "previous". The spec requires neither `data` nor `context` inside `previous_attributes`. */ export function flagRuleUpdatedContext( ws: WorkOSStore, flag: WorkOSFeatureFlag, previous: Record, + actor?: FlagActor, ): Record { return { - ...flagEventContext(), + ...flagEventContext(actor), ...flagRuleState(ws, flag), - previous_attributes: { - data: { enabled: flag.enabled, default_value: flag.default_value }, - context: previous, - }, + previous_attributes: { context: previous }, }; } diff --git a/src/workos/generated/response-shapes.ts b/src/workos/generated/response-shapes.ts index 02e8ff5..015dfeb 100644 --- a/src/workos/generated/response-shapes.ts +++ b/src/workos/generated/response-shapes.ts @@ -137,6 +137,35 @@ export const RESPONSE_SHAPE_REQUIREMENTS: Record get(`/organizations/${f.organizationId}/api_keys`)(app), }, + { operation: 'GET /feature-flags', request: get('/feature-flags') }, + { + operation: 'GET /organizations/{organizationId}/feature-flags', + request: (app, f) => get(`/organizations/${f.organizationId}/feature-flags`)(app), + }, + { + operation: 'GET /user_management/users/{userId}/feature-flags', + request: (app, f) => get(`/user_management/users/${f.userId}/feature-flags`)(app), + }, ]; /** @@ -165,6 +174,8 @@ describe('response envelope conformance (route bodies vs OpenAPI spec)', () => { webhookEndpoints: [{ endpoint_url: 'http://localhost:5005/webhooks', events: ['dsync.activated'] }], connectApplications: [{ name: 'Billing', type: 'm2m', organization: 'Acme Corp', client_id: 'client_billing' }], apiKeys: [{ name: 'Envelope Key', organization: 'Acme Corp', value: API_KEY, permissions: ['posts:read'] }], + // On for everyone, so both evaluation routes return a non-empty page for the fixtures. + featureFlags: [{ slug: 'envelope-flag', name: 'Envelope Flag', default_value: true }], }); const ws = getWorkOSStore(server.store); diff --git a/src/workos/response-shapes.spec.ts b/src/workos/response-shapes.spec.ts index 466fc5d..776d7aa 100644 --- a/src/workos/response-shapes.spec.ts +++ b/src/workos/response-shapes.spec.ts @@ -29,6 +29,7 @@ import { formatPermission, formatApiKeyRecord, formatPasswordReset, + formatFeatureFlag, } from './helpers.js'; import { RESPONSE_SHAPE_REQUIREMENTS } from './generated/response-shapes.js'; import type { @@ -42,6 +43,7 @@ import type { WorkOSPermission, WorkOSApiKey, WorkOSPasswordReset, + WorkOSFeatureFlag, } from './entities.js'; const TS = '2026-01-01T00:00:00.000Z'; @@ -188,6 +190,20 @@ const passwordReset: WorkOSPasswordReset = { updated_at: TS, }; +const featureFlag: WorkOSFeatureFlag = { + id: 'flag_01', + object: 'feature_flag', + slug: 'advanced-analytics', + name: 'Advanced Analytics', + description: null, + owner: { email: 'jane@example.com', first_name: 'Jane', last_name: 'Doe' }, + tags: ['reports'], + enabled: true, + default_value: false, + created_at: TS, + updated_at: TS, +}; + const store = new Store(); const ws = getWorkOSStore(store); @@ -202,6 +218,7 @@ const CASES: ReadonlyArray<{ objectType: string; output: Record { objectType: 'permission', output: formatPermission(permission) }, { objectType: 'api_key', output: formatApiKeyRecord(apiKey) }, { objectType: 'password_reset', output: formatPasswordReset(passwordReset) }, + { objectType: 'feature_flag', output: formatFeatureFlag(featureFlag) }, ]; /** diff --git a/src/workos/routes/feature-flags.spec.ts b/src/workos/routes/feature-flags.spec.ts index b46c6f8..72ae368 100644 --- a/src/workos/routes/feature-flags.spec.ts +++ b/src/workos/routes/feature-flags.spec.ts @@ -2,24 +2,17 @@ import { describe, it, expect, beforeEach } from 'bun:test'; import { createServer, type ApiKeyMap, type Store } from '../../core/index.js'; import { workosPlugin } from '../index.js'; import { getWorkOSStore } from '../store.js'; +import { RESPONSE_SHAPE_REQUIREMENTS } from '../generated/response-shapes.js'; const apiKeys: ApiKeyMap = { sk_test_org: { environment: 'test' } }; const headers = { Authorization: 'Bearer sk_test_org', 'Content-Type': 'application/json' }; -/** Every key the spec marks required on a `Flag`. A strict SDK deserializer faults on any omission. */ -const FLAG_KEYS = [ - 'object', - 'id', - 'slug', - 'name', - 'description', - 'owner', - 'tags', - 'enabled', - 'default_value', - 'created_at', - 'updated_at', -]; +/** + * Every key the spec marks required on a `Flag`, from the generated catalog rather than a + * hand-copied list: a strict SDK deserializer faults on any omission, and a spec change to + * `Flag` must fail here rather than drift past a literal. + */ +const FLAG_KEYS = RESPONSE_SHAPE_REQUIREMENTS.feature_flag.required; function createTestApp() { return createServer(workosPlugin, { port: 0, baseUrl: 'http://localhost:0', apiKeys }); @@ -267,9 +260,10 @@ describe('Feature Flags routes', () => { configured_targets: { organizations: [{ id: org.id, name: 'Acme' }], users: [] }, }); // The spec marks previous_attributes required on this event; before the target existed - // the flag reached nobody. - expect(events[0].context).toMatchObject({ - previous_attributes: { context: { access_type: 'none', configured_targets: { organizations: [] } } }, + // the flag reached nobody. Only the rule changed, so `data` is absent rather than + // restating the flag's unchanged attributes as "previous". + expect(events[0].context!.previous_attributes).toEqual({ + context: { access_type: 'none', configured_targets: { organizations: [], users: [] } }, }); }); @@ -302,6 +296,57 @@ describe('Feature Flags routes', () => { await req(`/feature-flags/beta/targets/${org.id}`, { method: 'POST' }); expect(ruleEvents()[0].context).toMatchObject({ access_type: 'all' }); }); + + it('names the calling API key as the flag.rule_updated actor when the key has a record', async () => { + seedFlag('beta', { default_value: false }); + const org = seedOrg('Acme'); + const record = getWorkOSStore(store).apiKeyRecords.insert({ + object: 'api_key', + name: 'CI key', + key: 'sk_test_org', + environment: 'test', + owner: { type: 'organization', id: org.id }, + permissions: [], + last_used_at: null, + expires_at: null, + }); + + await req(`/feature-flags/beta/targets/${org.id}`, { method: 'POST' }); + expect(ruleEvents()[0].context).toMatchObject({ + client_id: 'workos-emulate', + actor: { id: record.id, source: 'api', name: 'CI key' }, + }); + }); + + it('falls back to the placeholder actor when the calling key has no record', async () => { + // A map-form apiKeys entry authenticates but has no api_key resource behind it. + seedFlag('beta', { default_value: false }); + const org = seedOrg('Acme'); + await req(`/feature-flags/beta/targets/${org.id}`, { method: 'POST' }); + expect(ruleEvents()[0].context).toMatchObject({ + actor: { id: 'api_key_emulator', source: 'api', name: 'Emulator API key' }, + }); + }); + + it("drops a user's targets when the user is deleted", async () => { + seedFlag('beta', { default_value: false }); + const user = seedUser(); + await req(`/feature-flags/beta/targets/${user.id}`, { method: 'POST' }); + + expect((await req(`/user_management/users/${user.id}`, { method: 'DELETE' })).status).toBe(204); + expect(getWorkOSStore(store).flagTargets.all()).toEqual([]); + const body = await json(await req('/sdk/feature-flags')); + expect(body.beta.targets.users).toEqual([]); + }); + + it("drops an organization's targets when the organization is deleted", async () => { + seedFlag('beta', { default_value: false }); + const org = seedOrg(); + await req(`/feature-flags/beta/targets/${org.id}`, { method: 'POST' }); + + expect((await req(`/organizations/${org.id}`, { method: 'DELETE' })).status).toBe(204); + expect(getWorkOSStore(store).flagTargets.all()).toEqual([]); + }); }); describe('GET /sdk/feature-flags (runtime client polling)', () => { diff --git a/src/workos/routes/feature-flags.ts b/src/workos/routes/feature-flags.ts index decefd9..fe62c39 100644 --- a/src/workos/routes/feature-flags.ts +++ b/src/workos/routes/feature-flags.ts @@ -1,7 +1,7 @@ import { type RouteContext, WorkOSApiError, cursorPaginate, notFound, parseListParams } from '../../core/index.js'; import { getWorkOSStore, type WorkOSStore } from '../store.js'; import type { WorkOSFeatureFlag } from '../entities.js'; -import { formatFeatureFlag, formatFeatureFlagEvent, formatListResponse } from '../helpers.js'; +import { apiKeyActor, formatFeatureFlag, formatFeatureFlagEvent, formatListResponse } from '../helpers.js'; import { environmentIdFor, flagRuleState, flagRuleUpdatedContext } from '../flag-context.js'; import { EVENTS, STORE_KEYS } from '../constants.js'; import type { EventBus } from '../event-bus.js'; @@ -82,17 +82,21 @@ export function featureFlagRoutes(ctx: RouteContext): void { * this cannot ride on the collection's update hook. `previous` is the rule state captured * before the mutation — the spec marks `previous_attributes` required on this event. * + * The actor is the API key that made the request, resolved the way Vault's `updated_by` is; + * this is the one flag event emitted with a request in hand, so it is the one that can say + * who acted rather than falling back to the emulator's placeholder key. + * * The environment is the emulator's default rather than the caller's: flags are not * environment-scoped in the store, so reporting the requesting key's environment here would * make this event disagree with the collection-hook events about the same flag. */ - const emitRuleUpdated = (flag: WorkOSFeatureFlag, previous: Record) => { + const emitRuleUpdated = (flag: WorkOSFeatureFlag, previous: Record, apiKey?: string) => { const environmentId = environmentIdFor(); store.getData(STORE_KEYS.eventBus)?.emit({ event: EVENTS.flagRuleUpdated, data: formatFeatureFlagEvent(flag, environmentId), environment_id: environmentId, - context: flagRuleUpdatedContext(ws, flag, previous), + context: flagRuleUpdatedContext(ws, flag, previous, apiKeyActor(ws, apiKey)), }); }; @@ -145,12 +149,12 @@ export function featureFlagRoutes(ctx: RouteContext): void { // Documented as POST; PUT is accepted too, as the emulator previously exposed it there. app.post('/feature-flags/:slug/targets/:resourceId', (c) => { const { flag, changed, previous } = addTarget(c.req.param('slug'), c.req.param('resourceId')); - if (changed && previous) emitRuleUpdated(flag, previous); + if (changed && previous) emitRuleUpdated(flag, previous, c.get('auth')?.apiKey); return c.body(null, 204); }); app.put('/feature-flags/:slug/targets/:resourceId', (c) => { const { flag, changed, previous } = addTarget(c.req.param('slug'), c.req.param('resourceId')); - if (changed && previous) emitRuleUpdated(flag, previous); + if (changed && previous) emitRuleUpdated(flag, previous, c.get('auth')?.apiKey); return c.body(null, 204); }); @@ -172,7 +176,7 @@ export function featureFlagRoutes(ctx: RouteContext): void { if (target) { const previous = flagRuleState(ws, flag); ws.flagTargets.delete(target.id); - emitRuleUpdated(flag, previous); + emitRuleUpdated(flag, previous, c.get('auth')?.apiKey); } return c.body(null, 204); }); diff --git a/src/workos/routes/organizations.ts b/src/workos/routes/organizations.ts index e063d49..1bf5d5c 100644 --- a/src/workos/routes/organizations.ts +++ b/src/workos/routes/organizations.ts @@ -170,6 +170,9 @@ export function organizationRoutes(ctx: RouteContext): void { ws.organizationDomains.deleteBy('organization_id', org.id); ws.organizationMemberships.deleteBy('organization_id', org.id); + // Same as the user cascade: an organization target with no organization behind it is + // unreachable through the target routes. + ws.flagTargets.deleteBy('resource_id', org.id); // Both kinds of key scoped to this org go with it — the org's own, and members' keys // issued inside it, which the membership deletion above has just left unbacked. Same // ownership test the org listing route applies, so nothing it would list survives. diff --git a/src/workos/routes/users.ts b/src/workos/routes/users.ts index e35fca0..3622cab 100644 --- a/src/workos/routes/users.ts +++ b/src/workos/routes/users.ts @@ -141,6 +141,10 @@ export function userRoutes(ctx: RouteContext): void { for (const f of ws.authFactors.findBy('user_id', user.id)) { ws.authFactors.delete(f.id); } + // Flag targets name the user by id. Left behind, they would keep the user in every + // flag.rule_updated `configured_targets` and could never be removed over the API, since + // the target routes 404 on the missing user before they look at the target. + ws.flagTargets.deleteBy('resource_id', user.id); for (const i of ws.identities.findBy('user_id', user.id)) { ws.identities.delete(i.id); } diff --git a/src/workos/routes/vault.ts b/src/workos/routes/vault.ts index d891794..904b0d7 100644 --- a/src/workos/routes/vault.ts +++ b/src/workos/routes/vault.ts @@ -2,6 +2,7 @@ import { createHash, randomUUID } from 'node:crypto'; import { type RouteContext, parseJsonBody, parseListParams } from '../../core/index.js'; import type { WorkOSVaultObject, WorkOSVaultObjectVersion } from '../entities.js'; import { getWorkOSStore } from '../store.js'; +import { apiKeyActor } from '../helpers.js'; const error = (message: string) => ({ error: message }); @@ -45,10 +46,7 @@ export function vaultRoutes(ctx: RouteContext): void { }; const findByName = (name: string, environment?: string) => ws.vaultObjects.findBy('name', name).find((object) => object.environment_id === environmentIdFor(environment)); - const actorFor = (apiKey?: string) => { - const record = apiKey ? ws.apiKeyRecords.findOneBy('key', apiKey) : undefined; - return { id: record?.id ?? 'api_key_emulator', name: record?.name ?? 'Emulator API key' }; - }; + const actorFor = (apiKey?: string) => apiKeyActor(ws, apiKey); app.get('/vault/v1/kv', (c) => { const url = new URL(c.req.url); diff --git a/src/workos/seed-feature-flags.spec.ts b/src/workos/seed-feature-flags.spec.ts index 34a4852..e3ca5b5 100644 --- a/src/workos/seed-feature-flags.spec.ts +++ b/src/workos/seed-feature-flags.spec.ts @@ -278,6 +278,29 @@ describe('Seeding feature flags', () => { expect(errors.find((e) => e.path === 'featureFlags[1].slug')).toBeDefined(); }); + it('rejects a slug that is not URL-safe', () => { + // Every route addresses a flag by slug in the path, so a slug needing percent-encoding + // would seed fine and then be unreachable. + const { valid, errors } = validateSeedConfig({ + featureFlags: [{ slug: 'beta/dashboard' }, { slug: 'has space' }, { slug: 'fine-slug_1.0~' }], + }); + expect(valid).toBe(false); + expect(errors.map((e) => e.path)).toEqual(['featureFlags[0].slug', 'featureFlags[1].slug']); + }); + + it('reports a non-string organization target as a type error, not an unknown name', () => { + const { valid, errors } = validateSeedConfig({ + organizations: [{ name: 'Acme Corp' }], + featureFlags: [{ slug: 'a', targets: { organizations: [42 as unknown as string] } }], + }); + expect(valid).toBe(false); + expect(errors).toHaveLength(1); + expect(errors[0]).toMatchObject({ + path: 'featureFlags[0].targets.organizations[0]', + message: 'targets.organizations entries must be names of organizations defined in `organizations`', + }); + }); + it('rejects a non-boolean default_value', () => { const { valid, errors } = validateSeedConfig({ featureFlags: [{ slug: 'typed', default_value: 'variant-a' as unknown as boolean }],