From 1341dd49ec6944247c5c19dc2c0bf041508da2e0 Mon Sep 17 00:00:00 2001 From: "workos-tars[bot]" <269013284+workos-tars[bot]@users.noreply.github.com> Date: Wed, 2 Sep 2026 13:20:32 +0000 Subject: [PATCH 01/20] fix(permissions): emit SDK-required response fields --- src/workos/helpers.ts | 6 +++++- src/workos/response-shapes.spec.ts | 2 -- src/workos/routes/authorization-permissions.spec.ts | 1 + 3 files changed, 6 insertions(+), 3 deletions(-) diff --git a/src/workos/helpers.ts b/src/workos/helpers.ts index 447e4d4..17fd1b6 100644 --- a/src/workos/helpers.ts +++ b/src/workos/helpers.ts @@ -904,7 +904,11 @@ export function formatRole(role: WorkOSRole): Record { } export function formatPermission(p: WorkOSPermission): Record { - return formatEntity(p); + return { + ...formatEntity(p), + system: false, + resource_type_slug: 'organization', + }; } export function formatAuthorizationResource(r: WorkOSAuthorizationResource): Record { diff --git a/src/workos/response-shapes.spec.ts b/src/workos/response-shapes.spec.ts index 4314ac3..20193d4 100644 --- a/src/workos/response-shapes.spec.ts +++ b/src/workos/response-shapes.spec.ts @@ -199,8 +199,6 @@ const KNOWN_MISSING_REQUIRED: Record = { // The emulator's Role predates the spec's authorization Role: it has no // `permissions` array or `resource_type_slug`. role: ['permissions', 'resource_type_slug'], - // The emulator's Permission lacks the spec's `resource_type_slug` and `system`. - permission: ['resource_type_slug', 'system'], }; /** diff --git a/src/workos/routes/authorization-permissions.spec.ts b/src/workos/routes/authorization-permissions.spec.ts index f8eaff0..883349e 100644 --- a/src/workos/routes/authorization-permissions.spec.ts +++ b/src/workos/routes/authorization-permissions.spec.ts @@ -29,6 +29,7 @@ describe('Authorization permission routes', () => { expect(perm.object).toBe('permission'); expect(perm.slug).toBe('posts:read'); expect(perm.name).toBe('Read Posts'); + expect(perm).toMatchObject({ system: false, resource_type_slug: 'organization' }); expect(perm.id).toMatch(/^perm_/); }); From e84719d90d9179d9c1964bb47b1b589505f2dc72 Mon Sep 17 00:00:00 2001 From: "workos-tars[bot]" <269013284+workos-tars[bot]@users.noreply.github.com> Date: Wed, 2 Sep 2026 20:28:34 +0000 Subject: [PATCH 02/20] fix(permissions): preserve resource type scope --- src/workos/entities.ts | 1 + src/workos/helpers.ts | 2 +- src/workos/index.ts | 1 + .../routes/authorization-permissions.spec.ts | 17 +++++++++++++++++ src/workos/routes/authorization-permissions.ts | 7 +++++++ 5 files changed, 27 insertions(+), 1 deletion(-) diff --git a/src/workos/entities.ts b/src/workos/entities.ts index cfa88d8..0133bac 100644 --- a/src/workos/entities.ts +++ b/src/workos/entities.ts @@ -321,6 +321,7 @@ export interface WorkOSPermission extends Entity { slug: string; name: string; description: string | null; + resource_type_slug?: string; } export interface WorkOSRolePermission extends Entity { diff --git a/src/workos/helpers.ts b/src/workos/helpers.ts index 17fd1b6..d11432e 100644 --- a/src/workos/helpers.ts +++ b/src/workos/helpers.ts @@ -907,7 +907,7 @@ export function formatPermission(p: WorkOSPermission): Record { return { ...formatEntity(p), system: false, - resource_type_slug: 'organization', + resource_type_slug: p.resource_type_slug ?? 'organization', }; } diff --git a/src/workos/index.ts b/src/workos/index.ts index 4a09dfc..687f0ff 100644 --- a/src/workos/index.ts +++ b/src/workos/index.ts @@ -570,6 +570,7 @@ export function seedFromConfig(store: Store, _baseUrl: string, config: WorkOSSee slug: permConfig.slug, name: permConfig.name, description: permConfig.description ?? null, + resource_type_slug: 'organization', }); } } diff --git a/src/workos/routes/authorization-permissions.spec.ts b/src/workos/routes/authorization-permissions.spec.ts index 883349e..18212ba 100644 --- a/src/workos/routes/authorization-permissions.spec.ts +++ b/src/workos/routes/authorization-permissions.spec.ts @@ -33,6 +33,15 @@ describe('Authorization permission routes', () => { expect(perm.id).toMatch(/^perm_/); }); + it('preserves a permission resource type', async () => { + const res = await req('/authorization/permissions', { + method: 'POST', + body: JSON.stringify({ slug: 'documents:read', name: 'Read Documents', resource_type_slug: 'document' }), + }); + expect(res.status).toBe(201); + expect((await json(res)).resource_type_slug).toBe('document'); + }); + it('rejects duplicate slug', async () => { await req('/authorization/permissions', { method: 'POST', @@ -53,6 +62,14 @@ describe('Authorization permission routes', () => { expect(res.status).toBe(422); }); + it('rejects an invalid resource type', async () => { + const res = await req('/authorization/permissions', { + method: 'POST', + body: JSON.stringify({ slug: 'invalid:scope', name: 'Invalid Scope', resource_type_slug: 42 }), + }); + expect(res.status).toBe(422); + }); + it('lists permissions', async () => { await req('/authorization/permissions', { method: 'POST', diff --git a/src/workos/routes/authorization-permissions.ts b/src/workos/routes/authorization-permissions.ts index 1bfcc97..52e8d9f 100644 --- a/src/workos/routes/authorization-permissions.ts +++ b/src/workos/routes/authorization-permissions.ts @@ -10,6 +10,7 @@ export function authorizationPermissionRoutes(ctx: RouteContext): void { const body = await parseJsonBody(c); const slug = body.slug as string; const name = body.name as string; + const resourceTypeSlug = body.resource_type_slug; if (!slug || typeof slug !== 'string') { throw validationError('slug is required', [{ field: 'slug', code: 'required' }]); @@ -17,6 +18,11 @@ export function authorizationPermissionRoutes(ctx: RouteContext): void { if (!name || typeof name !== 'string') { throw validationError('name is required', [{ field: 'name', code: 'required' }]); } + if (resourceTypeSlug !== undefined && (typeof resourceTypeSlug !== 'string' || !resourceTypeSlug)) { + throw validationError('resource_type_slug must be a non-empty string', [ + { field: 'resource_type_slug', code: 'invalid' }, + ]); + } const existing = ws.permissions.findOneBy('slug', slug); if (existing) { @@ -28,6 +34,7 @@ export function authorizationPermissionRoutes(ctx: RouteContext): void { slug, name, description: (body.description as string) ?? null, + resource_type_slug: resourceTypeSlug ?? 'organization', }); return c.json(formatPermission(permission), 201); From ffca24ba15a07eb76fdc130f4de81f7fc91fc222 Mon Sep 17 00:00:00 2001 From: "workos-tars[bot]" <269013284+workos-tars[bot]@users.noreply.github.com> Date: Wed, 2 Sep 2026 20:34:19 +0000 Subject: [PATCH 03/20] fix(seed): preserve permission resource type scope --- src/workos/config-validator.ts | 10 +++++++++ src/workos/index.ts | 3 ++- .../routes/authorization-permissions.spec.ts | 21 ++++++++++++++++++- 3 files changed, 32 insertions(+), 2 deletions(-) diff --git a/src/workos/config-validator.ts b/src/workos/config-validator.ts index d12102f..0dcc942 100644 --- a/src/workos/config-validator.ts +++ b/src/workos/config-validator.ts @@ -591,6 +591,16 @@ export function validateSeedConfig(config: WorkOSSeedConfig): ConfigValidationRe value: perm.name, }); } + if ( + perm.resource_type_slug !== undefined && + (typeof perm.resource_type_slug !== 'string' || !perm.resource_type_slug) + ) { + errors.push({ + path: `permissions[${index}].resource_type_slug`, + message: 'resource_type_slug must be a non-empty string if provided', + value: perm.resource_type_slug, + }); + } }); } } diff --git a/src/workos/index.ts b/src/workos/index.ts index 687f0ff..f56ff2f 100644 --- a/src/workos/index.ts +++ b/src/workos/index.ts @@ -233,6 +233,7 @@ export interface WorkOSSeedPermission { slug: string; name: string; description?: string; + resource_type_slug?: string; } export interface WorkOSSeedWebhookEndpoint { @@ -570,7 +571,7 @@ export function seedFromConfig(store: Store, _baseUrl: string, config: WorkOSSee slug: permConfig.slug, name: permConfig.name, description: permConfig.description ?? null, - resource_type_slug: 'organization', + resource_type_slug: permConfig.resource_type_slug ?? 'organization', }); } } diff --git a/src/workos/routes/authorization-permissions.spec.ts b/src/workos/routes/authorization-permissions.spec.ts index 18212ba..433b4b9 100644 --- a/src/workos/routes/authorization-permissions.spec.ts +++ b/src/workos/routes/authorization-permissions.spec.ts @@ -1,6 +1,7 @@ import { describe, it, expect, beforeEach } from 'bun:test'; import { createServer, type ApiKeyMap } from '../../core/index.js'; -import { workosPlugin } from '../index.js'; +import { workosPlugin, seedFromConfig } from '../index.js'; +import { validateSeedConfig } from '../config-validator.js'; const apiKeys: ApiKeyMap = { sk_test_perm: { environment: 'test' } }; const headers = { Authorization: 'Bearer sk_test_perm', 'Content-Type': 'application/json' }; @@ -42,6 +43,24 @@ describe('Authorization permission routes', () => { expect((await json(res)).resource_type_slug).toBe('document'); }); + it('preserves a seeded permission resource type', async () => { + const server = createTestApp(); + seedFromConfig(server.store, 'http://localhost:0', { + permissions: [{ slug: 'seeded:read', name: 'Seeded Read', resource_type_slug: 'document' }], + }); + const res = await server.app.request('/authorization/permissions/seeded:read', { headers }); + expect(res.status).toBe(200); + expect((await json(res)).resource_type_slug).toBe('document'); + }); + + it('rejects an invalid seeded resource type', () => { + const result = validateSeedConfig({ + permissions: [{ slug: 'invalid:seed', name: 'Invalid Seed', resource_type_slug: 42 as unknown as string }], + }); + expect(result.valid).toBe(false); + expect(result.errors.some((error) => error.path === 'permissions[0].resource_type_slug')).toBe(true); + }); + it('rejects duplicate slug', async () => { await req('/authorization/permissions', { method: 'POST', From 603654c7292180361d2def57a3b4453a0048631e Mon Sep 17 00:00:00 2001 From: "Garen J. Torikian" Date: Thu, 3 Sep 2026 14:11:13 -0400 Subject: [PATCH 04/20] refactor(permissions): share the resource type default The `organization` fallback was spelled out independently at both persistence sites and again in the formatter, so changing it (or making it configurable) would have to land in three places. Review of #97 also found the new seed key undocumented and the empty-string rejection path untested; this closes those gaps in the same pass. --- README.md | 3 ++ src/workos/constants.ts | 8 +++++ src/workos/helpers.ts | 13 +++++-- src/workos/index.ts | 4 +-- .../routes/authorization-permissions.spec.ts | 36 ++++++++++++++----- .../routes/authorization-permissions.ts | 5 ++- 6 files changed, 55 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index d1a9e7f..bc4602e 100644 --- a/README.md +++ b/README.md @@ -269,6 +269,9 @@ permissions: name: Read Posts - slug: posts:write name: Write Posts + - slug: documents:read + name: Read Documents + resource_type_slug: document # optional; defaults to organization ``` ### Pinning organization and user ids diff --git a/src/workos/constants.ts b/src/workos/constants.ts index 6c53ddc..b483235 100644 --- a/src/workos/constants.ts +++ b/src/workos/constants.ts @@ -17,6 +17,14 @@ export const STORE_KEY_PREFIXES = { radarIpList: 'radar_ip_list', } as const; +/** + * Resource type a permission is scoped to when the caller supplies none. + * Production scopes permissions to the built-in `organization` resource type + * by default; the emulator does the same so every response carries the + * spec-required `resource_type_slug`. + */ +export const DEFAULT_PERMISSION_RESOURCE_TYPE_SLUG = 'organization'; + /** * WorkOS event catalog, generated from the OpenAPI spec. * Regenerate with: npm run gen:events -- path/to/open-api-spec.yaml diff --git a/src/workos/helpers.ts b/src/workos/helpers.ts index d11432e..932c1cd 100644 --- a/src/workos/helpers.ts +++ b/src/workos/helpers.ts @@ -11,7 +11,13 @@ import { type Entity, type Store, } from '../core/index.js'; -import { EVENTS, STORE_KEYS, type AuthenticationEventData, type WorkOSEventName } from './constants.js'; +import { + DEFAULT_PERMISSION_RESOURCE_TYPE_SLUG, + EVENTS, + STORE_KEYS, + type AuthenticationEventData, + type WorkOSEventName, +} from './constants.js'; import type { WorkOSStore } from './store.js'; import type { EventBus } from './event-bus.js'; import type { @@ -906,8 +912,11 @@ export function formatRole(role: WorkOSRole): Record { export function formatPermission(p: WorkOSPermission): Record { return { ...formatEntity(p), + // The emulator has no WorkOS-managed system permissions; everything is user-defined. system: false, - resource_type_slug: p.resource_type_slug ?? 'organization', + // Rows inserted without a scope (direct store inserts, pre-scope releases) + // still format with the default so the spec-required key is always present. + resource_type_slug: p.resource_type_slug ?? DEFAULT_PERMISSION_RESOURCE_TYPE_SLUG, }; } diff --git a/src/workos/index.ts b/src/workos/index.ts index f56ff2f..5a833cb 100644 --- a/src/workos/index.ts +++ b/src/workos/index.ts @@ -41,7 +41,7 @@ import { dataIntegrationRoutes } from './routes/data-integrations.js'; import { webhookEndpointRoutes } from './routes/webhook-endpoints.js'; import { eventRoutes } from './routes/events.js'; import { EventBus } from './event-bus.js'; -import { STORE_KEYS, EVENTS } from './constants.js'; +import { STORE_KEYS, EVENTS, DEFAULT_PERMISSION_RESOURCE_TYPE_SLUG } from './constants.js'; import { validateSeedConfig, formatValidationErrors } from './config-validator.js'; import { validateJwtTemplateContent } from './jwt-template.js'; import { @@ -571,7 +571,7 @@ export function seedFromConfig(store: Store, _baseUrl: string, config: WorkOSSee slug: permConfig.slug, name: permConfig.name, description: permConfig.description ?? null, - resource_type_slug: permConfig.resource_type_slug ?? 'organization', + resource_type_slug: permConfig.resource_type_slug ?? DEFAULT_PERMISSION_RESOURCE_TYPE_SLUG, }); } } diff --git a/src/workos/routes/authorization-permissions.spec.ts b/src/workos/routes/authorization-permissions.spec.ts index 433b4b9..de30e78 100644 --- a/src/workos/routes/authorization-permissions.spec.ts +++ b/src/workos/routes/authorization-permissions.spec.ts @@ -54,11 +54,26 @@ describe('Authorization permission routes', () => { }); it('rejects an invalid seeded resource type', () => { - const result = validateSeedConfig({ - permissions: [{ slug: 'invalid:seed', name: 'Invalid Seed', resource_type_slug: 42 as unknown as string }], + for (const resource_type_slug of [42 as unknown as string, '']) { + const result = validateSeedConfig({ + permissions: [{ slug: 'invalid:seed', name: 'Invalid Seed', resource_type_slug }], + }); + expect(result.valid).toBe(false); + expect(result.errors.some((error) => error.path === 'permissions[0].resource_type_slug')).toBe(true); + } + }); + + it('keeps the resource type when a permission is updated', async () => { + await req('/authorization/permissions', { + method: 'POST', + body: JSON.stringify({ slug: 'documents:write', name: 'Write Documents', resource_type_slug: 'document' }), + }); + const res = await req('/authorization/permissions/documents:write', { + method: 'PUT', + body: JSON.stringify({ name: 'Edit Documents' }), }); - expect(result.valid).toBe(false); - expect(result.errors.some((error) => error.path === 'permissions[0].resource_type_slug')).toBe(true); + expect(res.status).toBe(200); + expect(await json(res)).toMatchObject({ name: 'Edit Documents', resource_type_slug: 'document' }); }); it('rejects duplicate slug', async () => { @@ -82,11 +97,14 @@ describe('Authorization permission routes', () => { }); it('rejects an invalid resource type', async () => { - const res = await req('/authorization/permissions', { - method: 'POST', - body: JSON.stringify({ slug: 'invalid:scope', name: 'Invalid Scope', resource_type_slug: 42 }), - }); - expect(res.status).toBe(422); + for (const resource_type_slug of [42, '']) { + const res = await req('/authorization/permissions', { + method: 'POST', + body: JSON.stringify({ slug: 'invalid:scope', name: 'Invalid Scope', resource_type_slug }), + }); + expect(res.status).toBe(422); + expect((await json(res)).errors).toEqual([{ field: 'resource_type_slug', code: 'invalid' }]); + } }); it('lists permissions', async () => { diff --git a/src/workos/routes/authorization-permissions.ts b/src/workos/routes/authorization-permissions.ts index 52e8d9f..db77546 100644 --- a/src/workos/routes/authorization-permissions.ts +++ b/src/workos/routes/authorization-permissions.ts @@ -1,6 +1,7 @@ import { type RouteContext, notFound, validationError, parseJsonBody, parseListParams } from '../../core/index.js'; import { getWorkOSStore } from '../store.js'; import { formatPermission, formatListResponse } from '../helpers.js'; +import { DEFAULT_PERMISSION_RESOURCE_TYPE_SLUG } from '../constants.js'; export function authorizationPermissionRoutes(ctx: RouteContext): void { const { app, store } = ctx; @@ -18,6 +19,8 @@ export function authorizationPermissionRoutes(ctx: RouteContext): void { if (!name || typeof name !== 'string') { throw validationError('name is required', [{ field: 'name', code: 'required' }]); } + // Resource types are not modeled by the emulator (no registry, no endpoint), + // so any non-empty slug is accepted. Production requires a defined type. if (resourceTypeSlug !== undefined && (typeof resourceTypeSlug !== 'string' || !resourceTypeSlug)) { throw validationError('resource_type_slug must be a non-empty string', [ { field: 'resource_type_slug', code: 'invalid' }, @@ -34,7 +37,7 @@ export function authorizationPermissionRoutes(ctx: RouteContext): void { slug, name, description: (body.description as string) ?? null, - resource_type_slug: resourceTypeSlug ?? 'organization', + resource_type_slug: resourceTypeSlug ?? DEFAULT_PERMISSION_RESOURCE_TYPE_SLUG, }); return c.json(formatPermission(permission), 201); From 8219fe1f7cffea9cf05b8f47dca7523dd767c31c Mon Sep 17 00:00:00 2001 From: "workos-tars[bot]" <269013284+workos-tars[bot]@users.noreply.github.com> Date: Thu, 3 Sep 2026 18:13:40 +0000 Subject: [PATCH 05/20] chore: retry arm64 CI From 5cbb98c8bf230f14def719bc183a37dc29f2c3b9 Mon Sep 17 00:00:00 2001 From: "workos-tars[bot]" <269013284+workos-tars[bot]@users.noreply.github.com> Date: Thu, 3 Sep 2026 18:16:17 +0000 Subject: [PATCH 06/20] ci: use IPv4 for arm64 container smoke test --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ec1259a..f4c1a48 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -151,4 +151,4 @@ jobs: # `--retry-all-errors` retries on TCP reset (curl 56) too, not just # connection-refused (curl 7). The container is created before the # Node app finishes binding :4100, so the first probe can hit a RST. - curl --retry 10 --retry-delay 1 --retry-all-errors --fail http://localhost:4100/health + curl --retry 10 --retry-delay 1 --retry-all-errors --fail http://127.0.0.1:4100/health From d3b13b3ab68ab4ddbd4bdd11f0db389caf1f50fd Mon Sep 17 00:00:00 2001 From: "workos-tars[bot]" <269013284+workos-tars[bot]@users.noreply.github.com> Date: Thu, 3 Sep 2026 18:17:44 +0000 Subject: [PATCH 07/20] Revert "ci: use IPv4 for arm64 container smoke test" This reverts commit 5cbb98c8bf230f14def719bc183a37dc29f2c3b9. --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f4c1a48..ec1259a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -151,4 +151,4 @@ jobs: # `--retry-all-errors` retries on TCP reset (curl 56) too, not just # connection-refused (curl 7). The container is created before the # Node app finishes binding :4100, so the first probe can hit a RST. - curl --retry 10 --retry-delay 1 --retry-all-errors --fail http://127.0.0.1:4100/health + curl --retry 10 --retry-delay 1 --retry-all-errors --fail http://localhost:4100/health From 1b211ef5cc80779c6603801d87459019a6dcdecf Mon Sep 17 00:00:00 2001 From: "workos-tars[bot]" <269013284+workos-tars[bot]@users.noreply.github.com> Date: Thu, 3 Sep 2026 18:20:21 +0000 Subject: [PATCH 08/20] ci: allow time for container key generation --- .github/workflows/ci.yml | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ec1259a..5d338c2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -122,10 +122,9 @@ jobs: run: | docker run --rm -d -p 4100:4100 --name emulate-ci emulate:ci trap 'docker stop emulate-ci 2>/dev/null || true' EXIT - # `--retry-all-errors` retries on TCP reset (curl 56) too, not just - # connection-refused (curl 7). The container is created before the - # Node app finishes binding :4100, so the first probe can hit a RST. - curl --retry 10 --retry-delay 1 --retry-all-errors --fail http://localhost:4100/health + # RSA key generation happens before the Node app binds :4100 and can vary + # under shared runner load. Retry TCP resets/refusals until it is ready. + curl --retry 60 --retry-delay 1 --retry-all-errors --fail http://localhost:4100/health docker-arm64: name: Docker build (arm64) @@ -148,7 +147,6 @@ jobs: run: | docker run --rm -d -p 4100:4100 --name emulate-ci emulate:ci trap 'docker stop emulate-ci 2>/dev/null || true' EXIT - # `--retry-all-errors` retries on TCP reset (curl 56) too, not just - # connection-refused (curl 7). The container is created before the - # Node app finishes binding :4100, so the first probe can hit a RST. - curl --retry 10 --retry-delay 1 --retry-all-errors --fail http://localhost:4100/health + # RSA key generation happens before the Node app binds :4100 and can vary + # under shared runner load. Retry TCP resets/refusals until it is ready. + curl --retry 60 --retry-delay 1 --retry-all-errors --fail http://localhost:4100/health From e638b8e88b26a1e8065d56b581ce0336302aa18e Mon Sep 17 00:00:00 2001 From: "workos-tars[bot]" <269013284+workos-tars[bot]@users.noreply.github.com> Date: Thu, 3 Sep 2026 18:24:26 +0000 Subject: [PATCH 09/20] Revert "ci: allow time for container key generation" This reverts commit 1b211ef5cc80779c6603801d87459019a6dcdecf. --- .github/workflows/ci.yml | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5d338c2..ec1259a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -122,9 +122,10 @@ jobs: run: | docker run --rm -d -p 4100:4100 --name emulate-ci emulate:ci trap 'docker stop emulate-ci 2>/dev/null || true' EXIT - # RSA key generation happens before the Node app binds :4100 and can vary - # under shared runner load. Retry TCP resets/refusals until it is ready. - curl --retry 60 --retry-delay 1 --retry-all-errors --fail http://localhost:4100/health + # `--retry-all-errors` retries on TCP reset (curl 56) too, not just + # connection-refused (curl 7). The container is created before the + # Node app finishes binding :4100, so the first probe can hit a RST. + curl --retry 10 --retry-delay 1 --retry-all-errors --fail http://localhost:4100/health docker-arm64: name: Docker build (arm64) @@ -147,6 +148,7 @@ jobs: run: | docker run --rm -d -p 4100:4100 --name emulate-ci emulate:ci trap 'docker stop emulate-ci 2>/dev/null || true' EXIT - # RSA key generation happens before the Node app binds :4100 and can vary - # under shared runner load. Retry TCP resets/refusals until it is ready. - curl --retry 60 --retry-delay 1 --retry-all-errors --fail http://localhost:4100/health + # `--retry-all-errors` retries on TCP reset (curl 56) too, not just + # connection-refused (curl 7). The container is created before the + # Node app finishes binding :4100, so the first probe can hit a RST. + curl --retry 10 --retry-delay 1 --retry-all-errors --fail http://localhost:4100/health From ddaaf3033a8f1d519b08d2e5e84e8693c5978d70 Mon Sep 17 00:00:00 2001 From: "Garen J. Torikian" Date: Thu, 3 Sep 2026 15:10:22 -0400 Subject: [PATCH 10/20] chore(deps): bump @workos/openapi-spec from ^0.59.0 to ^0.80.0 The pin had fallen 21 minor versions behind the published spec, so the committed event catalog knew nothing of the agent and resource-export events and SUPPORTED.md measured coverage against a stale endpoint list. 0.80 also retags the agents endpoints and adds IT contacts, waitlists, and platform teams; the support-matrix generator refuses unassigned tags, so those are mapped to features here. --- SUPPORTED.md | 13 ++-- bun.lock | 4 +- package.json | 2 +- scripts/gen-supported-lib.ts | 20 +++++-- src/workos/generated/events.ts | 105 ++++++++++++++++++++++++++++++++- 5 files changed, 128 insertions(+), 16 deletions(-) diff --git a/SUPPORTED.md b/SUPPORTED.md index de06d01..531b6e1 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 **159 of 250** endpoints in the WorkOS OpenAPI spec (`@workos/openapi-spec@0.80.0`) (**63.6%**). 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 @@ -19,16 +19,16 @@ answers "can I actually emulate this?". | 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. | +| Organizations | ⚠️ 5/6 | ⚠️ 6/10 | ✅ seed `organizations` | IT contact endpoints are not implemented. | +| User Management | ⚠️ 8/11 | ⚠️ 7/13 | ✅ seed `users` | Email-change confirm/send and waitlist 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. | +| SSO | ⚠️ 5/8 | ⚠️ 4/11 | ✅ 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. | +| FGA / Authorization | ⚠️ 15/19 | ⚠️ 14/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. | @@ -42,7 +42,8 @@ answers "can I actually emulate this?". | Admin Portal | — | ✅ 1/1 | ⚠️ API only | Generates a portal link; the portal itself is not served. | | Widgets | — | ✅ 1/1 | ⚠️ API only | Mints widget tokens only. | | Radar | — | ⚠️ 1/4 | ⚠️ API only | Attempt listing only; no risk signals are computed. | -| Agents | ❌ 0/1 | ❌ 0/2 | ❌ none | Not implemented. | +| Agents | ❌ 0/7 | ❌ 0/9 | ❌ none | Not implemented. | +| Platform Teams | ❌ 0/1 | ❌ 0/1 | ❌ none | Not implemented. | ## How this file is generated diff --git a/bun.lock b/bun.lock index 7a5789a..07ace0c 100644 --- a/bun.lock +++ b/bun.lock @@ -15,7 +15,7 @@ "@types/bun": "^1.3.14", "@types/node": "~22.19.7", "@types/semver": "^7.7.1", - "@workos/openapi-spec": "^0.59.0", + "@workos/openapi-spec": "^0.80.0", "husky": "^9.1.7", "oxfmt": "^0.62.0", "oxlint": "^1.77.0", @@ -148,7 +148,7 @@ "@typescript/typescript-win32-x64": ["@typescript/typescript-win32-x64@7.0.2", "", { "os": "win32", "cpu": "x64" }, "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g=="], - "@workos/openapi-spec": ["@workos/openapi-spec@0.59.0", "", {}, "sha512-AGmrXTYdA1OluGb9vDtC21HQVqavVtVv5WpNjyBPo6OviBFfA1whzzkdwcgv0oG0DXnttqr0NsRQa7FxP6X0ug=="], + "@workos/openapi-spec": ["@workos/openapi-spec@0.80.0", "", {}, "sha512-jMAJVVvnWEdT3cq8G8H7U7gWJ3gIC19gRmkxB7DEA7YU2NXbhfGOt2Z4GDtspgljRt/GAp/pPT2+B5oaLIUyjw=="], "bun-types": ["bun-types@1.3.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="], diff --git a/package.json b/package.json index e7566c2..89a3909 100644 --- a/package.json +++ b/package.json @@ -50,7 +50,7 @@ "@types/bun": "^1.3.14", "@types/node": "~22.19.7", "@types/semver": "^7.7.1", - "@workos/openapi-spec": "^0.59.0", + "@workos/openapi-spec": "^0.80.0", "husky": "^9.1.7", "oxfmt": "^0.62.0", "oxlint": "^1.77.0", diff --git a/scripts/gen-supported-lib.ts b/scripts/gen-supported-lib.ts index 2d4d3ab..134d7b9 100644 --- a/scripts/gen-supported-lib.ts +++ b/scripts/gen-supported-lib.ts @@ -101,14 +101,15 @@ export interface FeatureDef { export const FEATURES: FeatureDef[] = [ { name: 'Organizations', - tags: ['organizations', 'organization-domains'], + tags: ['organizations', 'organization-domains', 'organizations.it-contacts'], seedKeys: ['organizations'], + notes: 'IT contact endpoints are not implemented.', }, { name: 'User Management', - tags: ['user-management.users', 'user-management.session-tokens'], + tags: ['user-management.users', 'user-management.session-tokens', 'user-management.waitlists'], seedKeys: ['users'], - notes: 'Email-change confirm/send endpoints are not implemented.', + notes: 'Email-change confirm/send and waitlist endpoints are not implemented.', }, { name: 'Authentication', @@ -245,7 +246,18 @@ export const FEATURES: FeatureDef[] = [ }, { name: 'Agents', - tags: ['agents'], + tags: [ + 'agents.blueprints', + 'agents.blueprints.tokens', + 'agents.instances', + 'agents.registrations', + 'agents.sessions', + ], + notes: 'Not implemented.', + }, + { + name: 'Platform Teams', + tags: ['platform.teams'], notes: 'Not implemented.', }, ]; diff --git a/src/workos/generated/events.ts b/src/workos/generated/events.ts index f60bca4..9dfcdab 100644 --- a/src/workos/generated/events.ts +++ b/src/workos/generated/events.ts @@ -8,6 +8,13 @@ export const EVENTS = { actionAuthenticationDenied: 'action.authentication.denied', actionUserRegistrationDenied: 'action.user_registration.denied', + agentBlueprintCreated: 'agent.blueprint.created', + agentBlueprintDeleted: 'agent.blueprint.deleted', + agentBlueprintUpdated: 'agent.blueprint.updated', + agentInstanceCreated: 'agent.instance.created', + agentInstanceDeleted: 'agent.instance.deleted', + agentInstanceSessionCreated: 'agent.instance.session.created', + agentInstanceSessionRevoked: 'agent.instance.session.revoked', agentRegistrationClaimAttemptCreated: 'agent.registration.claim.attempt.created', agentRegistrationClaimCompleted: 'agent.registration.claim.completed', agentRegistrationCreated: 'agent.registration.created', @@ -94,6 +101,10 @@ export const EVENTS = { pipesConnectedAccountDisconnected: 'pipes.connected_account.disconnected', pipesConnectedAccountReauthorizationNeeded: 'pipes.connected_account.reauthorization_needed', radarChallengeCreated: 'radar.challenge_created', + resourceExportCompleted: 'resource_export.completed', + resourceExportCreated: 'resource_export.created', + resourceExportDownloaded: 'resource_export.downloaded', + resourceExportFailed: 'resource_export.failed', roleCreated: 'role.created', roleDeleted: 'role.deleted', roleUpdated: 'role.updated', @@ -123,6 +134,13 @@ export type WorkOSEventName = (typeof EVENTS)[keyof typeof EVENTS]; /** Event names subscribable via webhook endpoints (CreateWebhookEndpointDto). */ export const SUBSCRIBABLE_EVENTS: readonly WorkOSEventName[] = [ + 'agent.blueprint.created', + 'agent.blueprint.deleted', + 'agent.blueprint.updated', + 'agent.instance.created', + 'agent.instance.deleted', + 'agent.instance.session.created', + 'agent.instance.session.revoked', 'agent.registration.claim.attempt.created', 'agent.registration.claim.completed', 'agent.registration.created', @@ -227,6 +245,7 @@ export interface AuthenticationEventData { user_id: string | null; email: string | null; error?: { code: string; message: string }; + provider?: string; sso?: { organization_id: string | null; connection_id: string | null; session_id: string | null }; } @@ -260,6 +279,82 @@ export const EVENT_DATA_REQUIREMENTS: Record Date: Thu, 3 Sep 2026 15:10:23 -0400 Subject: [PATCH 11/20] fix(permissions): serve PATCH and 409 slug conflicts The spec and every SDK update a permission with PATCH, but the emulator only registered PUT, so the Node SDK's updatePermission never reached the handler. Nothing outside the emulator ever sent PUT, so it is dropped rather than kept as an alias. A taken slug now answers 409 permission_slug_conflict as production does; the former 422 field error never carried the code clients key on. --- .../routes/authorization-permissions.spec.ts | 7 ++++--- src/workos/routes/authorization-permissions.ts | 15 ++++++++++++--- 2 files changed, 16 insertions(+), 6 deletions(-) diff --git a/src/workos/routes/authorization-permissions.spec.ts b/src/workos/routes/authorization-permissions.spec.ts index de30e78..b1487c2 100644 --- a/src/workos/routes/authorization-permissions.spec.ts +++ b/src/workos/routes/authorization-permissions.spec.ts @@ -69,7 +69,7 @@ describe('Authorization permission routes', () => { body: JSON.stringify({ slug: 'documents:write', name: 'Write Documents', resource_type_slug: 'document' }), }); const res = await req('/authorization/permissions/documents:write', { - method: 'PUT', + method: 'PATCH', body: JSON.stringify({ name: 'Edit Documents' }), }); expect(res.status).toBe(200); @@ -85,7 +85,8 @@ describe('Authorization permission routes', () => { method: 'POST', body: JSON.stringify({ slug: 'dup', name: 'Dup 2' }), }); - expect(res.status).toBe(422); + expect(res.status).toBe(409); + expect((await json(res)).code).toBe('permission_slug_conflict'); }); it('rejects missing slug', async () => { @@ -145,7 +146,7 @@ describe('Authorization permission routes', () => { body: JSON.stringify({ slug: 'upd', name: 'Original' }), }); const res = await req('/authorization/permissions/upd', { - method: 'PUT', + method: 'PATCH', body: JSON.stringify({ name: 'Updated', description: 'desc' }), }); expect(res.status).toBe(200); diff --git a/src/workos/routes/authorization-permissions.ts b/src/workos/routes/authorization-permissions.ts index db77546..7f1fec9 100644 --- a/src/workos/routes/authorization-permissions.ts +++ b/src/workos/routes/authorization-permissions.ts @@ -1,4 +1,11 @@ -import { type RouteContext, notFound, validationError, parseJsonBody, parseListParams } from '../../core/index.js'; +import { + type RouteContext, + WorkOSApiError, + notFound, + validationError, + parseJsonBody, + parseListParams, +} from '../../core/index.js'; import { getWorkOSStore } from '../store.js'; import { formatPermission, formatListResponse } from '../helpers.js'; import { DEFAULT_PERMISSION_RESOURCE_TYPE_SLUG } from '../constants.js'; @@ -29,7 +36,8 @@ export function authorizationPermissionRoutes(ctx: RouteContext): void { const existing = ws.permissions.findOneBy('slug', slug); if (existing) { - throw validationError('Permission with this slug already exists', [{ field: 'slug', code: 'duplicate' }]); + // Production answers a taken slug with 409 permission_slug_conflict, not a 422 field error. + throw new WorkOSApiError(409, 'Permission with this slug already exists', 'permission_slug_conflict'); } const permission = ws.permissions.insert({ @@ -58,7 +66,8 @@ export function authorizationPermissionRoutes(ctx: RouteContext): void { return c.json(formatPermission(permission)); }); - app.put('/authorization/permissions/:slug', async (c) => { + // The spec (and every SDK) updates a permission with PATCH; there is no PUT. + app.patch('/authorization/permissions/:slug', async (c) => { const slug = c.req.param('slug'); const permission = ws.permissions.findOneBy('slug', slug); if (!permission) throw notFound('Permission'); From 073fa569baec90f465000a118a0f158a7d9b4ecc Mon Sep 17 00:00:00 2001 From: "Garen J. Torikian" Date: Thu, 3 Sep 2026 15:10:23 -0400 Subject: [PATCH 12/20] test(permissions): table-drive invalid resource type cases A loop inside one `it` reports a bare mismatch without saying which input tripped; `it.each` names the failing value in the test title. --- .../routes/authorization-permissions.spec.ts | 30 ++++++++----------- 1 file changed, 13 insertions(+), 17 deletions(-) diff --git a/src/workos/routes/authorization-permissions.spec.ts b/src/workos/routes/authorization-permissions.spec.ts index b1487c2..a3a0de2 100644 --- a/src/workos/routes/authorization-permissions.spec.ts +++ b/src/workos/routes/authorization-permissions.spec.ts @@ -53,14 +53,12 @@ describe('Authorization permission routes', () => { expect((await json(res)).resource_type_slug).toBe('document'); }); - it('rejects an invalid seeded resource type', () => { - for (const resource_type_slug of [42 as unknown as string, '']) { - const result = validateSeedConfig({ - permissions: [{ slug: 'invalid:seed', name: 'Invalid Seed', resource_type_slug }], - }); - expect(result.valid).toBe(false); - expect(result.errors.some((error) => error.path === 'permissions[0].resource_type_slug')).toBe(true); - } + it.each([[42 as unknown as string], ['']])('rejects an invalid seeded resource type %p', (resource_type_slug) => { + const result = validateSeedConfig({ + permissions: [{ slug: 'invalid:seed', name: 'Invalid Seed', resource_type_slug }], + }); + expect(result.valid).toBe(false); + expect(result.errors.some((error) => error.path === 'permissions[0].resource_type_slug')).toBe(true); }); it('keeps the resource type when a permission is updated', async () => { @@ -97,15 +95,13 @@ describe('Authorization permission routes', () => { expect(res.status).toBe(422); }); - it('rejects an invalid resource type', async () => { - for (const resource_type_slug of [42, '']) { - const res = await req('/authorization/permissions', { - method: 'POST', - body: JSON.stringify({ slug: 'invalid:scope', name: 'Invalid Scope', resource_type_slug }), - }); - expect(res.status).toBe(422); - expect((await json(res)).errors).toEqual([{ field: 'resource_type_slug', code: 'invalid' }]); - } + it.each([[42], ['']])('rejects an invalid resource type %p', async (resource_type_slug) => { + const res = await req('/authorization/permissions', { + method: 'POST', + body: JSON.stringify({ slug: 'invalid:scope', name: 'Invalid Scope', resource_type_slug }), + }); + expect(res.status).toBe(422); + expect((await json(res)).errors).toEqual([{ field: 'resource_type_slug', code: 'invalid' }]); }); it('lists permissions', async () => { From 2720cf1c229ed3600e4e2f3ef9d97be04cc481e8 Mon Sep 17 00:00:00 2001 From: "Garen J. Torikian" Date: Thu, 3 Sep 2026 15:27:16 -0400 Subject: [PATCH 13/20] fix(roles): serve PATCH and 409 slug conflicts Same divergence as permissions: the spec and the Node SDK update environment and organization roles with PATCH, so updateRole and updateOrganizationRole never reached the emulator's PUT handler. A taken slug now answers 409 with the spec's role_slug_conflict or organization_role_slug_conflict code instead of a 422 field error, which never carried the code clients key on. --- src/workos/role-helpers.ts | 17 ++++++++++++++--- .../routes/authorization-org-roles.spec.ts | 5 +++-- src/workos/routes/authorization-org-roles.ts | 1 + src/workos/routes/authorization-roles.spec.ts | 5 +++-- src/workos/routes/authorization-roles.ts | 1 + 5 files changed, 22 insertions(+), 7 deletions(-) diff --git a/src/workos/role-helpers.ts b/src/workos/role-helpers.ts index 04ec60d..9045951 100644 --- a/src/workos/role-helpers.ts +++ b/src/workos/role-helpers.ts @@ -1,5 +1,12 @@ import type { Context } from 'hono'; -import { type RouteContext, notFound, validationError, parseJsonBody, parseListParams } from '../core/index.js'; +import { + type RouteContext, + WorkOSApiError, + notFound, + validationError, + parseJsonBody, + parseListParams, +} from '../core/index.js'; import type { WorkOSStore } from './store.js'; import type { WorkOSRole, WorkOSPermission } from './entities.js'; import { getWorkOSStore } from './store.js'; @@ -71,6 +78,8 @@ export interface RoleRouteConfig { listFilter: (c: Context) => (r: WorkOSRole) => boolean; insertDefaults: (c: Context) => Partial; duplicateMessage: string; + /** Spec error code for a taken slug: `role_slug_conflict` or `organization_role_slug_conflict`. */ + duplicateCode: string; validateBeforeCreate?: (ws: WorkOSStore, c: Context) => void; } @@ -95,7 +104,8 @@ export function registerRoleRoutes(ctx: RouteContext, config: RoleRouteConfig): const existing = config.findRole(ws, c, slug); if (existing) { - throw validationError(config.duplicateMessage, [{ field: 'slug', code: 'duplicate' }]); + // Production answers a taken slug with 409, not a 422 field error. + throw new WorkOSApiError(409, config.duplicateMessage, config.duplicateCode); } const defaults = config.insertDefaults(c); @@ -130,7 +140,8 @@ export function registerRoleRoutes(ctx: RouteContext, config: RoleRouteConfig): return c.json(formatRole(role)); }); - app.put(`${pathPrefix}/:slug`, async (c) => { + // The spec (and every SDK) updates a role with PATCH; there is no PUT. + app.patch(`${pathPrefix}/:slug`, async (c) => { const role = config.requireRole(ws, c); const body = await parseJsonBody(c); diff --git a/src/workos/routes/authorization-org-roles.spec.ts b/src/workos/routes/authorization-org-roles.spec.ts index acb80f0..96c5f08 100644 --- a/src/workos/routes/authorization-org-roles.spec.ts +++ b/src/workos/routes/authorization-org-roles.spec.ts @@ -50,7 +50,8 @@ describe('Authorization org role routes', () => { method: 'POST', body: JSON.stringify({ slug: 'dup', name: 'Dup 2' }), }); - expect(res.status).toBe(422); + expect(res.status).toBe(409); + expect((await json(res)).code).toBe('organization_role_slug_conflict'); }); it('allows same slug in different orgs', async () => { @@ -105,7 +106,7 @@ describe('Authorization org role routes', () => { body: JSON.stringify({ slug: 'upd', name: 'Original' }), }); const res = await req(`/authorization/organizations/${org.id}/roles/upd`, { - method: 'PUT', + method: 'PATCH', body: JSON.stringify({ name: 'Updated' }), }); expect(res.status).toBe(200); diff --git a/src/workos/routes/authorization-org-roles.ts b/src/workos/routes/authorization-org-roles.ts index f09c2d5..4ff1b12 100644 --- a/src/workos/routes/authorization-org-roles.ts +++ b/src/workos/routes/authorization-org-roles.ts @@ -49,6 +49,7 @@ export function authorizationOrgRoleRoutes(ctx: RouteContext): void { listFilter: (c) => (r) => r.organization_id === c.req.param('orgId')! && r.type === 'OrganizationRole', insertDefaults: (c) => ({ organization_id: c.req.param('orgId')! }), duplicateMessage: 'Role with this slug already exists in this organization', + duplicateCode: 'organization_role_slug_conflict', validateBeforeCreate: (ws, c) => { const org = ws.organizations.get(c.req.param('orgId')!); if (!org) throw notFound('Organization'); diff --git a/src/workos/routes/authorization-roles.spec.ts b/src/workos/routes/authorization-roles.spec.ts index 0dc606b..e24338e 100644 --- a/src/workos/routes/authorization-roles.spec.ts +++ b/src/workos/routes/authorization-roles.spec.ts @@ -42,7 +42,8 @@ describe('Authorization environment role routes', () => { method: 'POST', body: JSON.stringify({ slug: 'dup', name: 'Dup 2' }), }); - expect(res.status).toBe(422); + expect(res.status).toBe(409); + expect((await json(res)).code).toBe('role_slug_conflict'); }); it('lists environment roles', async () => { @@ -77,7 +78,7 @@ describe('Authorization environment role routes', () => { body: JSON.stringify({ slug: 'upd', name: 'Original' }), }); const res = await req('/authorization/roles/upd', { - method: 'PUT', + method: 'PATCH', body: JSON.stringify({ name: 'Updated', description: 'new desc' }), }); expect(res.status).toBe(200); diff --git a/src/workos/routes/authorization-roles.ts b/src/workos/routes/authorization-roles.ts index 9feecd4..a3efc60 100644 --- a/src/workos/routes/authorization-roles.ts +++ b/src/workos/routes/authorization-roles.ts @@ -10,5 +10,6 @@ export function authorizationRoleRoutes(ctx: RouteContext): void { listFilter: () => (r) => r.type === 'EnvironmentRole', insertDefaults: () => ({ organization_id: null }), duplicateMessage: 'Role with this slug already exists', + duplicateCode: 'role_slug_conflict', }); } From bcd2c909dc023ab8c83aeca8f0dcf6bd7fc884cc Mon Sep 17 00:00:00 2001 From: "Garen J. Torikian" Date: Thu, 3 Sep 2026 15:46:02 -0400 Subject: [PATCH 14/20] fix(roles): set, add, and remove permissions per spec The spec and the Node SDK replace a role's permissions with PUT and attach a single one with POST, both answering with the role, which production returns with its permission slugs inlined. The emulator gave POST replace-all semantics, had no PUT, and answered DELETE with an empty 204, so setEnvironmentRolePermissions had no handler and addEnvironmentRolePermission was rejected as malformed. Roles now carry `permissions` wherever they are formatted, which also closes that tracked gap in the response-shape ledger. --- src/workos/helpers.ts | 10 +++- src/workos/index.ts | 6 +- src/workos/response-shapes.spec.ts | 6 +- src/workos/role-helpers.ts | 43 +++++++++----- .../routes/authorization-checks.spec.ts | 6 +- .../routes/authorization-org-roles.spec.ts | 5 +- src/workos/routes/authorization-org-roles.ts | 5 +- .../routes/authorization-permissions.spec.ts | 2 +- src/workos/routes/authorization-roles.spec.ts | 57 +++++++++++++++++-- 9 files changed, 106 insertions(+), 34 deletions(-) diff --git a/src/workos/helpers.ts b/src/workos/helpers.ts index 10956e5..e9df392 100644 --- a/src/workos/helpers.ts +++ b/src/workos/helpers.ts @@ -908,8 +908,14 @@ export function formatAuthChallenge(c: WorkOSAuthenticationChallenge): Record { - return formatEntity(role); +export function formatRole(role: WorkOSRole, ws: WorkOSStore): Record { + // Production inlines the role's permission slugs; the emulator keeps them in a + // join table, so resolve them here rather than at every call site. + const permissions = ws.rolePermissions + .findBy('role_id', role.id) + .map((rp) => ws.permissions.get(rp.permission_id)?.slug) + .filter((slug): slug is string => typeof slug === 'string'); + return { ...formatEntity(role), permissions }; } export function formatPermission(p: WorkOSPermission): Record { diff --git a/src/workos/index.ts b/src/workos/index.ts index 8cc230c..1a61e30 100644 --- a/src/workos/index.ts +++ b/src/workos/index.ts @@ -994,17 +994,17 @@ export const workosPlugin: ServicePlugin = { onInsert: (r) => eventBus.emit({ event: r.type === 'OrganizationRole' ? EVENTS.organizationRoleCreated : EVENTS.roleCreated, - data: formatRole(r), + data: formatRole(r, ws), }), onUpdate: (r) => eventBus.emit({ event: r.type === 'OrganizationRole' ? EVENTS.organizationRoleUpdated : EVENTS.roleUpdated, - data: formatRole(r), + data: formatRole(r, ws), }), onDelete: (r) => eventBus.emit({ event: r.type === 'OrganizationRole' ? EVENTS.organizationRoleDeleted : EVENTS.roleDeleted, - data: formatRole(r), + data: formatRole(r, ws), }), }); ws.permissions.setHooks({ diff --git a/src/workos/response-shapes.spec.ts b/src/workos/response-shapes.spec.ts index 6aee154..36d4606 100644 --- a/src/workos/response-shapes.spec.ts +++ b/src/workos/response-shapes.spec.ts @@ -214,7 +214,7 @@ const CASES: ReadonlyArray<{ objectType: string; output: Record { objectType: 'directory', output: formatDirectory(directory) }, { objectType: 'directory_group', output: formatDirectoryGroup(directoryGroup) }, { objectType: 'directory_user', output: formatDirectoryUser(directoryUser) }, - { objectType: 'role', output: formatRole(role) }, + { objectType: 'role', output: formatRole(role, ws) }, { objectType: 'permission', output: formatPermission(permission) }, { objectType: 'api_key', output: formatApiKeyRecord(apiKey) }, { objectType: 'password_reset', output: formatPasswordReset(passwordReset) }, @@ -230,8 +230,8 @@ const KNOWN_MISSING_REQUIRED: Record = { // WorkOSConnection carries only `state`. connection: ['status'], // The emulator's Role predates the spec's authorization Role: it has no - // `permissions` array or `resource_type_slug`. - role: ['permissions', 'resource_type_slug'], + // `resource_type_slug`. + role: ['resource_type_slug'], }; /** diff --git a/src/workos/role-helpers.ts b/src/workos/role-helpers.ts index 9045951..3d72a0d 100644 --- a/src/workos/role-helpers.ts +++ b/src/workos/role-helpers.ts @@ -120,7 +120,7 @@ export function registerRoleRoutes(ctx: RouteContext, config: RoleRouteConfig): priority: typeof body.priority === 'number' ? body.priority : 0, }); - return c.json(formatRole(role), 201); + return c.json(formatRole(role, ws), 201); }); app.get(pathPrefix, (c) => { @@ -132,12 +132,12 @@ export function registerRoleRoutes(ctx: RouteContext, config: RoleRouteConfig): filter: config.listFilter(c), }); - return c.json(formatListResponse(result, formatRole)); + return c.json(formatListResponse(result, (r) => formatRole(r, ws))); }); app.get(`${pathPrefix}/:slug`, (c) => { const role = config.requireRole(ws, c); - return c.json(formatRole(role)); + return c.json(formatRole(role, ws)); }); // The spec (and every SDK) updates a role with PATCH; there is no PUT. @@ -152,7 +152,7 @@ export function registerRoleRoutes(ctx: RouteContext, config: RoleRouteConfig): if ('priority' in body) updates.priority = body.priority; const updated = ws.roles.update(role.id, updates); - return c.json(formatRole(updated!)); + return c.json(formatRole(updated!, ws)); }); app.delete(`${pathPrefix}/:slug`, (c) => { @@ -165,7 +165,8 @@ export function registerRoleRoutes(ctx: RouteContext, config: RoleRouteConfig): return c.body(null, 204); }); - // Role permissions management + // Not in the spec — production inlines permission slugs on the role instead. + // Kept as the only way to read the full permission objects for a role. app.get(`${pathPrefix}/:slug/permissions`, (c) => { const role = config.requireRole(ws, c); const permissions = getRolePermissions(ws, role.id); @@ -177,21 +178,35 @@ export function registerRoleRoutes(ctx: RouteContext, config: RoleRouteConfig): }); }); - app.post(`${pathPrefix}/:slug/permissions`, async (c) => { + // Spec: PUT replaces the whole set, POST attaches one; both answer with the role. + app.put(`${pathPrefix}/:slug/permissions`, async (c) => { const role = config.requireRole(ws, c); const body = await parseJsonBody(c); - const permissionSlugs = body.permissions as string[]; - if (!Array.isArray(permissionSlugs)) { + const permissionSlugs = body.permissions; + if (!Array.isArray(permissionSlugs) || permissionSlugs.some((slug) => typeof slug !== 'string')) { throw validationError('permissions must be an array of slugs', [{ field: 'permissions', code: 'invalid' }]); } - const permissions = replaceRolePermissions(ws, role.id, permissionSlugs); + replaceRolePermissions(ws, role.id, permissionSlugs as string[]); + return c.json(formatRole(role, ws)); + }); - return c.json({ - object: 'list', - data: permissions.map((p) => formatPermission(p)), - list_metadata: { before: null, after: null }, - }); + app.post(`${pathPrefix}/:slug/permissions`, async (c) => { + const role = config.requireRole(ws, c); + + const body = await parseJsonBody(c); + const slug = body.slug; + if (!slug || typeof slug !== 'string') { + throw validationError('slug is required', [{ field: 'slug', code: 'required' }]); + } + const permission = ws.permissions.findOneBy('slug', slug); + if (!permission) throw notFound('Permission'); + + // Re-attaching is a no-op rather than a duplicate join row. + const attached = ws.rolePermissions.findBy('role_id', role.id).some((rp) => rp.permission_id === permission.id); + if (!attached) ws.rolePermissions.insert({ role_id: role.id, permission_id: permission.id }); + + return c.json(formatRole(role, ws)); }); } diff --git a/src/workos/routes/authorization-checks.spec.ts b/src/workos/routes/authorization-checks.spec.ts index 6dc57af..8d6b5ae 100644 --- a/src/workos/routes/authorization-checks.spec.ts +++ b/src/workos/routes/authorization-checks.spec.ts @@ -65,7 +65,7 @@ describe('Authorization check + role assignment routes', () => { body: JSON.stringify({ slug: 'editor', name: 'Editor' }), }); await req('/authorization/roles/editor/permissions', { - method: 'POST', + method: 'PUT', body: JSON.stringify({ permissions: ['posts:read', 'posts:write'] }), }); @@ -76,7 +76,7 @@ describe('Authorization check + role assignment routes', () => { }); const adminRole = await json(adminRes); await req('/authorization/roles/admin-role/permissions', { - method: 'POST', + method: 'PUT', body: JSON.stringify({ permissions: ['admin:manage'] }), }); @@ -511,7 +511,7 @@ describe('Authorization check + role assignment routes', () => { body: JSON.stringify({ slug: 'editor', name: 'Org Editor' }), }); await req(`/authorization/organizations/${org.id}/roles/editor/permissions`, { - method: 'POST', + method: 'PUT', body: JSON.stringify({ permissions: ['posts:read'] }), }); diff --git a/src/workos/routes/authorization-org-roles.spec.ts b/src/workos/routes/authorization-org-roles.spec.ts index 96c5f08..c0c84d3 100644 --- a/src/workos/routes/authorization-org-roles.spec.ts +++ b/src/workos/routes/authorization-org-roles.spec.ts @@ -171,7 +171,7 @@ describe('Authorization org role routes', () => { // Set permissions await req(`/authorization/organizations/${org.id}/roles/org-editor/permissions`, { - method: 'POST', + method: 'PUT', body: JSON.stringify({ permissions: ['org-read', 'org-write'] }), }); @@ -184,7 +184,8 @@ describe('Authorization org role routes', () => { const delRes = await req(`/authorization/organizations/${org.id}/roles/org-editor/permissions/org-write`, { method: 'DELETE', }); - expect(delRes.status).toBe(204); + expect(delRes.status).toBe(200); + expect((await json(delRes)).permissions).toEqual(['org-read']); // Verify removal const afterRes = await req(`/authorization/organizations/${org.id}/roles/org-editor/permissions`); diff --git a/src/workos/routes/authorization-org-roles.ts b/src/workos/routes/authorization-org-roles.ts index 4ff1b12..377e035 100644 --- a/src/workos/routes/authorization-org-roles.ts +++ b/src/workos/routes/authorization-org-roles.ts @@ -36,7 +36,7 @@ export function authorizationOrgRoleRoutes(ctx: RouteContext): void { return c.json({ object: 'list', - data: updated.map(formatRole), + data: updated.map((r) => formatRole(r, ws)), list_metadata: { before: null, after: null }, }); }); @@ -67,6 +67,7 @@ export function authorizationOrgRoleRoutes(ctx: RouteContext): void { if (!rp) throw notFound('RolePermission'); ws.rolePermissions.delete(rp.id); - return c.body(null, 204); + // The spec answers with the updated role, not an empty 204. + return c.json(formatRole(role, ws)); }); } diff --git a/src/workos/routes/authorization-permissions.spec.ts b/src/workos/routes/authorization-permissions.spec.ts index a3a0de2..e9fcf9d 100644 --- a/src/workos/routes/authorization-permissions.spec.ts +++ b/src/workos/routes/authorization-permissions.spec.ts @@ -174,7 +174,7 @@ describe('Authorization permission routes', () => { body: JSON.stringify({ slug: 'cascade-role', name: 'Cascade Role' }), }); await req('/authorization/roles/cascade-role/permissions', { - method: 'POST', + method: 'PUT', body: JSON.stringify({ permissions: ['cascade-perm'] }), }); diff --git a/src/workos/routes/authorization-roles.spec.ts b/src/workos/routes/authorization-roles.spec.ts index e24338e..efbb3db 100644 --- a/src/workos/routes/authorization-roles.spec.ts +++ b/src/workos/routes/authorization-roles.spec.ts @@ -118,12 +118,17 @@ describe('Authorization environment role routes', () => { // Set permissions const setRes = await req('/authorization/roles/editor/permissions', { - method: 'POST', + method: 'PUT', body: JSON.stringify({ permissions: ['read', 'write'] }), }); expect(setRes.status).toBe(200); const setBody = await json(setRes); - expect(setBody.data.length).toBe(2); + expect(setBody.object).toBe('role'); + expect([...setBody.permissions].sort()).toEqual(['read', 'write']); + + // The role itself carries the slugs, as in production + const roleRes = await req('/authorization/roles/editor'); + expect([...(await json(roleRes)).permissions].sort()).toEqual(['read', 'write']); // Get permissions const getRes = await req('/authorization/roles/editor/permissions'); @@ -149,13 +154,13 @@ describe('Authorization environment role routes', () => { // Set to p1 await req('/authorization/roles/rep/permissions', { - method: 'POST', + method: 'PUT', body: JSON.stringify({ permissions: ['p1'] }), }); // Replace with p2 await req('/authorization/roles/rep/permissions', { - method: 'POST', + method: 'PUT', body: JSON.stringify({ permissions: ['p2'] }), }); @@ -165,6 +170,50 @@ describe('Authorization environment role routes', () => { expect(body.data[0].slug).toBe('p2'); }); + it('adds a single permission with POST', async () => { + for (const slug of ['add-a', 'add-b']) { + await req('/authorization/permissions', { method: 'POST', body: JSON.stringify({ slug, name: slug }) }); + } + await req('/authorization/roles', { method: 'POST', body: JSON.stringify({ slug: 'adder', name: 'Adder' }) }); + await req('/authorization/roles/adder/permissions', { + method: 'PUT', + body: JSON.stringify({ permissions: ['add-a'] }), + }); + + const res = await req('/authorization/roles/adder/permissions', { + method: 'POST', + body: JSON.stringify({ slug: 'add-b' }), + }); + expect(res.status).toBe(200); + expect([...(await json(res)).permissions].sort()).toEqual(['add-a', 'add-b']); + + // Re-attaching is a no-op, not a duplicate + const again = await req('/authorization/roles/adder/permissions', { + method: 'POST', + body: JSON.stringify({ slug: 'add-b' }), + }); + expect([...(await json(again)).permissions].sort()).toEqual(['add-a', 'add-b']); + }); + + it('rejects malformed permission changes', async () => { + await req('/authorization/roles', { method: 'POST', body: JSON.stringify({ slug: 'strict', name: 'Strict' }) }); + + const missing = await req('/authorization/roles/strict/permissions', { + method: 'POST', + body: JSON.stringify({ slug: 'nope' }), + }); + expect(missing.status).toBe(404); + + const noSlug = await req('/authorization/roles/strict/permissions', { method: 'POST', body: JSON.stringify({}) }); + expect(noSlug.status).toBe(422); + + const notArray = await req('/authorization/roles/strict/permissions', { + method: 'PUT', + body: JSON.stringify({ permissions: 'read' }), + }); + expect(notArray.status).toBe(422); + }); + it('creates role with default flag', async () => { const res = await req('/authorization/roles', { method: 'POST', From d57d813ce8c16cb9d59e214fe83156f1b62589f9 Mon Sep 17 00:00:00 2001 From: "Garen J. Torikian" Date: Thu, 3 Sep 2026 15:46:02 -0400 Subject: [PATCH 15/20] fix(supported): parse role helper routes from source The matrix expanded registerRoleRoutes from a hand-kept mirror of the helper's routes, which had already drifted: it still listed PUT for role updates after the switch to PATCH, so SUPPORTED.md kept scoring FGA at 14/26 writes. Reading the registrations out of role-helpers.ts removes the second copy, and the generator now fails if it finds none rather than silently dropping every role endpoint. --- SUPPORTED.md | 4 +-- scripts/gen-supported-lib.spec.ts | 52 ++++++++++++++++++++++++++++--- scripts/gen-supported-lib.ts | 43 +++++++++++++++---------- scripts/gen-supported.ts | 12 ++++++- 4 files changed, 87 insertions(+), 24 deletions(-) diff --git a/SUPPORTED.md b/SUPPORTED.md index 20224ac..781a52e 100644 --- a/SUPPORTED.md +++ b/SUPPORTED.md @@ -2,7 +2,7 @@ # Supported Features -The emulator implements **162 of 250** endpoints in the WorkOS OpenAPI spec (`@workos/openapi-spec@0.80.0`) (**64.8%**). +The emulator implements **166 of 250** endpoints in the WorkOS OpenAPI spec (`@workos/openapi-spec@0.80.0`) (**66.4%**). 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 @@ -28,7 +28,7 @@ answers "can I actually emulate this?". | SSO | ⚠️ 5/8 | ⚠️ 4/11 | ✅ 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 | ⚠️ 14/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. | +| FGA / Authorization | ⚠️ 15/19 | ⚠️ 18/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. | diff --git a/scripts/gen-supported-lib.spec.ts b/scripts/gen-supported-lib.spec.ts index 70e98c0..e28c099 100644 --- a/scripts/gen-supported-lib.spec.ts +++ b/scripts/gen-supported-lib.spec.ts @@ -1,4 +1,5 @@ import { describe, it, expect } from 'bun:test'; +import { readFileSync } from 'node:fs'; import { type SupportSpec, type FeatureDef, @@ -8,6 +9,7 @@ import { routeKey, parseSpecOperations, parseEmulatorRoutes, + parseRoleHelperRoutes, parseSeedConfigKeys, deriveSetup, buildMatrix, @@ -62,6 +64,19 @@ const fixtureRouteSource = ` app.delete('/directories/:id', (c) => {}); `; +// Mirrors how registerRoleRoutes registers under its pathPrefix parameter. +const fixtureRoleHelperSource = ` + app.post(pathPrefix, async (c) => {}); + app.get(pathPrefix, (c) => {}); + app.get(\`\${pathPrefix}/:slug\`, (c) => {}); + app.patch(\`\${pathPrefix}/:slug\`, async (c) => {}); + app.delete(\`\${pathPrefix}/:slug\`, (c) => {}); + app.get(\`\${pathPrefix}/:slug/permissions\`, (c) => {}); + app.put(\`\${pathPrefix}/:slug/permissions\`, async (c) => {}); + app.post(\`\${pathPrefix}/:slug/permissions\`, async (c) => {}); +`; +const fixtureRoleHelperRoutes = parseRoleHelperRoutes(fixtureRoleHelperSource); + const fixtureSeedKeys = ['organizations', 'users']; function buildFixtureMatrix() { @@ -163,14 +178,15 @@ describe('parseEmulatorRoutes', () => { ` roleType: 'EnvironmentRole',`, `});`, ].join('\n'); - const routes = parseEmulatorRoutes([source]); + const routes = parseEmulatorRoutes([source], fixtureRoleHelperRoutes); expect(routes.map((r) => `${r.method} ${r.path}`)).toEqual([ 'POST /authorization/roles', 'GET /authorization/roles', 'GET /authorization/roles/:slug', - 'PUT /authorization/roles/:slug', + 'PATCH /authorization/roles/:slug', 'DELETE /authorization/roles/:slug', 'GET /authorization/roles/:slug/permissions', + 'PUT /authorization/roles/:slug/permissions', 'POST /authorization/roles/:slug/permissions', ]); }); @@ -183,12 +199,40 @@ describe('parseEmulatorRoutes', () => { ` roleType: 'OrganizationRole',`, `});`, ].join('\n'); - const routes = parseEmulatorRoutes([source]); - expect(routes).toHaveLength(7); + const routes = parseEmulatorRoutes([source], fixtureRoleHelperRoutes); + expect(routes).toHaveLength(8); expect(routes[0]).toEqual({ method: 'POST', path: '/authorization/organizations/:orgId/roles' }); }); }); +describe('parseRoleHelperRoutes', () => { + it('reads every registration under pathPrefix, bare or templated', () => { + expect(fixtureRoleHelperRoutes).toHaveLength(8); + expect(fixtureRoleHelperRoutes[0]).toEqual({ method: 'POST', suffix: '' }); + expect(fixtureRoleHelperRoutes).toContainEqual({ method: 'PATCH', suffix: '/:slug' }); + expect(fixtureRoleHelperRoutes).toContainEqual({ method: 'PUT', suffix: '/:slug/permissions' }); + }); + + it('ignores registrations that are not under pathPrefix', () => { + const routes = parseRoleHelperRoutes(`app.get('/literal', h); app.get(other, h); app.get(\`\${other}/x\`, h);`); + expect(routes).toHaveLength(0); + }); + + it('expands nothing for registerRoleRoutes when no helper routes are supplied', () => { + expect(parseEmulatorRoutes([`registerRoleRoutes(ctx, { pathPrefix: '/authorization/roles' });`])).toHaveLength(0); + }); + + it('still matches the real helper, so the matrix cannot drift from it', () => { + const real = parseRoleHelperRoutes( + readFileSync(new URL('../src/workos/role-helpers.ts', import.meta.url), 'utf-8'), + ); + expect(real.length).toBeGreaterThan(0); + expect(real).toContainEqual({ method: 'PATCH', suffix: '/:slug' }); + expect(real).toContainEqual({ method: 'PUT', suffix: '/:slug/permissions' }); + expect(real).toContainEqual({ method: 'POST', suffix: '/:slug/permissions' }); + }); +}); + describe('parseSeedConfigKeys', () => { const source = ` export interface ErrorHookSeedConfig { diff --git a/scripts/gen-supported-lib.ts b/scripts/gen-supported-lib.ts index 213c15f..0fc507d 100644 --- a/scripts/gen-supported-lib.ts +++ b/scripts/gen-supported-lib.ts @@ -311,33 +311,42 @@ export function parseSpecOperations(spec: SupportSpec): SpecOperation[] { return operations; } +/** A route `registerRoleRoutes` registers beneath each caller's `pathPrefix`. */ +export interface RoleHelperRoute { + method: string; + suffix: string; +} + /** - * Routes that `registerRoleRoutes` in `src/workos/role-helpers.ts` registers - * for each path prefix. Kept in sync with that helper — if it gains or loses - * a route, this list must be updated. + * Read the routes `registerRoleRoutes` (src/workos/role-helpers.ts) registers + * from its own source, so the matrix cannot drift from the helper. A + * registration is either `app.verb(pathPrefix, …)` (empty suffix) or + * `app.verb(\`${pathPrefix}/suffix\`, …)`. */ -const ROLE_HELPER_ROUTES: ReadonlyArray<{ method: string; suffix: string }> = [ - { method: 'POST', suffix: '' }, - { method: 'GET', suffix: '' }, - { method: 'GET', suffix: '/:slug' }, - { method: 'PUT', suffix: '/:slug' }, - { method: 'DELETE', suffix: '/:slug' }, - { method: 'GET', suffix: '/:slug/permissions' }, - { method: 'POST', suffix: '/:slug/permissions' }, -]; +export function parseRoleHelperRoutes(source: string): RoleHelperRoute[] { + const routes: RoleHelperRoute[] = []; + const pattern = /app\.(get|post|put|patch|delete)\((?:pathPrefix|`\$\{pathPrefix\}([^`]*)`)\s*,/g; + for (const match of source.matchAll(pattern)) { + routes.push({ method: match[1].toUpperCase(), suffix: match[2] ?? '' }); + } + return routes; +} /** * Extract route registrations from route source. Handles three patterns: * - `app.method('/literal')` — direct literal paths * - `app.method(`\`${prefix}/suffix\``) — template literals whose variable * is a `const` assigned a literal string earlier in the same file - * - `registerRoleRoutes(ctx, { pathPrefix: … })` — helper that registers a - * known set of routes under the given prefix + * - `registerRoleRoutes(ctx, { pathPrefix: … })` — helper whose routes are + * parsed from its own source (`roleHelperRoutes`) and expanded under the prefix * * Static parsing rather than booting the server keeps codegen free of side * effects (a real boot binds a port and seeds a store). */ -export function parseEmulatorRoutes(sources: string[]): EmulatorRoute[] { +export function parseEmulatorRoutes( + sources: string[], + roleHelperRoutes: ReadonlyArray = [], +): EmulatorRoute[] { const routes: EmulatorRoute[] = []; const literalPattern = /app\.(get|post|put|patch|delete)\('([^']+)'/g; const templatePattern = /app\.(get|post|put|patch|delete)\(`([^`]+)`/g; @@ -370,7 +379,7 @@ export function parseEmulatorRoutes(sources: string[]): EmulatorRoute[] { if (path) routes.push({ method: match[1].toUpperCase(), path }); } - // 4. registerRoleRoutes helper — expand the known routes from pathPrefix + // 4. registerRoleRoutes helper — expand the helper's own routes under each pathPrefix for (const match of source.matchAll(helperPattern)) { let prefix = match[1].trim(); if (prefix.startsWith("'") && prefix.endsWith("'")) { @@ -379,7 +388,7 @@ export function parseEmulatorRoutes(sources: string[]): EmulatorRoute[] { prefix = vars.get(prefix) ?? ''; } if (!prefix) continue; - for (const r of ROLE_HELPER_ROUTES) { + for (const r of roleHelperRoutes) { routes.push({ method: r.method, path: prefix + r.suffix }); } } diff --git a/scripts/gen-supported.ts b/scripts/gen-supported.ts index 875e700..78f6127 100644 --- a/scripts/gen-supported.ts +++ b/scripts/gen-supported.ts @@ -26,12 +26,14 @@ import { type SupportSpec, parseSpecOperations, parseEmulatorRoutes, + parseRoleHelperRoutes, parseSeedConfigKeys, buildMatrix, generateSupportedMarkdown, } from './gen-supported-lib.js'; const ROUTES_DIR = 'src/workos/routes'; +const ROLE_HELPER_FILE = 'src/workos/role-helpers.ts'; const SERVER_FILE = 'src/core/server.ts'; const INDEX_FILE = 'src/index.ts'; @@ -96,7 +98,15 @@ async function main(): Promise { ext === '.yaml' || ext === '.yml' ? (YAML.parse(raw) as SupportSpec) : (JSON.parse(raw) as SupportSpec); const operations = parseSpecOperations(spec); - const routes = parseEmulatorRoutes(readRouteSources()); + // The role helper registers its routes under a caller-supplied prefix, so its + // source is parsed on its own; an empty result means the parser drifted from + // how the helper registers routes, which must fail loudly rather than quietly + // dropping every role endpoint from the table. + const roleHelperRoutes = parseRoleHelperRoutes(readFileSync(resolve(ROLE_HELPER_FILE), 'utf-8')); + if (roleHelperRoutes.length === 0) { + throw new Error(`No route registrations found in ${ROLE_HELPER_FILE}; update parseRoleHelperRoutes.`); + } + const routes = parseEmulatorRoutes(readRouteSources(), roleHelperRoutes); const seedKeys = parseSeedConfigKeys(readFileSync(resolve(INDEX_FILE), 'utf-8')); // buildMatrix throws on an unmapped spec tag or a stale seed key — that is From 88efe11fb3306573cba46f849e1c031c8417a69f Mon Sep 17 00:00:00 2001 From: "Garen J. Torikian" Date: Thu, 3 Sep 2026 16:00:02 -0400 Subject: [PATCH 16/20] feat(roles): scope roles to a resource type The spec requires `resource_type_slug` on every role and on the role lifecycle events, and both create DTOs accept it, but the emulator had no notion of a role's scope and carried it as a tracked gap in the response-shape ledger. Roles now take the scope on create and in the seed file, default to `organization` as production does, and keep it through updates, which the update DTO cannot change. Resource types are still not modeled, so a scope is not checked against a registry and a role's permissions are not checked against its scope; production enforces both. --- README.md | 4 ++ src/workos/config-validator.ts | 10 ++++ src/workos/constants.ts | 8 ++-- src/workos/entities.ts | 1 + src/workos/helpers.ts | 11 +++-- src/workos/index.ts | 6 ++- src/workos/response-shapes.spec.ts | 3 -- src/workos/role-helpers.ts | 11 +++++ .../routes/authorization-org-roles.spec.ts | 11 +++++ .../routes/authorization-permissions.ts | 4 +- src/workos/routes/authorization-roles.spec.ts | 47 ++++++++++++++++++- 11 files changed, 101 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index 0b014f5..55ac9a7 100644 --- a/README.md +++ b/README.md @@ -264,6 +264,10 @@ roles: - slug: admin name: Admin permissions: [posts:read, posts:write] + - slug: document-editor + name: Document Editor + permissions: [documents:read] + resource_type_slug: document # optional; defaults to organization permissions: - slug: posts:read diff --git a/src/workos/config-validator.ts b/src/workos/config-validator.ts index bf8ff49..d1afb4f 100644 --- a/src/workos/config-validator.ts +++ b/src/workos/config-validator.ts @@ -563,6 +563,16 @@ export function validateSeedConfig(config: WorkOSSeedConfig): ConfigValidationRe value: role.type, }); } + if ( + role.resource_type_slug !== undefined && + (typeof role.resource_type_slug !== 'string' || !role.resource_type_slug) + ) { + errors.push({ + path: `roles[${index}].resource_type_slug`, + message: 'resource_type_slug must be a non-empty string if provided', + value: role.resource_type_slug, + }); + } }); } } diff --git a/src/workos/constants.ts b/src/workos/constants.ts index 61b2afd..bdcfc2f 100644 --- a/src/workos/constants.ts +++ b/src/workos/constants.ts @@ -22,12 +22,12 @@ export const STORE_KEY_PREFIXES = { } as const; /** - * Resource type a permission is scoped to when the caller supplies none. - * Production scopes permissions to the built-in `organization` resource type - * by default; the emulator does the same so every response carries the + * Resource type a permission or role is scoped to when the caller supplies + * none. Production scopes both to the built-in `organization` resource type by + * default; the emulator does the same so every response carries the * spec-required `resource_type_slug`. */ -export const DEFAULT_PERMISSION_RESOURCE_TYPE_SLUG = 'organization'; +export const DEFAULT_RESOURCE_TYPE_SLUG = 'organization'; /** * WorkOS event catalog, generated from the OpenAPI spec. diff --git a/src/workos/entities.ts b/src/workos/entities.ts index d0363a8..9a42169 100644 --- a/src/workos/entities.ts +++ b/src/workos/entities.ts @@ -335,6 +335,7 @@ export interface WorkOSRole extends Entity { organization_id: string | null; is_default_role: boolean; priority: number; + resource_type_slug?: string; } export interface WorkOSPermission extends Entity { diff --git a/src/workos/helpers.ts b/src/workos/helpers.ts index e9df392..88807a2 100644 --- a/src/workos/helpers.ts +++ b/src/workos/helpers.ts @@ -12,7 +12,7 @@ import { type Store, } from '../core/index.js'; import { - DEFAULT_PERMISSION_RESOURCE_TYPE_SLUG, + DEFAULT_RESOURCE_TYPE_SLUG, EVENTS, STORE_KEYS, type AuthenticationEventData, @@ -915,7 +915,12 @@ export function formatRole(role: WorkOSRole, ws: WorkOSStore): Record ws.permissions.get(rp.permission_id)?.slug) .filter((slug): slug is string => typeof slug === 'string'); - return { ...formatEntity(role), permissions }; + return { + ...formatEntity(role), + permissions, + // Rows persisted before roles carried a scope still format with the default. + resource_type_slug: role.resource_type_slug ?? DEFAULT_RESOURCE_TYPE_SLUG, + }; } export function formatPermission(p: WorkOSPermission): Record { @@ -925,7 +930,7 @@ export function formatPermission(p: WorkOSPermission): Record { system: false, // Rows inserted without a scope (direct store inserts, pre-scope releases) // still format with the default so the spec-required key is always present. - resource_type_slug: p.resource_type_slug ?? DEFAULT_PERMISSION_RESOURCE_TYPE_SLUG, + resource_type_slug: p.resource_type_slug ?? DEFAULT_RESOURCE_TYPE_SLUG, }; } diff --git a/src/workos/index.ts b/src/workos/index.ts index 1a61e30..5b7e9f4 100644 --- a/src/workos/index.ts +++ b/src/workos/index.ts @@ -41,7 +41,7 @@ import { dataIntegrationRoutes } from './routes/data-integrations.js'; import { webhookEndpointRoutes } from './routes/webhook-endpoints.js'; import { eventRoutes } from './routes/events.js'; import { EventBus } from './event-bus.js'; -import { STORE_KEYS, EVENTS, DEFAULT_PERMISSION_RESOURCE_TYPE_SLUG } from './constants.js'; +import { STORE_KEYS, EVENTS, DEFAULT_RESOURCE_TYPE_SLUG } from './constants.js'; import { validateSeedConfig, formatValidationErrors } from './config-validator.js'; import { validateJwtTemplateContent } from './jwt-template.js'; import { environmentIdFor, flagEventContext } from './flag-context.js'; @@ -229,6 +229,7 @@ export interface WorkOSSeedRole { is_default_role?: boolean; priority?: number; permissions?: string[]; + resource_type_slug?: string; } export interface WorkOSSeedPermission { @@ -618,7 +619,7 @@ export function seedFromConfig(store: Store, _baseUrl: string, config: WorkOSSee slug: permConfig.slug, name: permConfig.name, description: permConfig.description ?? null, - resource_type_slug: permConfig.resource_type_slug ?? DEFAULT_PERMISSION_RESOURCE_TYPE_SLUG, + resource_type_slug: permConfig.resource_type_slug ?? DEFAULT_RESOURCE_TYPE_SLUG, }); } } @@ -634,6 +635,7 @@ export function seedFromConfig(store: Store, _baseUrl: string, config: WorkOSSee organization_id: roleConfig.organization_id ?? null, is_default_role: roleConfig.is_default_role ?? false, priority: roleConfig.priority ?? 0, + resource_type_slug: roleConfig.resource_type_slug ?? DEFAULT_RESOURCE_TYPE_SLUG, }); if (roleConfig.permissions) { diff --git a/src/workos/response-shapes.spec.ts b/src/workos/response-shapes.spec.ts index 36d4606..f8b1ce9 100644 --- a/src/workos/response-shapes.spec.ts +++ b/src/workos/response-shapes.spec.ts @@ -229,9 +229,6 @@ const KNOWN_MISSING_REQUIRED: Record = { // Spec models a connection `status` distinct from `state`; the emulator's // WorkOSConnection carries only `state`. connection: ['status'], - // The emulator's Role predates the spec's authorization Role: it has no - // `resource_type_slug`. - role: ['resource_type_slug'], }; /** diff --git a/src/workos/role-helpers.ts b/src/workos/role-helpers.ts index 3d72a0d..26a4106 100644 --- a/src/workos/role-helpers.ts +++ b/src/workos/role-helpers.ts @@ -10,6 +10,7 @@ import { import type { WorkOSStore } from './store.js'; import type { WorkOSRole, WorkOSPermission } from './entities.js'; import { getWorkOSStore } from './store.js'; +import { DEFAULT_RESOURCE_TYPE_SLUG } from './constants.js'; import { formatRole, formatPermission, formatListResponse } from './helpers.js'; export function findEnvRole(ws: WorkOSStore, slug: string): WorkOSRole | undefined { @@ -94,6 +95,7 @@ export function registerRoleRoutes(ctx: RouteContext, config: RoleRouteConfig): const body = await parseJsonBody(c); const slug = body.slug as string; const name = body.name as string; + const resourceTypeSlug = body.resource_type_slug; if (!slug || typeof slug !== 'string') { throw validationError('slug is required', [{ field: 'slug', code: 'required' }]); @@ -101,6 +103,14 @@ export function registerRoleRoutes(ctx: RouteContext, config: RoleRouteConfig): if (!name || typeof name !== 'string') { throw validationError('name is required', [{ field: 'name', code: 'required' }]); } + // Resource types are not modeled by the emulator (no registry, no endpoint), + // so any non-empty slug is accepted, and a role's permissions are not checked + // against its scope. Production requires a defined type and matching scopes. + if (resourceTypeSlug !== undefined && (typeof resourceTypeSlug !== 'string' || !resourceTypeSlug)) { + throw validationError('resource_type_slug must be a non-empty string', [ + { field: 'resource_type_slug', code: 'invalid' }, + ]); + } const existing = config.findRole(ws, c, slug); if (existing) { @@ -118,6 +128,7 @@ export function registerRoleRoutes(ctx: RouteContext, config: RoleRouteConfig): organization_id: defaults.organization_id ?? null, is_default_role: Boolean(body.is_default_role), priority: typeof body.priority === 'number' ? body.priority : 0, + resource_type_slug: resourceTypeSlug ?? DEFAULT_RESOURCE_TYPE_SLUG, }); return c.json(formatRole(role, ws), 201); diff --git a/src/workos/routes/authorization-org-roles.spec.ts b/src/workos/routes/authorization-org-roles.spec.ts index c0c84d3..ec9488a 100644 --- a/src/workos/routes/authorization-org-roles.spec.ts +++ b/src/workos/routes/authorization-org-roles.spec.ts @@ -38,6 +38,17 @@ describe('Authorization org role routes', () => { expect(role.type).toBe('OrganizationRole'); expect(role.organization_id).toBe(org.id); expect(role.slug).toBe('org-admin'); + expect(role.resource_type_slug).toBe('organization'); + }); + + it('preserves an org role resource type', async () => { + const org = await createOrg('Scoped Org'); + const res = await req(`/authorization/organizations/${org.id}/roles`, { + method: 'POST', + body: JSON.stringify({ slug: 'doc-editor', name: 'Doc Editor', resource_type_slug: 'document' }), + }); + expect(res.status).toBe(201); + expect((await json(res)).resource_type_slug).toBe('document'); }); it('rejects duplicate slug within same org', async () => { diff --git a/src/workos/routes/authorization-permissions.ts b/src/workos/routes/authorization-permissions.ts index 7f1fec9..d33e83b 100644 --- a/src/workos/routes/authorization-permissions.ts +++ b/src/workos/routes/authorization-permissions.ts @@ -8,7 +8,7 @@ import { } from '../../core/index.js'; import { getWorkOSStore } from '../store.js'; import { formatPermission, formatListResponse } from '../helpers.js'; -import { DEFAULT_PERMISSION_RESOURCE_TYPE_SLUG } from '../constants.js'; +import { DEFAULT_RESOURCE_TYPE_SLUG } from '../constants.js'; export function authorizationPermissionRoutes(ctx: RouteContext): void { const { app, store } = ctx; @@ -45,7 +45,7 @@ export function authorizationPermissionRoutes(ctx: RouteContext): void { slug, name, description: (body.description as string) ?? null, - resource_type_slug: resourceTypeSlug ?? DEFAULT_PERMISSION_RESOURCE_TYPE_SLUG, + resource_type_slug: resourceTypeSlug ?? DEFAULT_RESOURCE_TYPE_SLUG, }); return c.json(formatPermission(permission), 201); diff --git a/src/workos/routes/authorization-roles.spec.ts b/src/workos/routes/authorization-roles.spec.ts index efbb3db..09dd7d0 100644 --- a/src/workos/routes/authorization-roles.spec.ts +++ b/src/workos/routes/authorization-roles.spec.ts @@ -1,6 +1,7 @@ import { describe, it, expect, beforeEach } from 'bun:test'; import { createServer, type ApiKeyMap } from '../../core/index.js'; -import { workosPlugin } from '../index.js'; +import { workosPlugin, seedFromConfig } from '../index.js'; +import { validateSeedConfig } from '../config-validator.js'; const apiKeys: ApiKeyMap = { sk_test_role: { environment: 'test' } }; const headers = { Authorization: 'Bearer sk_test_role', 'Content-Type': 'application/json' }; @@ -30,9 +31,53 @@ describe('Authorization environment role routes', () => { expect(role.slug).toBe('admin'); expect(role.type).toBe('EnvironmentRole'); expect(role.organization_id).toBeNull(); + expect(role.resource_type_slug).toBe('organization'); expect(role.id).toMatch(/^role_/); }); + it('preserves a role resource type', async () => { + const res = await req('/authorization/roles', { + method: 'POST', + body: JSON.stringify({ slug: 'doc-editor', name: 'Doc Editor', resource_type_slug: 'document' }), + }); + expect(res.status).toBe(201); + expect((await json(res)).resource_type_slug).toBe('document'); + + // The update DTO has no scope field, so PATCH must leave it untouched + const patched = await req('/authorization/roles/doc-editor', { + method: 'PATCH', + body: JSON.stringify({ name: 'Document Editor', resource_type_slug: 'folder' }), + }); + expect(await json(patched)).toMatchObject({ name: 'Document Editor', resource_type_slug: 'document' }); + }); + + it.each([[42], ['']])('rejects an invalid resource type %p', async (resource_type_slug) => { + const res = await req('/authorization/roles', { + method: 'POST', + body: JSON.stringify({ slug: 'bad-scope', name: 'Bad Scope', resource_type_slug }), + }); + expect(res.status).toBe(422); + expect((await json(res)).errors).toEqual([{ field: 'resource_type_slug', code: 'invalid' }]); + }); + + it('preserves a seeded role resource type', async () => { + const server = createTestApp(); + seedFromConfig(server.store, 'http://localhost:0', { + roles: [{ slug: 'seeded-editor', name: 'Seeded Editor', resource_type_slug: 'document' }], + }); + const res = await server.app.request('/authorization/roles/seeded-editor', { headers }); + expect(res.status).toBe(200); + expect((await json(res)).resource_type_slug).toBe('document'); + }); + + it.each([[42 as unknown as string], ['']])('rejects an invalid seeded resource type %p', (resource_type_slug) => { + const result = validateSeedConfig({ + roles: [{ slug: 'bad-seed', name: 'Bad Seed', resource_type_slug }], + }); + expect(result.valid).toBe(false); + expect(result.errors.some((error) => error.path === 'roles[0].resource_type_slug')).toBe(true); + }); + it('rejects duplicate slug', async () => { await req('/authorization/roles', { method: 'POST', From 77e521fa0b2d5d5a7906db18870d420b69443369 Mon Sep 17 00:00:00 2001 From: "Garen J. Torikian" Date: Thu, 3 Sep 2026 16:38:52 -0400 Subject: [PATCH 17/20] docs(supported): note that resource types are not modeled Production rejects a `resource_type_slug` that names no resource type and refuses permissions whose scope does not match the role's; the emulator accepts any non-empty slug and never cross-checks. That was stated only in code comments, so a reader of SUPPORTED.md had no way to know a scope typo passes here and fails against WorkOS. --- SUPPORTED.md | 2 +- scripts/gen-supported-lib.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/SUPPORTED.md b/SUPPORTED.md index 781a52e..e776527 100644 --- a/SUPPORTED.md +++ b/SUPPORTED.md @@ -28,7 +28,7 @@ answers "can I actually emulate this?". | SSO | ⚠️ 5/8 | ⚠️ 4/11 | ✅ 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 | ⚠️ 18/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. | +| FGA / Authorization | ⚠️ 15/19 | ⚠️ 18/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. Resource types are not modeled: any `resource_type_slug` is accepted on roles and permissions, and permission scopes are not checked against the role scope. | | 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. | diff --git a/scripts/gen-supported-lib.ts b/scripts/gen-supported-lib.ts index 0fc507d..5d704c2 100644 --- a/scripts/gen-supported-lib.ts +++ b/scripts/gen-supported-lib.ts @@ -156,7 +156,7 @@ export const FEATURES: FeatureDef[] = [ tags: ['authorization', 'permissions'], seedKeys: ['roles', 'permissions'], notes: - 'Checks and effective-permission listings honor resource-scoped role assignments and ancestor inheritance (`parent_resource_id`); group role assignments are not implemented.', + 'Checks and effective-permission listings honor resource-scoped role assignments and ancestor inheritance (`parent_resource_id`); group role assignments are not implemented. Resource types are not modeled: any `resource_type_slug` is accepted on roles and permissions, and permission scopes are not checked against the role scope.', }, { name: 'Audit Logs', From c98eb50788b8b72461995b3dc8fbfe83ad46ca2d Mon Sep 17 00:00:00 2001 From: "Garen J. Torikian" Date: Thu, 3 Sep 2026 16:39:35 -0400 Subject: [PATCH 18/20] refactor(authorization): share the resource type slug check The same optional-but-non-empty rule was spelled out at both create routes and both seed validators, so tightening it later (a registry, once resource types are modeled) would take four coordinated edits. --- src/workos/config-validator.ts | 11 +++-------- src/workos/constants.ts | 10 ++++++++++ src/workos/role-helpers.ts | 4 ++-- src/workos/routes/authorization-permissions.ts | 4 ++-- 4 files changed, 17 insertions(+), 12 deletions(-) diff --git a/src/workos/config-validator.ts b/src/workos/config-validator.ts index d1afb4f..d663d86 100644 --- a/src/workos/config-validator.ts +++ b/src/workos/config-validator.ts @@ -3,6 +3,7 @@ */ import type { WorkOSSeedConfig } from './index.js'; import { validateJwtTemplateContent } from './jwt-template.js'; +import { isValidResourceTypeSlug } from './constants.js'; import { normalizeEmail, type NormalizedEmail } from './helpers.js'; /** @@ -563,10 +564,7 @@ export function validateSeedConfig(config: WorkOSSeedConfig): ConfigValidationRe value: role.type, }); } - if ( - role.resource_type_slug !== undefined && - (typeof role.resource_type_slug !== 'string' || !role.resource_type_slug) - ) { + if (!isValidResourceTypeSlug(role.resource_type_slug)) { errors.push({ path: `roles[${index}].resource_type_slug`, message: 'resource_type_slug must be a non-empty string if provided', @@ -601,10 +599,7 @@ export function validateSeedConfig(config: WorkOSSeedConfig): ConfigValidationRe value: perm.name, }); } - if ( - perm.resource_type_slug !== undefined && - (typeof perm.resource_type_slug !== 'string' || !perm.resource_type_slug) - ) { + if (!isValidResourceTypeSlug(perm.resource_type_slug)) { errors.push({ path: `permissions[${index}].resource_type_slug`, message: 'resource_type_slug must be a non-empty string if provided', diff --git a/src/workos/constants.ts b/src/workos/constants.ts index bdcfc2f..b9a87d4 100644 --- a/src/workos/constants.ts +++ b/src/workos/constants.ts @@ -29,6 +29,16 @@ export const STORE_KEY_PREFIXES = { */ export const DEFAULT_RESOURCE_TYPE_SLUG = 'organization'; +/** + * `resource_type_slug` is optional wherever the emulator accepts it (create + * DTOs and seed entries), but a supplied value must be a non-empty string. + * Resource types are not modeled, so the slug is not checked against a + * registry the way production does. + */ +export function isValidResourceTypeSlug(value: unknown): value is string | undefined { + return value === undefined || (typeof value === 'string' && value.length > 0); +} + /** * WorkOS event catalog, generated from the OpenAPI spec. * Regenerate with: npm run gen:events -- path/to/open-api-spec.yaml diff --git a/src/workos/role-helpers.ts b/src/workos/role-helpers.ts index 26a4106..13bfc8f 100644 --- a/src/workos/role-helpers.ts +++ b/src/workos/role-helpers.ts @@ -10,7 +10,7 @@ import { import type { WorkOSStore } from './store.js'; import type { WorkOSRole, WorkOSPermission } from './entities.js'; import { getWorkOSStore } from './store.js'; -import { DEFAULT_RESOURCE_TYPE_SLUG } from './constants.js'; +import { DEFAULT_RESOURCE_TYPE_SLUG, isValidResourceTypeSlug } from './constants.js'; import { formatRole, formatPermission, formatListResponse } from './helpers.js'; export function findEnvRole(ws: WorkOSStore, slug: string): WorkOSRole | undefined { @@ -106,7 +106,7 @@ export function registerRoleRoutes(ctx: RouteContext, config: RoleRouteConfig): // Resource types are not modeled by the emulator (no registry, no endpoint), // so any non-empty slug is accepted, and a role's permissions are not checked // against its scope. Production requires a defined type and matching scopes. - if (resourceTypeSlug !== undefined && (typeof resourceTypeSlug !== 'string' || !resourceTypeSlug)) { + if (!isValidResourceTypeSlug(resourceTypeSlug)) { throw validationError('resource_type_slug must be a non-empty string', [ { field: 'resource_type_slug', code: 'invalid' }, ]); diff --git a/src/workos/routes/authorization-permissions.ts b/src/workos/routes/authorization-permissions.ts index d33e83b..d2fdced 100644 --- a/src/workos/routes/authorization-permissions.ts +++ b/src/workos/routes/authorization-permissions.ts @@ -8,7 +8,7 @@ import { } from '../../core/index.js'; import { getWorkOSStore } from '../store.js'; import { formatPermission, formatListResponse } from '../helpers.js'; -import { DEFAULT_RESOURCE_TYPE_SLUG } from '../constants.js'; +import { DEFAULT_RESOURCE_TYPE_SLUG, isValidResourceTypeSlug } from '../constants.js'; export function authorizationPermissionRoutes(ctx: RouteContext): void { const { app, store } = ctx; @@ -28,7 +28,7 @@ export function authorizationPermissionRoutes(ctx: RouteContext): void { } // Resource types are not modeled by the emulator (no registry, no endpoint), // so any non-empty slug is accepted. Production requires a defined type. - if (resourceTypeSlug !== undefined && (typeof resourceTypeSlug !== 'string' || !resourceTypeSlug)) { + if (!isValidResourceTypeSlug(resourceTypeSlug)) { throw validationError('resource_type_slug must be a non-empty string', [ { field: 'resource_type_slug', code: 'invalid' }, ]); From d0ef6baee27553d13388c2c409861ed341b78b02 Mon Sep 17 00:00:00 2001 From: "Garen J. Torikian" Date: Thu, 3 Sep 2026 16:39:48 -0400 Subject: [PATCH 19/20] fix(roles): resolve every slug before replacing permissions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `PUT …/permissions` deleted the role's join rows and then inserted slug by slug, throwing 404 at the first unknown one, so a single typo left the role with a partial set. Production validates the whole list before writing, and this is the path the SDKs' setEnvironmentRolePermissions and setOrganizationRolePermissions call. --- src/workos/role-helpers.ts | 25 +++++++++++++------ src/workos/routes/authorization-roles.spec.ts | 18 +++++++++++++ 2 files changed, 36 insertions(+), 7 deletions(-) diff --git a/src/workos/role-helpers.ts b/src/workos/role-helpers.ts index 13bfc8f..de30aa5 100644 --- a/src/workos/role-helpers.ts +++ b/src/workos/role-helpers.ts @@ -57,18 +57,29 @@ export function getRolePermissions(ws: WorkOSStore, roleId: string): WorkOSPermi return rps.map((rp) => ws.permissions.get(rp.permission_id)).filter(Boolean) as WorkOSPermission[]; } -export function replaceRolePermissions(ws: WorkOSStore, roleId: string, permissionSlugs: string[]): WorkOSPermission[] { - // Delete existing - ws.rolePermissions.deleteBy('role_id', roleId); - - // Insert new +/** + * Replace a role's permission set. Every slug is resolved before the join + * table is touched, so an unknown slug answers 404 and leaves the current set + * intact rather than half-applied. Returns whether the set actually changed, + * which is what decides whether a role.updated event is due, as in production. + */ +export function replaceRolePermissions(ws: WorkOSStore, roleId: string, permissionSlugs: string[]): boolean { + const next = new Map(); for (const permSlug of permissionSlugs) { const perm = ws.permissions.findOneBy('slug', permSlug); if (!perm) throw notFound('Permission'); - ws.rolePermissions.insert({ role_id: roleId, permission_id: perm.id }); + next.set(perm.id, perm); } - return getRolePermissions(ws, roleId); + const current = new Set(ws.rolePermissions.findBy('role_id', roleId).map((rp) => rp.permission_id)); + const changed = current.size !== next.size || [...next.keys()].some((id) => !current.has(id)); + if (!changed) return false; + + ws.rolePermissions.deleteBy('role_id', roleId); + for (const perm of next.values()) { + ws.rolePermissions.insert({ role_id: roleId, permission_id: perm.id }); + } + return true; } export interface RoleRouteConfig { diff --git a/src/workos/routes/authorization-roles.spec.ts b/src/workos/routes/authorization-roles.spec.ts index 09dd7d0..5450119 100644 --- a/src/workos/routes/authorization-roles.spec.ts +++ b/src/workos/routes/authorization-roles.spec.ts @@ -259,6 +259,24 @@ describe('Authorization environment role routes', () => { expect(notArray.status).toBe(422); }); + it('leaves permissions intact when a replacement names an unknown slug', async () => { + await req('/authorization/permissions', { method: 'POST', body: JSON.stringify({ slug: 'keep', name: 'Keep' }) }); + await req('/authorization/roles', { method: 'POST', body: JSON.stringify({ slug: 'atomic', name: 'Atomic' }) }); + await req('/authorization/roles/atomic/permissions', { + method: 'PUT', + body: JSON.stringify({ permissions: ['keep'] }), + }); + + const res = await req('/authorization/roles/atomic/permissions', { + method: 'PUT', + body: JSON.stringify({ permissions: ['keep', 'missing'] }), + }); + expect(res.status).toBe(404); + + const role = await json(await req('/authorization/roles/atomic')); + expect(role.permissions).toEqual(['keep']); + }); + it('creates role with default flag', async () => { const res = await req('/authorization/roles', { method: 'POST', From 749ac1eb2d1f6ac8b527071f89d11eff93363809 Mon Sep 17 00:00:00 2001 From: "Garen J. Torikian" Date: Thu, 3 Sep 2026 16:40:02 -0400 Subject: [PATCH 20/20] fix(roles): emit permission changes as production does Production emits role.updated or organization_role.updated when a permission set actually changes through the permissions endpoints, without touching the role row; the emulator emitted nothing there, because only ws.roles.update fires the collection hook. Its role.deleted also never carries permissions while organization_role.deleted does, but the delete route cascaded the joins before the row, so the hook always saw an empty list. Both behaviors were read from the production roles service and the role-permissions controllers. --- src/workos/index.ts | 17 +++-- src/workos/role-helpers.ts | 32 +++++++-- .../routes/authorization-org-roles.spec.ts | 35 +++++++++- src/workos/routes/authorization-org-roles.ts | 3 +- src/workos/routes/authorization-roles.spec.ts | 69 ++++++++++++++++++- 5 files changed, 141 insertions(+), 15 deletions(-) diff --git a/src/workos/index.ts b/src/workos/index.ts index 5b7e9f4..9dfe713 100644 --- a/src/workos/index.ts +++ b/src/workos/index.ts @@ -1003,11 +1003,18 @@ export const workosPlugin: ServicePlugin = { event: r.type === 'OrganizationRole' ? EVENTS.organizationRoleUpdated : EVENTS.roleUpdated, data: formatRole(r, ws), }), - onDelete: (r) => - eventBus.emit({ - event: r.type === 'OrganizationRole' ? EVENTS.organizationRoleDeleted : EVENTS.roleDeleted, - data: formatRole(r, ws), - }), + onDelete: (r) => { + // The role routes delete the role row before cascading its joins, so the + // permissions are still resolvable here. Production's organization_role.deleted + // carries them; its role.deleted never does. + const data = formatRole(r, ws); + if (r.type === 'OrganizationRole') { + eventBus.emit({ event: EVENTS.organizationRoleDeleted, data }); + } else { + delete data.permissions; + eventBus.emit({ event: EVENTS.roleDeleted, data }); + } + }, }); ws.permissions.setHooks({ onInsert: (p) => eventBus.emit({ event: EVENTS.permissionCreated, data: formatPermission(p) }), diff --git a/src/workos/role-helpers.ts b/src/workos/role-helpers.ts index de30aa5..d9ff06f 100644 --- a/src/workos/role-helpers.ts +++ b/src/workos/role-helpers.ts @@ -1,6 +1,7 @@ import type { Context } from 'hono'; import { type RouteContext, + type Store, WorkOSApiError, notFound, validationError, @@ -9,8 +10,9 @@ import { } from '../core/index.js'; import type { WorkOSStore } from './store.js'; import type { WorkOSRole, WorkOSPermission } from './entities.js'; +import type { EventBus } from './event-bus.js'; import { getWorkOSStore } from './store.js'; -import { DEFAULT_RESOURCE_TYPE_SLUG, isValidResourceTypeSlug } from './constants.js'; +import { DEFAULT_RESOURCE_TYPE_SLUG, EVENTS, STORE_KEYS, isValidResourceTypeSlug } from './constants.js'; import { formatRole, formatPermission, formatListResponse } from './helpers.js'; export function findEnvRole(ws: WorkOSStore, slug: string): WorkOSRole | undefined { @@ -82,6 +84,19 @@ export function replaceRolePermissions(ws: WorkOSStore, roleId: string, permissi return true; } +/** + * Production emits `role.updated` (or `organization_role.updated`) when a + * role's permission set changes through the permissions endpoints without + * touching the role row, so this goes to the bus directly rather than through + * the collection hooks. + */ +export function emitRolePermissionsUpdated(store: Store, ws: WorkOSStore, role: WorkOSRole): void { + store.getData(STORE_KEYS.eventBus)?.emit({ + event: role.type === 'OrganizationRole' ? EVENTS.organizationRoleUpdated : EVENTS.roleUpdated, + data: formatRole(role, ws), + }); +} + export interface RoleRouteConfig { pathPrefix: string; roleType: 'EnvironmentRole' | 'OrganizationRole'; @@ -180,10 +195,12 @@ export function registerRoleRoutes(ctx: RouteContext, config: RoleRouteConfig): app.delete(`${pathPrefix}/:slug`, (c) => { const role = config.requireRole(ws, c); + // The role row goes first so the deleted event, emitted from the collection + // hook, can still resolve the role's permissions; the joins cascade after. + ws.roles.delete(role.id); ws.rolePermissions.deleteBy('role_id', role.id); ws.roleAssignments.deleteBy('role_id', role.id); - ws.roles.delete(role.id); return c.body(null, 204); }); @@ -210,7 +227,9 @@ export function registerRoleRoutes(ctx: RouteContext, config: RoleRouteConfig): throw validationError('permissions must be an array of slugs', [{ field: 'permissions', code: 'invalid' }]); } - replaceRolePermissions(ws, role.id, permissionSlugs as string[]); + if (replaceRolePermissions(ws, role.id, permissionSlugs as string[])) { + emitRolePermissionsUpdated(store, ws, role); + } return c.json(formatRole(role, ws)); }); @@ -225,9 +244,12 @@ export function registerRoleRoutes(ctx: RouteContext, config: RoleRouteConfig): const permission = ws.permissions.findOneBy('slug', slug); if (!permission) throw notFound('Permission'); - // Re-attaching is a no-op rather than a duplicate join row. + // Re-attaching is a no-op rather than a duplicate join row, and emits nothing. const attached = ws.rolePermissions.findBy('role_id', role.id).some((rp) => rp.permission_id === permission.id); - if (!attached) ws.rolePermissions.insert({ role_id: role.id, permission_id: permission.id }); + if (!attached) { + ws.rolePermissions.insert({ role_id: role.id, permission_id: permission.id }); + emitRolePermissionsUpdated(store, ws, role); + } return c.json(formatRole(role, ws)); }); diff --git a/src/workos/routes/authorization-org-roles.spec.ts b/src/workos/routes/authorization-org-roles.spec.ts index ec9488a..41bcd0a 100644 --- a/src/workos/routes/authorization-org-roles.spec.ts +++ b/src/workos/routes/authorization-org-roles.spec.ts @@ -1,6 +1,6 @@ import { describe, it, expect, beforeEach } from 'bun:test'; import { createServer, type ApiKeyMap } from '../../core/index.js'; -import { workosPlugin } from '../index.js'; +import { workosPlugin, getWorkOSStore } from '../index.js'; const apiKeys: ApiKeyMap = { sk_test_orgrole: { environment: 'test' } }; const headers = { Authorization: 'Bearer sk_test_orgrole', 'Content-Type': 'application/json' }; @@ -11,9 +11,12 @@ function createTestApp() { describe('Authorization org role routes', () => { let app: ReturnType['app']; + let store: ReturnType['store']; beforeEach(() => { - app = createTestApp().app; + const server = createTestApp(); + app = server.app; + store = server.store; }); const req = (path: string, init?: RequestInit) => app.request(path, { headers, ...init }); @@ -161,6 +164,29 @@ describe('Authorization org role routes', () => { expect(body.data[1].priority).toBe(1); }); + it('emits organization_role.deleted with the permissions the role held', async () => { + const org = await createOrg('Deleted Org'); + await req('/authorization/permissions', { + method: 'POST', + body: JSON.stringify({ slug: 'org-gone', name: 'Gone' }), + }); + await req(`/authorization/organizations/${org.id}/roles`, { + method: 'POST', + body: JSON.stringify({ slug: 'org-doomed', name: 'Doomed' }), + }); + await req(`/authorization/organizations/${org.id}/roles/org-doomed/permissions`, { + method: 'PUT', + body: JSON.stringify({ permissions: ['org-gone'] }), + }); + + const res = await req(`/authorization/organizations/${org.id}/roles/org-doomed`, { method: 'DELETE' }); + expect(res.status).toBe(204); + + const deleted = getWorkOSStore(store).events.findBy('event', 'organization_role.deleted'); + expect(deleted).toHaveLength(1); + expect(deleted[0]!.data).toMatchObject({ slug: 'org-doomed', permissions: ['org-gone'] }); + }); + it('manages org role permissions', async () => { const org = await createOrg('Perm Org'); @@ -198,6 +224,11 @@ describe('Authorization org role routes', () => { expect(delRes.status).toBe(200); expect((await json(delRes)).permissions).toEqual(['org-read']); + // Both the set and the removal changed the permission set, so each emitted + const updated = getWorkOSStore(store).events.findBy('event', 'organization_role.updated'); + expect(updated).toHaveLength(2); + expect(updated[1]!.data).toMatchObject({ slug: 'org-editor', permissions: ['org-read'] }); + // Verify removal const afterRes = await req(`/authorization/organizations/${org.id}/roles/org-editor/permissions`); const afterBody = await json(afterRes); diff --git a/src/workos/routes/authorization-org-roles.ts b/src/workos/routes/authorization-org-roles.ts index 377e035..5ae988f 100644 --- a/src/workos/routes/authorization-org-roles.ts +++ b/src/workos/routes/authorization-org-roles.ts @@ -1,7 +1,7 @@ import { type RouteContext, notFound, validationError, parseJsonBody } from '../../core/index.js'; import { getWorkOSStore } from '../store.js'; import { formatRole } from '../helpers.js'; -import { findOrgRole, requireOrgRole, registerRoleRoutes } from '../role-helpers.js'; +import { emitRolePermissionsUpdated, findOrgRole, requireOrgRole, registerRoleRoutes } from '../role-helpers.js'; export function authorizationOrgRoleRoutes(ctx: RouteContext): void { const { app, store } = ctx; @@ -67,6 +67,7 @@ export function authorizationOrgRoleRoutes(ctx: RouteContext): void { if (!rp) throw notFound('RolePermission'); ws.rolePermissions.delete(rp.id); + emitRolePermissionsUpdated(store, ws, role); // The spec answers with the updated role, not an empty 204. return c.json(formatRole(role, ws)); }); diff --git a/src/workos/routes/authorization-roles.spec.ts b/src/workos/routes/authorization-roles.spec.ts index 5450119..01ea0e9 100644 --- a/src/workos/routes/authorization-roles.spec.ts +++ b/src/workos/routes/authorization-roles.spec.ts @@ -1,6 +1,6 @@ import { describe, it, expect, beforeEach } from 'bun:test'; import { createServer, type ApiKeyMap } from '../../core/index.js'; -import { workosPlugin, seedFromConfig } from '../index.js'; +import { workosPlugin, seedFromConfig, getWorkOSStore } from '../index.js'; import { validateSeedConfig } from '../config-validator.js'; const apiKeys: ApiKeyMap = { sk_test_role: { environment: 'test' } }; @@ -12,9 +12,12 @@ function createTestApp() { describe('Authorization environment role routes', () => { let app: ReturnType['app']; + let store: ReturnType['store']; beforeEach(() => { - app = createTestApp().app; + const server = createTestApp(); + app = server.app; + store = server.store; }); const req = (path: string, init?: RequestInit) => app.request(path, { headers, ...init }); @@ -277,6 +280,68 @@ describe('Authorization environment role routes', () => { expect(role.permissions).toEqual(['keep']); }); + it('emits role.updated only when the permission set changes', async () => { + for (const slug of ['ev-a', 'ev-b']) { + await req('/authorization/permissions', { method: 'POST', body: JSON.stringify({ slug, name: slug }) }); + } + await req('/authorization/roles', { method: 'POST', body: JSON.stringify({ slug: 'evented', name: 'Evented' }) }); + const updates = () => getWorkOSStore(store).events.findBy('event', 'role.updated'); + + await req('/authorization/roles/evented/permissions', { + method: 'PUT', + body: JSON.stringify({ permissions: ['ev-a'] }), + }); + expect(updates()).toHaveLength(1); + expect(updates()[0]!.data).toMatchObject({ slug: 'evented', permissions: ['ev-a'] }); + + // The same set again changes nothing and emits nothing + await req('/authorization/roles/evented/permissions', { + method: 'PUT', + body: JSON.stringify({ permissions: ['ev-a'] }), + }); + expect(updates()).toHaveLength(1); + + await req('/authorization/roles/evented/permissions', { method: 'POST', body: JSON.stringify({ slug: 'ev-b' }) }); + expect(updates()).toHaveLength(2); + expect([...(updates()[1]!.data as any).permissions].sort()).toEqual(['ev-a', 'ev-b']); + + // Re-attaching is a no-op + await req('/authorization/roles/evented/permissions', { method: 'POST', body: JSON.stringify({ slug: 'ev-b' }) }); + expect(updates()).toHaveLength(2); + }); + + it('emits role.deleted without permissions, as production does', async () => { + await req('/authorization/permissions', { method: 'POST', body: JSON.stringify({ slug: 'gone', name: 'Gone' }) }); + await req('/authorization/roles', { method: 'POST', body: JSON.stringify({ slug: 'doomed', name: 'Doomed' }) }); + await req('/authorization/roles/doomed/permissions', { + method: 'PUT', + body: JSON.stringify({ permissions: ['gone'] }), + }); + + await req('/authorization/roles/doomed', { method: 'DELETE' }); + + const deleted = getWorkOSStore(store).events.findBy('event', 'role.deleted'); + expect(deleted).toHaveLength(1); + expect(deleted[0]!.data).toMatchObject({ slug: 'doomed', resource_type_slug: 'organization' }); + expect(deleted[0]!.data).not.toHaveProperty('permissions'); + }); + + it('formats a directly inserted role without a resource type using the default', async () => { + getWorkOSStore(store).roles.insert({ + object: 'role', + slug: 'legacy', + name: 'Legacy', + description: null, + type: 'EnvironmentRole', + organization_id: null, + is_default_role: false, + priority: 0, + }); + const res = await req('/authorization/roles/legacy'); + expect(res.status).toBe(200); + expect(await json(res)).toMatchObject({ resource_type_slug: 'organization', permissions: [] }); + }); + it('creates role with default flag', async () => { const res = await req('/authorization/roles', { method: 'POST',