diff --git a/README.md b/README.md index e061928..46f8583 100644 --- a/README.md +++ b/README.md @@ -512,6 +512,128 @@ 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…`). 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: + +- **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 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 }); +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 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. + +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. + +`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/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-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/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..9a08844 100644 --- a/src/workos/config-validator.ts +++ b/src/workos/config-validator.ts @@ -802,6 +802,200 @@ 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}`; + + // 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({ + 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 (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 }); + } 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 (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}]`), + 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..27b64c8 --- /dev/null +++ b/src/workos/flag-context.ts @@ -0,0 +1,93 @@ +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'}`; +} + +/** `{ 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, + * `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 — 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(actor: FlagActor = PLACEHOLDER_ACTOR): Record { + return { + client_id: 'workos-emulate', + 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); + return { + access_type: flagAccessType(ws, flag), + configured_targets: { + organizations: targets + .filter((t) => t.resource_type === 'organization') + .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') + .flatMap((t) => { + const user = ws.users.get(t.resource_id); + return user ? [{ id: user.id, email: user.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. + * + * 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(actor), + ...flagRuleState(ws, flag), + 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 { - 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 }; +} + +/** + * The actor an API-key request acts as — flag event `context.actor`, Vault `updated_by`. The + * key's record when the caller's key has one (an array-form `apiKeys` seed entry, or a key + * created over the API), otherwise the emulator's standing placeholder: a map-form `apiKeys` + * entry authenticates but has no `api_key` resource behind it, so there is nothing to name. + */ +export function apiKeyActor(ws: WorkOSStore, apiKey?: string): { id: string; name: string } { + const record = apiKey ? ws.apiKeyRecords.findOneBy('key', apiKey) : undefined; + return { id: record?.id ?? 'api_key_emulator', name: record?.name ?? 'Emulator API key' }; } /** Generate a Connect Application client_id, e.g. `client_01HXYZ...`. */ diff --git a/src/workos/index.ts b/src/workos/index.ts index 4a09dfc..1ffa736 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,46 @@ 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, unique within the environment, and URL-safe: + * every route addresses a flag by slug in the path. + */ + 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 +372,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 +761,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 +1030,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/response-envelopes.spec.ts b/src/workos/response-envelopes.spec.ts index c2e7ebb..2d9d9c6 100644 --- a/src/workos/response-envelopes.spec.ts +++ b/src/workos/response-envelopes.spec.ts @@ -117,6 +117,15 @@ const CASES: readonly EnvelopeCase[] = [ operation: 'GET /organizations/{organizationId}/api_keys', request: (app, f) => 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/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..72ae368 100644 --- a/src/workos/routes/feature-flags.spec.ts +++ b/src/workos/routes/feature-flags.spec.ts @@ -2,10 +2,18 @@ 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`, 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 }); } @@ -23,117 +31,473 @@ 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, + status: 'active' | 'inactive' | 'pending' = 'active', + ) { + return getWorkOSStore(store).organizationMemberships.insert({ + object: 'organization_membership', + organization_id: organizationId, + user_id: userId, + role: { slug: 'member' }, + status, + 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('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('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('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); + }); + + 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); + }); + } - // Add target - const addRes = await req('/feature-flags/dark-mode/targets/user_123', { - method: 'PUT', - body: JSON.stringify({ value: false, resource_type: 'user' }), + 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. 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: [] } }, + }); + }); + + 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('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([]); + }); }); - 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 }], + }); + }); - 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); + 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' }); + + // 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']); + }); - const res = await req('/user_management/users/user_123/feature-flags'); - const list = await json(res); - expect(list.data[0].value).toBe(null); + 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(); + // 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 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..fe62c39 100644 --- a/src/workos/routes/feature-flags.ts +++ b/src/workos/routes/feature-flags.ts @@ -1,134 +1,233 @@ -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 { 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'; -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; } /** - * 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. + * 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) + .filter((m) => m.status === 'active') + .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: 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 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, 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, apiKeyActor(ws, apiKey)), + }); + }; - // 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 existing = ws.flagTargets.findBy('flag_slug', flag.slug).find((t) => t.resource_id === resourceId); + if (existing) return { flag, changed: false, previous: undefined }; - const target = ws.flagTargets.insert({ + 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, 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, c.get('auth')?.apiKey); + 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(); - ws.flagTargets.delete(target.id); + // 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'); + + 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, c.get('auth')?.apiKey); + } 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/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 new file mode 100644 index 0000000..e3ca5b5 --- /dev/null +++ b/src/workos/seed-feature-flags.spec.ts @@ -0,0 +1,311 @@ +/** + * 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('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' }], + }); + expect(valid).toBe(false); + 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 }], + }); + expect(valid).toBe(false); + expect(errors.find((e) => e.path === 'featureFlags[0].default_value')).toBeDefined(); + }); +});