diff --git a/.gitignore b/.gitignore index a45dfb57..c9639c7b 100644 --- a/.gitignore +++ b/.gitignore @@ -15,6 +15,10 @@ pnpm-debug.log dist/ coverage/ +# .NET build output +bin/ +obj/ + # Generated src/version.ts diff --git a/README.md b/README.md index facf85cc..e88a993a 100644 --- a/README.md +++ b/README.md @@ -84,6 +84,9 @@ Resource Management: Migrations: migrations Migrate users and SSO connections into WorkOS +Local Development: + emulate Start a local WorkOS API emulator + Workflows: seed Declarative resource provisioning from YAML setup-org One-shot organization onboarding @@ -220,38 +223,9 @@ workos migrations import --csv users.csv Run `workos migrations --help` for all available subcommands. - +# Show login pages for SSO/AuthKit browser testing +workos emulate --interactive +``` ### Environment Management diff --git a/package.json b/package.json index 6710658d..5cc168b7 100644 --- a/package.json +++ b/package.json @@ -38,10 +38,6 @@ ".": { "import": "./dist/index.js", "types": "./dist/index.d.ts" - }, - "./emulate": { - "import": "./dist/emulate/index.js", - "types": "./dist/emulate/index.d.ts" } }, "dependencies": { @@ -49,16 +45,15 @@ "@anthropic-ai/sdk": "^0.78.0", "@clack/core": "^1.0.1", "@clack/prompts": "1.0.1", - "@hono/node-server": "^1", "@napi-rs/keyring": "^1.2.0", "@workos-inc/node": "^8.7.0", + "@workos/emulate": "^0.1.0", "@workos/migrations": "^2.0.0", "@workos/openapi-spec": "^0.1.0", "@workos/skills": "0.6.0", "chalk": "^5.6.2", "diff": "^8.0.3", "fast-glob": "^3.3.3", - "hono": "^4", "ink": "^6.8.0", "open": "^11.0.0", "react": "^19.2.4", @@ -110,9 +105,7 @@ "eval:diff": "tsx tests/evals/index.ts diff", "eval:prune": "tsx tests/evals/index.ts prune", "eval:logs": "tsx tests/evals/index.ts logs", - "eval:show": "tsx tests/evals/index.ts show", - "gen:routes": "tsx scripts/gen-routes.ts", - "check:coverage": "tsx scripts/check-coverage.ts" + "eval:show": "tsx tests/evals/index.ts show" }, "author": "WorkOS", "license": "MIT" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 24b29e2e..08268671 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -20,15 +20,15 @@ importers: '@clack/prompts': specifier: 1.0.1 version: 1.0.1 - '@hono/node-server': - specifier: ^1 - version: 1.19.13(hono@4.11.4) '@napi-rs/keyring': specifier: ^1.2.0 version: 1.2.0 '@workos-inc/node': specifier: ^8.7.0 version: 8.7.0 + '@workos/emulate': + specifier: ^0.1.0 + version: 0.1.0 '@workos/migrations': specifier: ^2.0.0 version: 2.0.0 @@ -47,9 +47,6 @@ importers: fast-glob: specifier: ^3.3.3 version: 3.3.3 - hono: - specifier: ^4 - version: 4.11.4 ink: specifier: ^6.8.0 version: 6.8.0(@types/react@19.2.14)(react@19.2.4) @@ -1512,6 +1509,11 @@ packages: resolution: {integrity: sha512-JAEjrwgr8aETOS7YzHmSlPj+1LCXonAKGX1/B89hIZ+l+YgQGJrdFZnBqBSlDnqUaOtxDZkGwg7V3LFNkF+evg==} engines: {node: '>=22.11.0'} + '@workos/emulate@0.1.0': + resolution: {integrity: sha512-0f4g99yh6Ozf7wfdVlcAktrkyq2ejmweanXFnn0KMfTYdsYO5/oDgBFw+MVLhx9CoslDp2ApPgdImMZTeJ1n6g==} + engines: {node: '>=22.11'} + hasBin: true + '@workos/migrations@2.0.0': resolution: {integrity: sha512-xFAOMRoqRbUICkQ2LlKNh1hy9A6yp+eamAwGcWitUvSRwayTecYlQAnNsd2DT4xKgevdgoufecHW6XJvbnd0WQ==} engines: {node: '>=22.11.0'} @@ -3718,6 +3720,13 @@ snapshots: dependencies: eventemitter3: 5.0.4 + '@workos/emulate@0.1.0': + dependencies: + '@hono/node-server': 1.19.13(hono@4.11.4) + chalk: 5.6.2 + hono: 4.11.4 + yaml: 2.8.3 + '@workos/migrations@2.0.0': dependencies: '@aws-sdk/client-cognito-identity-provider': 3.1045.0 diff --git a/scripts/check-coverage.ts b/scripts/check-coverage.ts deleted file mode 100644 index 8fe4745d..00000000 --- a/scripts/check-coverage.ts +++ /dev/null @@ -1,237 +0,0 @@ -#!/usr/bin/env tsx -/** - * Coverage checker: compares the WorkOS OpenAPI spec against the emulator's - * registered routes to find missing or extra endpoints. - * - * Usage: - * pnpm check:coverage path/to/openapi.yaml - * pnpm check:coverage ~/Developer/workos/packages/api/open-api-spec.yaml - * - * Reports: - * - Spec endpoints missing from the emulator - * - Emulator endpoints not in the spec (custom/internal) - * - Coverage percentage - */ - -import { readFileSync, existsSync, readdirSync } from 'node:fs'; -import { resolve, extname, join } from 'node:path'; -import YAML from 'yaml'; - -// --------------------------------------------------------------------------- -// Parse OpenAPI spec endpoints -// --------------------------------------------------------------------------- - -interface SpecEndpoint { - method: string; - path: string; - operationId?: string; - summary?: string; - tags: string[]; -} - -function parseOpenApiEndpoints(specPath: string): SpecEndpoint[] { - const raw = readFileSync(specPath, 'utf-8'); - const ext = extname(specPath).toLowerCase(); - const spec = ext === '.yaml' || ext === '.yml' ? YAML.parse(raw) : JSON.parse(raw); - - const endpoints: SpecEndpoint[] = []; - const methods = ['get', 'post', 'put', 'patch', 'delete'] as const; - - for (const [path, item] of Object.entries(spec.paths ?? {}) as [string, any][]) { - for (const method of methods) { - const op = item[method]; - if (!op) continue; - - // Normalize OpenAPI path params {id} → :id - const normalizedPath = path.replace(/\{([^}]+)\}/g, ':$1'); - - endpoints.push({ - method: method.toUpperCase(), - path: normalizedPath, - operationId: op.operationId, - summary: op.summary, - tags: op.tags ?? [], - }); - } - } - - return endpoints; -} - -// --------------------------------------------------------------------------- -// Parse emulator registered routes from source files -// --------------------------------------------------------------------------- - -interface EmulatorEndpoint { - method: string; - path: string; - file: string; - line: number; -} - -function parseEmulatorEndpoints(): EmulatorEndpoint[] { - const routesDir = resolve('src/emulate/workos/routes'); - const serverFile = resolve('src/emulate/core/server.ts'); - const endpoints: EmulatorEndpoint[] = []; - - const routePattern = /app\.(get|post|put|patch|delete)\('([^']+)'/g; - - const filesToScan: string[] = []; - - // Collect route files - if (existsSync(routesDir)) { - for (const file of readdirSync(routesDir)) { - if (file.endsWith('.ts') && !file.endsWith('.spec.ts')) { - filesToScan.push(join(routesDir, file)); - } - } - } - - // Also scan server.ts for JWKS and other direct routes - if (existsSync(serverFile)) { - filesToScan.push(serverFile); - } - - for (const filePath of filesToScan) { - const content = readFileSync(filePath, 'utf-8'); - const lines = content.split('\n'); - - for (let i = 0; i < lines.length; i++) { - routePattern.lastIndex = 0; - let match; - while ((match = routePattern.exec(lines[i])) !== null) { - endpoints.push({ - method: match[1].toUpperCase(), - path: match[2], - file: filePath.replace(resolve('.') + '/', ''), - line: i + 1, - }); - } - } - } - - return endpoints; -} - -// --------------------------------------------------------------------------- -// Normalize paths for comparison -// --------------------------------------------------------------------------- - -/** Normalize path params to a canonical form for matching. - * e.g., :id, :orgId, :organization_id all become :param in the same position */ -function normalizePath(path: string): string { - return path - .replace(/:[a-zA-Z_]+/g, ':param') - .replace(/\/+$/, '') - .toLowerCase(); -} - -function routeKey(method: string, path: string): string { - return `${method} ${normalizePath(path)}`; -} - -// --------------------------------------------------------------------------- -// Main -// --------------------------------------------------------------------------- - -function main(): void { - const specPath = process.argv[2]; - if (!specPath) { - console.error('Usage: check-coverage '); - console.error(' e.g.: pnpm check:coverage ~/Developer/workos/packages/api/open-api-spec.yaml'); - process.exit(1); - } - - const resolvedSpec = resolve(specPath); - if (!existsSync(resolvedSpec)) { - console.error(`Spec file not found: ${resolvedSpec}`); - process.exit(1); - } - - const specEndpoints = parseOpenApiEndpoints(resolvedSpec); - const emulatorEndpoints = parseEmulatorEndpoints(); - - // Build lookup maps - const specMap = new Map(); - for (const ep of specEndpoints) { - specMap.set(routeKey(ep.method, ep.path), ep); - } - - const emulatorMap = new Map(); - for (const ep of emulatorEndpoints) { - emulatorMap.set(routeKey(ep.method, ep.path), ep); - } - - // Find gaps - const missing: SpecEndpoint[] = []; - const covered: SpecEndpoint[] = []; - for (const [key, ep] of specMap) { - if (emulatorMap.has(key)) { - covered.push(ep); - } else { - missing.push(ep); - } - } - - const extra: EmulatorEndpoint[] = []; - for (const [key, ep] of emulatorMap) { - if (!specMap.has(key)) { - extra.push(ep); - } - } - - // Group missing by tag - const missingByTag = new Map(); - for (const ep of missing) { - const tag = ep.tags[0] ?? 'untagged'; - if (!missingByTag.has(tag)) missingByTag.set(tag, []); - missingByTag.get(tag)!.push(ep); - } - - // Report - const total = specEndpoints.length; - const coveredCount = covered.length; - const pct = total > 0 ? ((coveredCount / total) * 100).toFixed(1) : '0'; - - console.log(''); - console.log('=== Emulator API Coverage Report ==='); - console.log(''); - console.log(` Spec endpoints: ${total}`); - console.log(` Emulator endpoints: ${emulatorEndpoints.length}`); - console.log(` Covered: ${coveredCount}/${total} (${pct}%)`); - console.log(` Missing: ${missing.length}`); - console.log(` Extra (emulator-only): ${extra.length}`); - console.log(''); - - if (missing.length > 0) { - console.log('--- Missing from emulator ---'); - console.log(''); - for (const [tag, eps] of [...missingByTag.entries()].sort((a, b) => a[0].localeCompare(b[0]))) { - console.log(` [${tag}]`); - for (const ep of eps) { - const desc = ep.summary ? ` — ${ep.summary}` : ''; - console.log(` ${ep.method.padEnd(6)} ${ep.path}${desc}`); - } - console.log(''); - } - } - - if (extra.length > 0) { - console.log('--- Emulator-only (not in spec) ---'); - console.log(''); - for (const ep of extra.sort((a, b) => a.path.localeCompare(b.path))) { - console.log(` ${ep.method.padEnd(6)} ${ep.path} (${ep.file}:${ep.line})`); - } - console.log(''); - } - - if (missing.length === 0) { - console.log('Full coverage — all spec endpoints are implemented.'); - console.log(''); - } - - // Exit 1 if there are missing endpoints (useful for CI later) - process.exit(missing.length > 0 ? 1 : 0); -} - -main(); diff --git a/scripts/gen-routes-lib.spec.ts b/scripts/gen-routes-lib.spec.ts deleted file mode 100644 index 1f98a92f..00000000 --- a/scripts/gen-routes-lib.spec.ts +++ /dev/null @@ -1,659 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import { - type OpenAPISpec, - type ParsedEntity, - type ParsedRoute, - parseSpec, - generateEntities, - generateStore, - generateHelpers, - generateRoutes, - schemaToTsType, - toSnakeCase, - toPascalCase, - toCamelCase, - pluralize, - singularize, - openApiPathToHono, -} from './gen-routes-lib.js'; - -// --------------------------------------------------------------------------- -// Utility helpers -// --------------------------------------------------------------------------- - -describe('toSnakeCase', () => { - it('converts PascalCase', () => { - expect(toSnakeCase('Organization')).toBe('organization'); - expect(toSnakeCase('OrganizationDomain')).toBe('organization_domain'); - expect(toSnakeCase('SSOProfile')).toBe('sso_profile'); - }); - - it('handles already snake_case', () => { - expect(toSnakeCase('organization')).toBe('organization'); - }); -}); - -describe('toPascalCase', () => { - it('converts snake_case', () => { - expect(toPascalCase('organization')).toBe('Organization'); - expect(toPascalCase('organization_domain')).toBe('OrganizationDomain'); - }); - - it('converts hyphenated', () => { - expect(toPascalCase('magic-auth')).toBe('MagicAuth'); - }); -}); - -describe('toCamelCase', () => { - it('converts snake_case', () => { - expect(toCamelCase('organization')).toBe('organization'); - expect(toCamelCase('organization_domain')).toBe('organizationDomain'); - }); -}); - -describe('pluralize', () => { - it('adds -s to regular words', () => { - expect(pluralize('organization')).toBe('organizations'); - expect(pluralize('user')).toBe('users'); - }); - - it('adds -ies for consonant+y', () => { - expect(pluralize('identity')).toBe('identities'); - }); - - it('adds -es for words ending in s/x/z', () => { - expect(pluralize('address')).toBe('addresses'); - }); -}); - -describe('singularize', () => { - it('removes trailing -s', () => { - expect(singularize('organizations')).toBe('organization'); - expect(singularize('users')).toBe('user'); - }); - - it('handles -ies', () => { - expect(singularize('identities')).toBe('identity'); - }); - - it('handles -ses', () => { - expect(singularize('addresses')).toBe('address'); - }); -}); - -describe('openApiPathToHono', () => { - it('converts path params', () => { - expect(openApiPathToHono('/organizations/{id}')).toBe('/organizations/:id'); - expect(openApiPathToHono('/users/{user_id}/sessions')).toBe('/users/:user_id/sessions'); - }); - - it('handles multiple params', () => { - expect(openApiPathToHono('/orgs/{org_id}/members/{id}')).toBe('/orgs/:org_id/members/:id'); - }); - - it('passes through paths without params', () => { - expect(openApiPathToHono('/organizations')).toBe('/organizations'); - }); -}); - -// --------------------------------------------------------------------------- -// schemaToTsType -// --------------------------------------------------------------------------- - -describe('schemaToTsType', () => { - const emptySpec: OpenAPISpec = {}; - - it('converts string type', () => { - expect(schemaToTsType({ type: 'string' }, emptySpec)).toBe('string'); - }); - - it('converts integer type', () => { - expect(schemaToTsType({ type: 'integer' }, emptySpec)).toBe('number'); - }); - - it('converts boolean type', () => { - expect(schemaToTsType({ type: 'boolean' }, emptySpec)).toBe('boolean'); - }); - - it('converts enum to union type', () => { - expect(schemaToTsType({ type: 'string', enum: ['active', 'inactive'] }, emptySpec)).toBe("'active' | 'inactive'"); - }); - - it('converts array type', () => { - expect(schemaToTsType({ type: 'array', items: { type: 'string' } }, emptySpec)).toBe('string[]'); - }); - - it('converts object with additionalProperties', () => { - expect(schemaToTsType({ type: 'object', additionalProperties: { type: 'string' } }, emptySpec)).toBe( - 'Record', - ); - }); - - it('handles unknown type', () => { - expect(schemaToTsType({}, emptySpec)).toBe('unknown'); - }); - - it('resolves $ref', () => { - const spec: OpenAPISpec = { - components: { - schemas: { - Status: { type: 'string', enum: ['active', 'pending'] }, - }, - }, - }; - expect(schemaToTsType({ $ref: '#/components/schemas/Status' }, spec)).toBe("'active' | 'pending'"); - }); -}); - -// --------------------------------------------------------------------------- -// parseSpec -// --------------------------------------------------------------------------- - -describe('parseSpec', () => { - function makeSpec(overrides: Partial = {}): OpenAPISpec { - return { - openapi: '3.0.0', - info: { title: 'Test', version: '1.0.0' }, - ...overrides, - }; - } - - it('returns empty entities and routes for empty spec', () => { - const result = parseSpec(makeSpec()); - expect(result.entities).toEqual([]); - expect(result.routes).toEqual([]); - }); - - it('extracts an entity from a schema', () => { - const spec = makeSpec({ - components: { - schemas: { - Organization: { - type: 'object', - required: ['name'], - properties: { - id: { type: 'string' }, - object: { type: 'string', enum: ['organization'] }, - name: { type: 'string' }, - external_id: { type: 'string', nullable: true }, - created_at: { type: 'string', format: 'date-time' }, - updated_at: { type: 'string', format: 'date-time' }, - }, - }, - }, - }, - }); - - const result = parseSpec(spec); - expect(result.entities).toHaveLength(1); - - const org = result.entities[0]; - expect(org.name).toBe('Organization'); - expect(org.objectType).toBe('organization'); - expect(org.idPrefix).toBe('org'); - // id, created_at, updated_at should be excluded from fields - expect(org.fields.find((f) => f.name === 'id')).toBeUndefined(); - expect(org.fields.find((f) => f.name === 'created_at')).toBeUndefined(); - expect(org.fields.find((f) => f.name === 'updated_at')).toBeUndefined(); - // object and name should be present - expect(org.fields.find((f) => f.name === 'object')).toBeDefined(); - expect(org.fields.find((f) => f.name === 'name')).toBeDefined(); - expect(org.fields.find((f) => f.name === 'external_id')).toBeDefined(); - }); - - it('indexes external_id and fields ending with _id', () => { - const spec = makeSpec({ - components: { - schemas: { - Membership: { - type: 'object', - required: ['organization_id', 'user_id'], - properties: { - object: { type: 'string' }, - organization_id: { type: 'string' }, - user_id: { type: 'string' }, - external_id: { type: 'string', nullable: true }, - }, - }, - }, - }, - }); - - const result = parseSpec(spec); - const membership = result.entities[0]; - expect(membership.indexFields).toContain('organization_id'); - expect(membership.indexFields).toContain('user_id'); - expect(membership.indexFields).toContain('external_id'); - }); - - it('extracts routes from paths', () => { - const spec = makeSpec({ - paths: { - '/organizations': { - get: { - tags: ['organizations'], - operationId: 'listOrganizations', - summary: 'List organizations', - }, - post: { - tags: ['organizations'], - operationId: 'createOrganization', - summary: 'Create organization', - }, - }, - '/organizations/{id}': { - get: { - tags: ['organizations'], - operationId: 'getOrganization', - summary: 'Get organization', - }, - put: { - tags: ['organizations'], - operationId: 'updateOrganization', - summary: 'Update organization', - }, - delete: { - tags: ['organizations'], - operationId: 'deleteOrganization', - summary: 'Delete organization', - }, - }, - }, - }); - - const result = parseSpec(spec); - expect(result.routes).toHaveLength(1); - - const route = result.routes[0]; - expect(route.tag).toBe('organizations'); - expect(route.filename).toBe('organizations.ts'); - expect(route.functionName).toBe('organizationRoutes'); - expect(route.storeAccessor).toBe('organizations'); - expect(route.formatterName).toBe('formatOrganization'); - expect(route.operations).toHaveLength(5); - - const listOp = route.operations.find((o) => o.operationId === 'listOrganizations')!; - expect(listOp.method).toBe('get'); - expect(listOp.isList).toBe(true); - expect(listOp.hasIdParam).toBe(false); - - const getOp = route.operations.find((o) => o.operationId === 'getOrganization')!; - expect(getOp.method).toBe('get'); - expect(getOp.isList).toBe(false); - expect(getOp.hasIdParam).toBe(true); - expect(getOp.path).toBe('/organizations/:id'); - }); - - it('infers tag from path when no tags provided', () => { - const spec = makeSpec({ - paths: { - '/connections': { - get: { operationId: 'listConnections' }, - }, - }, - }); - - const result = parseSpec(spec); - expect(result.routes[0].tag).toBe('connections'); - }); -}); - -// --------------------------------------------------------------------------- -// Code generation -// --------------------------------------------------------------------------- - -const sampleEntity: ParsedEntity = { - name: 'Organization', - objectType: 'organization', - idPrefix: 'org', - fields: [ - { name: 'object', tsType: "'organization'", nullable: false }, - { name: 'name', tsType: 'string', nullable: false }, - { name: 'external_id', tsType: 'string', nullable: true }, - { name: 'metadata', tsType: 'Record', nullable: false }, - ], - indexFields: ['name', 'external_id'], -}; - -describe('generateEntities', () => { - it('generates entity interface', () => { - const output = generateEntities([sampleEntity]); - expect(output).toContain("import type { Entity } from '../../core/index.js';"); - expect(output).toContain('export interface WorkOSOrganization extends Entity {'); - expect(output).toContain(" object: 'organization';"); - expect(output).toContain(' name: string;'); - expect(output).toContain(' external_id: string | null;'); - expect(output).toContain(' metadata: Record;'); - }); - - it('does not duplicate null in already-nullable types', () => { - const entity: ParsedEntity = { - name: 'Test', - objectType: 'test', - idPrefix: 'test', - fields: [{ name: 'value', tsType: 'string | null', nullable: true }], - indexFields: [], - }; - const output = generateEntities([entity]); - // Should not produce "string | null | null" - expect(output).toContain(' value: string | null;'); - expect(output).not.toContain('null | null'); - }); -}); - -describe('generateStore', () => { - it('generates store interface and factory', () => { - const output = generateStore([sampleEntity]); - expect(output).toContain('export interface WorkOSGeneratedStore {'); - expect(output).toContain(' organizations: Collection;'); - expect(output).toContain('export function getWorkOSGeneratedStore(store: Store): WorkOSGeneratedStore {'); - expect(output).toContain( - "store.collection('workos.organizations', 'org', ['name', 'external_id'])", - ); - }); -}); - -describe('generateHelpers', () => { - it('generates format functions', () => { - const output = generateHelpers([sampleEntity]); - expect(output).toContain( - 'export function formatOrganization(organization: WorkOSOrganization): Record {', - ); - expect(output).toContain(" object: 'organization',"); - expect(output).toContain(' id: organization.id,'); - expect(output).toContain(' name: organization.name,'); - expect(output).toContain(' created_at: organization.created_at,'); - expect(output).toContain(' updated_at: organization.updated_at,'); - }); - - it('generates parseListParams', () => { - const output = generateHelpers([sampleEntity]); - expect(output).toContain('export function parseListParams(url: URL)'); - }); -}); - -describe('generateRoutes', () => { - const sampleRoute: ParsedRoute = { - tag: 'organizations', - filename: 'organizations.ts', - functionName: 'organizationRoutes', - storeAccessor: 'organizations', - formatterName: 'formatOrganization', - operations: [ - { method: 'post', path: '/organizations', hasIdParam: false, isList: false, queryParams: [] }, - { - method: 'get', - path: '/organizations', - operationId: 'listOrganizations', - summary: 'List organizations', - hasIdParam: false, - isList: true, - queryParams: ['limit', 'order'], - }, - { - method: 'get', - path: '/organizations/:id', - operationId: 'getOrganization', - summary: 'Get organization', - hasIdParam: true, - isList: false, - queryParams: [], - }, - { - method: 'put', - path: '/organizations/:id', - operationId: 'updateOrganization', - hasIdParam: true, - isList: false, - queryParams: [], - }, - { - method: 'delete', - path: '/organizations/:id', - operationId: 'deleteOrganization', - hasIdParam: true, - isList: false, - queryParams: [], - }, - ], - }; - - it('generates route function with correct structure', () => { - const output = generateRoutes(sampleRoute); - expect(output).toContain('export function organizationRoutes(ctx: RouteContext): void {'); - expect(output).toContain('const ws = getWorkOSGeneratedStore(store);'); - }); - - it('generates POST handler', () => { - const output = generateRoutes(sampleRoute); - expect(output).toContain("app.post('/organizations', async (c) => {"); - expect(output).toContain('const body = await parseJsonBody(c);'); - expect(output).toContain('ws.organizations.insert({'); - expect(output).toContain('return c.json(formatOrganization(item), 201);'); - }); - - it('generates list GET handler', () => { - const output = generateRoutes(sampleRoute); - expect(output).toContain("app.get('/organizations', (c) => {"); - expect(output).toContain('const params = parseListParams(url);'); - expect(output).toContain("object: 'list',"); - expect(output).toContain('data: result.data.map(formatOrganization),'); - }); - - it('generates single GET handler', () => { - const output = generateRoutes(sampleRoute); - expect(output).toContain("app.get('/organizations/:id', (c) => {"); - expect(output).toContain("ws.organizations.get(c.req.param('id'))"); - expect(output).toContain("if (!item) throw notFound('Organization');"); - }); - - it('generates PUT handler', () => { - const output = generateRoutes(sampleRoute); - expect(output).toContain("app.put('/organizations/:id', async (c) => {"); - expect(output).toContain('ws.organizations.update(item.id, body)'); - }); - - it('generates DELETE handler', () => { - const output = generateRoutes(sampleRoute); - expect(output).toContain("app.delete('/organizations/:id', (c) => {"); - expect(output).toContain('ws.organizations.delete(item.id);'); - expect(output).toContain('return c.body(null, 204);'); - }); -}); - -// --------------------------------------------------------------------------- -// Idempotency -// --------------------------------------------------------------------------- - -describe('idempotency', () => { - it('produces identical output when run twice', () => { - const spec: OpenAPISpec = { - openapi: '3.0.0', - info: { title: 'Test', version: '1.0.0' }, - components: { - schemas: { - Widget: { - type: 'object', - required: ['name'], - properties: { - object: { type: 'string' }, - name: { type: 'string' }, - color: { type: 'string', nullable: true }, - }, - }, - }, - }, - paths: { - '/widgets': { - get: { tags: ['widgets'], operationId: 'listWidgets' }, - post: { tags: ['widgets'], operationId: 'createWidget' }, - }, - '/widgets/{id}': { - get: { tags: ['widgets'], operationId: 'getWidget' }, - delete: { tags: ['widgets'], operationId: 'deleteWidget' }, - }, - }, - }; - - const run1 = parseSpec(spec); - const run2 = parseSpec(spec); - - expect(generateEntities(run1.entities)).toBe(generateEntities(run2.entities)); - expect(generateStore(run1.entities)).toBe(generateStore(run2.entities)); - expect(generateHelpers(run1.entities)).toBe(generateHelpers(run2.entities)); - - for (let i = 0; i < run1.routes.length; i++) { - expect(generateRoutes(run1.routes[i])).toBe(generateRoutes(run2.routes[i])); - } - }); -}); - -// --------------------------------------------------------------------------- -// End-to-end: full spec parsing + generation -// --------------------------------------------------------------------------- - -describe('end-to-end generation', () => { - const spec: OpenAPISpec = { - openapi: '3.0.0', - info: { title: 'WorkOS', version: '1.0.0' }, - components: { - schemas: { - Organization: { - type: 'object', - required: ['name', 'object'], - properties: { - id: { type: 'string' }, - object: { type: 'string', enum: ['organization'] }, - name: { type: 'string' }, - external_id: { type: 'string', nullable: true }, - metadata: { type: 'object', additionalProperties: { type: 'string' } }, - created_at: { type: 'string', format: 'date-time' }, - updated_at: { type: 'string', format: 'date-time' }, - }, - }, - User: { - type: 'object', - required: ['email', 'object'], - properties: { - id: { type: 'string' }, - object: { type: 'string', enum: ['user'] }, - email: { type: 'string' }, - first_name: { type: 'string', nullable: true }, - last_name: { type: 'string', nullable: true }, - email_verified: { type: 'boolean' }, - created_at: { type: 'string', format: 'date-time' }, - updated_at: { type: 'string', format: 'date-time' }, - }, - }, - }, - }, - paths: { - '/organizations': { - get: { - tags: ['organizations'], - operationId: 'listOrganizations', - summary: 'List organizations', - parameters: [ - { name: 'limit', in: 'query', schema: { type: 'integer' } }, - { name: 'name', in: 'query', schema: { type: 'string' } }, - ], - }, - post: { - tags: ['organizations'], - operationId: 'createOrganization', - summary: 'Create organization', - }, - }, - '/organizations/{id}': { - get: { - tags: ['organizations'], - operationId: 'getOrganization', - summary: 'Get an organization', - }, - put: { - tags: ['organizations'], - operationId: 'updateOrganization', - summary: 'Update an organization', - }, - delete: { - tags: ['organizations'], - operationId: 'deleteOrganization', - summary: 'Delete an organization', - }, - }, - '/user_management/users': { - get: { - tags: ['user_management_users'], - operationId: 'listUsers', - summary: 'List users', - }, - post: { - tags: ['user_management_users'], - operationId: 'createUser', - summary: 'Create user', - }, - }, - '/user_management/users/{id}': { - get: { - tags: ['user_management_users'], - operationId: 'getUser', - summary: 'Get user', - }, - }, - }, - }; - - it('parses entities from schemas', () => { - const parsed = parseSpec(spec); - expect(parsed.entities).toHaveLength(2); - expect(parsed.entities.map((e) => e.name).sort()).toEqual(['Organization', 'User']); - }); - - it('parses routes from paths', () => { - const parsed = parseSpec(spec); - expect(parsed.routes).toHaveLength(2); - const tags = parsed.routes.map((r) => r.tag).sort(); - expect(tags).toEqual(['organizations', 'user_management_users']); - }); - - it('generates valid entity code', () => { - const parsed = parseSpec(spec); - const entitiesCode = generateEntities(parsed.entities); - // Should produce valid-looking TypeScript - expect(entitiesCode).toContain('export interface WorkOSOrganization extends Entity'); - expect(entitiesCode).toContain('export interface WorkOSUser extends Entity'); - }); - - it('generates store with all entities', () => { - const parsed = parseSpec(spec); - const storeCode = generateStore(parsed.entities); - expect(storeCode).toContain('organizations: Collection'); - expect(storeCode).toContain('users: Collection'); - }); - - it('generates helpers with format functions', () => { - const parsed = parseSpec(spec); - const helpersCode = generateHelpers(parsed.entities); - expect(helpersCode).toContain('export function formatOrganization'); - expect(helpersCode).toContain('export function formatUser'); - }); - - it('generates route stubs', () => { - const parsed = parseSpec(spec); - const orgRoute = parsed.routes.find((r) => r.tag === 'organizations')!; - const routeCode = generateRoutes(orgRoute); - expect(routeCode).toContain("app.post('/organizations'"); - expect(routeCode).toContain("app.get('/organizations'"); - expect(routeCode).toContain("app.get('/organizations/:id'"); - expect(routeCode).toContain("app.put('/organizations/:id'"); - expect(routeCode).toContain("app.delete('/organizations/:id'"); - }); - - it('handles query parameters in list endpoints', () => { - const parsed = parseSpec(spec); - const orgRoute = parsed.routes.find((r) => r.tag === 'organizations')!; - const listOp = orgRoute.operations.find((o) => o.isList)!; - expect(listOp.queryParams).toContain('limit'); - expect(listOp.queryParams).toContain('name'); - }); -}); diff --git a/scripts/gen-routes-lib.ts b/scripts/gen-routes-lib.ts deleted file mode 100644 index 94039972..00000000 --- a/scripts/gen-routes-lib.ts +++ /dev/null @@ -1,647 +0,0 @@ -/** - * Core codegen logic for gen-routes. Separated from the CLI entry point - * so the transformation functions can be unit-tested independently. - */ - -// --------------------------------------------------------------------------- -// OpenAPI types (minimal subset we need) -// --------------------------------------------------------------------------- - -export interface OpenAPISpec { - openapi?: string; - info?: { title?: string; version?: string }; - paths?: Record; - components?: { schemas?: Record }; -} - -export interface PathItem { - get?: OperationObject; - post?: OperationObject; - put?: OperationObject; - patch?: OperationObject; - delete?: OperationObject; - parameters?: ParameterObject[]; -} - -export interface OperationObject { - operationId?: string; - summary?: string; - tags?: string[]; - parameters?: ParameterObject[]; - requestBody?: { - content?: Record; - }; - responses?: Record< - string, - { - description?: string; - content?: Record; - } - >; -} - -export interface ParameterObject { - name: string; - in: 'path' | 'query' | 'header'; - required?: boolean; - schema?: SchemaObject; -} - -export interface SchemaObject { - type?: string; - format?: string; - enum?: string[]; - properties?: Record; - required?: string[]; - items?: SchemaObject; - $ref?: string; - allOf?: SchemaObject[]; - oneOf?: SchemaObject[]; - anyOf?: SchemaObject[]; - nullable?: boolean; - description?: string; - additionalProperties?: boolean | SchemaObject; -} - -// --------------------------------------------------------------------------- -// Parsed intermediate representation -// --------------------------------------------------------------------------- - -export interface ParsedEntity { - /** PascalCase name, e.g. "Organization" */ - name: string; - /** snake_case object type, e.g. "organization" */ - objectType: string; - /** ID prefix, e.g. "org" */ - idPrefix: string; - /** Fields beyond the base Entity (id, created_at, updated_at) */ - fields: ParsedField[]; - /** Fields to index in the store collection */ - indexFields: string[]; -} - -export interface ParsedField { - name: string; - tsType: string; - nullable: boolean; - description?: string; -} - -export interface ParsedRoute { - /** The resource tag, e.g. "organizations" */ - tag: string; - /** Output filename, e.g. "organizations.ts" */ - filename: string; - /** Function name, e.g. "organizationRoutes" */ - functionName: string; - /** The collection accessor on WorkOSStore, e.g. "organizations" */ - storeAccessor: string; - /** The formatter function name, e.g. "formatOrganization" */ - formatterName: string; - /** Individual route operations */ - operations: ParsedOperation[]; -} - -export interface ParsedOperation { - method: 'get' | 'post' | 'put' | 'patch' | 'delete'; - path: string; - operationId?: string; - summary?: string; - /** Whether the path has an :id param */ - hasIdParam: boolean; - /** Whether this is a list endpoint (GET without :id in the resource path) */ - isList: boolean; - /** Query parameter names */ - queryParams: string[]; -} - -export interface ParsedSpec { - entities: ParsedEntity[]; - routes: ParsedRoute[]; -} - -export interface GeneratedOutput { - [filename: string]: string; -} - -// --------------------------------------------------------------------------- -// Spec parsing -// --------------------------------------------------------------------------- - -/** Well-known ID prefixes matching src/emulate/core/id.ts */ -const KNOWN_PREFIXES: Record = { - organization: 'org', - organization_domain: 'org_domain', - organization_membership: 'om', - user: 'user', - session: 'session', - email_verification: 'email_verification', - password_reset: 'password_reset', - magic_auth: 'magic_auth', - authentication_factor: 'auth_factor', - authorization_code: 'auth_code', - identity: 'identity', - connection: 'conn', - connection_domain: 'conn_domain', - profile: 'prof', - sso_profile: 'prof', - sso_authorization: 'sso_auth', - directory: 'directory', - directory_user: 'directory_user', - directory_group: 'directory_grp', - event: 'event', - invitation: 'inv', -}; - -/** Base entity fields that are auto-managed — excluded from generated fields. */ -const BASE_FIELDS = new Set(['id', 'created_at', 'updated_at']); - -/** - * Resolve a $ref to a schema name. Only handles local refs like - * "#/components/schemas/Organization". - */ -function resolveRefName(ref: string): string { - const parts = ref.split('/'); - return parts[parts.length - 1]; -} - -function resolveSchema(schema: SchemaObject, spec: OpenAPISpec): SchemaObject { - if (schema.$ref) { - const name = resolveRefName(schema.$ref); - const resolved = spec.components?.schemas?.[name]; - return resolved ? resolveSchema(resolved, spec) : schema; - } - if (schema.allOf) { - const merged: SchemaObject = { type: 'object', properties: {}, required: [] }; - for (const sub of schema.allOf) { - const resolved = resolveSchema(sub, spec); - if (resolved.properties) { - Object.assign(merged.properties!, resolved.properties); - } - if (resolved.required) { - merged.required!.push(...resolved.required); - } - } - return merged; - } - return schema; -} - -/** Convert an OpenAPI type + format to a TypeScript type string. */ -export function schemaToTsType(schema: SchemaObject, spec: OpenAPISpec): string { - if (schema.$ref) { - const name = resolveRefName(schema.$ref); - const resolved = spec.components?.schemas?.[name]; - if (resolved) return schemaToTsType(resolved, spec); - return 'unknown'; - } - - if (schema.allOf) { - const resolved = resolveSchema(schema, spec); - return schemaToTsType(resolved, spec); - } - - if (schema.oneOf || schema.anyOf) { - const variants = (schema.oneOf ?? schema.anyOf)!; - const types = variants.map((v) => schemaToTsType(v, spec)); - return types.join(' | '); - } - - if (schema.enum) { - return schema.enum.map((v) => `'${v}'`).join(' | '); - } - - switch (schema.type) { - case 'string': - return 'string'; - case 'integer': - case 'number': - return 'number'; - case 'boolean': - return 'boolean'; - case 'array': - if (schema.items) { - return `${schemaToTsType(schema.items, spec)}[]`; - } - return 'unknown[]'; - case 'object': - if (schema.additionalProperties) { - if (typeof schema.additionalProperties === 'boolean') { - return 'Record'; - } - const valType = schemaToTsType(schema.additionalProperties, spec); - return `Record`; - } - if (schema.properties) { - const entries = Object.entries(schema.properties).map(([k, v]) => { - const t = schemaToTsType(v, spec); - return `${k}: ${t}`; - }); - return `{ ${entries.join('; ')} }`; - } - return 'Record'; - default: - return 'unknown'; - } -} - -/** Convert a schema name to snake_case. */ -export function toSnakeCase(name: string): string { - return name - .replace(/([a-z])([A-Z])/g, '$1_$2') - .replace(/([A-Z]+)([A-Z][a-z])/g, '$1_$2') - .toLowerCase(); -} - -/** Convert a snake_case string to PascalCase. */ -export function toPascalCase(name: string): string { - return name - .split(/[_\-\s]+/) - .map((w) => w.charAt(0).toUpperCase() + w.slice(1).toLowerCase()) - .join(''); -} - -/** Convert a snake_case string to camelCase. */ -export function toCamelCase(name: string): string { - const pascal = toPascalCase(name); - return pascal.charAt(0).toLowerCase() + pascal.slice(1); -} - -/** Pluralize a simple English word (naive). */ -export function pluralize(word: string): string { - if (word.endsWith('s') || word.endsWith('x') || word.endsWith('z')) return word + 'es'; - if (word.endsWith('y') && !/[aeiou]y$/i.test(word)) return word.slice(0, -1) + 'ies'; - return word + 's'; -} - -/** Singularize a simple English word (naive). */ -export function singularize(word: string): string { - if (word.endsWith('ies')) return word.slice(0, -3) + 'y'; - if (word.endsWith('ses') || word.endsWith('xes') || word.endsWith('zes')) return word.slice(0, -2); - if (word.endsWith('s') && !word.endsWith('ss')) return word.slice(0, -1); - return word; -} - -/** - * Heuristic: guess which fields should be indexed for a collection. - * Looks at field names that end with _id or are common lookup fields. - */ -function guessIndexFields(fields: ParsedField[]): string[] { - const indexes: string[] = []; - for (const f of fields) { - if (f.name === 'object') continue; - if (f.name.endsWith('_id') && f.name !== 'external_id' && f.name !== 'stripe_customer_id' && f.name !== 'idp_id') { - indexes.push(f.name); - } - if (f.name === 'email' || f.name === 'code' || f.name === 'domain') { - indexes.push(f.name); - } - } - // Also add external_id if present — the hand-written code indexes it for some collections - if (fields.some((f) => f.name === 'external_id')) { - indexes.push('external_id'); - } - return indexes; -} - -function extractEntityFromSchema(schemaName: string, schema: SchemaObject, spec: OpenAPISpec): ParsedEntity | null { - const resolved = resolveSchema(schema, spec); - if (resolved.type !== 'object' || !resolved.properties) return null; - - const objectType = toSnakeCase(schemaName); - const required = new Set(resolved.required ?? []); - - const fields: ParsedField[] = []; - for (const [propName, propSchema] of Object.entries(resolved.properties)) { - if (BASE_FIELDS.has(propName)) continue; - - const resolvedProp = propSchema.$ref ? resolveSchema(propSchema, spec) : propSchema; - const tsType = schemaToTsType(resolvedProp, spec); - const nullable = resolvedProp.nullable === true || !required.has(propName); - - fields.push({ - name: propName, - tsType, - nullable, - description: resolvedProp.description, - }); - } - - if (fields.length === 0) return null; - - const idPrefix = KNOWN_PREFIXES[objectType] ?? objectType.replace(/_/g, '_').slice(0, 10); - const indexFields = guessIndexFields(fields); - - return { - name: schemaName, - objectType, - idPrefix, - fields, - indexFields, - }; -} - -/** Convert OpenAPI path "/organizations/{id}" to Hono path "/organizations/:id". */ -export function openApiPathToHono(path: string): string { - return path.replace(/\{([^}]+)\}/g, ':$1'); -} - -function extractRoutes(spec: OpenAPISpec): Map { - const tagOps = new Map(); - - for (const [path, item] of Object.entries(spec.paths ?? {})) { - const methods: Array<'get' | 'post' | 'put' | 'patch' | 'delete'> = ['get', 'post', 'put', 'patch', 'delete']; - - for (const method of methods) { - const op = item[method]; - if (!op) continue; - - const tag = op.tags?.[0] ?? inferTagFromPath(path); - const honoPath = openApiPathToHono(path); - const hasIdParam = /\/:id\b/.test(honoPath) || /\/:[\w]+_id\b/.test(honoPath); - const isList = method === 'get' && !hasIdParam; - - const queryParams: string[] = []; - const allParams = [...(item.parameters ?? []), ...(op.parameters ?? [])]; - for (const p of allParams) { - if (p.in === 'query') { - queryParams.push(p.name); - } - } - - if (!tagOps.has(tag)) tagOps.set(tag, []); - tagOps.get(tag)!.push({ - method, - path: honoPath, - operationId: op.operationId, - summary: op.summary, - hasIdParam, - isList, - queryParams, - }); - } - } - - return tagOps; -} - -function inferTagFromPath(path: string): string { - const segments = path.split('/').filter(Boolean); - // Skip path params and use first real segment - for (const seg of segments) { - if (!seg.startsWith('{')) return seg; - } - return 'default'; -} - -export function parseSpec(spec: OpenAPISpec): ParsedSpec { - const entities: ParsedEntity[] = []; - - // Extract entities from schemas - if (spec.components?.schemas) { - for (const [name, schema] of Object.entries(spec.components.schemas)) { - const entity = extractEntityFromSchema(name, schema, spec); - if (entity) { - entities.push(entity); - } - } - } - - // Extract routes from paths - const tagOps = extractRoutes(spec); - const routes: ParsedRoute[] = []; - - for (const [tag, operations] of tagOps) { - const singular = singularize(tag); - const pascalSingular = toPascalCase(singular); - const camelPlural = toCamelCase(tag); - - routes.push({ - tag, - filename: `${tag.replace(/_/g, '-')}.ts`, - functionName: `${toCamelCase(singular)}Routes`, - storeAccessor: camelPlural, - formatterName: `format${pascalSingular}`, - operations, - }); - } - - return { entities, routes }; -} - -// --------------------------------------------------------------------------- -// Code generation -// --------------------------------------------------------------------------- - -export function generateEntities(entities: ParsedEntity[]): string { - const lines: string[] = []; - lines.push("import type { Entity } from '../../core/index.js';"); - lines.push(''); - - for (const entity of entities) { - lines.push(`export interface WorkOS${entity.name} extends Entity {`); - - // Always include `object` field with literal type - const hasObjectField = entity.fields.some((f) => f.name === 'object'); - if (hasObjectField) { - lines.push(` object: '${entity.objectType}';`); - } - - for (const field of entity.fields) { - if (field.name === 'object') continue; // Already handled above with literal type - - let tsType = field.tsType; - if (field.nullable && !tsType.includes('null')) { - tsType = `${tsType} | null`; - } - lines.push(` ${field.name}: ${tsType};`); - } - - lines.push('}'); - lines.push(''); - } - - return lines.join('\n'); -} - -export function generateStore(entities: ParsedEntity[]): string { - const lines: string[] = []; - - lines.push("import { type Store, type Collection } from '../../core/index.js';"); - - // Import entity types - const typeNames = entities.map((e) => `WorkOS${e.name}`); - if (typeNames.length > 0) { - lines.push('import type {'); - for (const t of typeNames) { - lines.push(` ${t},`); - } - lines.push("} from './entities.js';"); - } - - lines.push(''); - - // Store interface - lines.push('export interface WorkOSGeneratedStore {'); - for (const entity of entities) { - const accessor = toCamelCase(pluralize(entity.objectType)); - lines.push(` ${accessor}: Collection;`); - } - lines.push('}'); - lines.push(''); - - // getWorkOSGeneratedStore function - lines.push('export function getWorkOSGeneratedStore(store: Store): WorkOSGeneratedStore {'); - lines.push(' return {'); - for (const entity of entities) { - const accessor = toCamelCase(pluralize(entity.objectType)); - const namespace = `workos.${pluralize(entity.objectType)}`; - const indexList = entity.indexFields.map((f) => `'${f}'`).join(', '); - lines.push( - ` ${accessor}: store.collection('${namespace}', '${entity.idPrefix}', [${indexList}]),`, - ); - } - lines.push(' };'); - lines.push('}'); - lines.push(''); - - return lines.join('\n'); -} - -export function generateHelpers(entities: ParsedEntity[]): string { - const lines: string[] = []; - - // Imports - const typeNames = entities.map((e) => `WorkOS${e.name}`); - lines.push('import type {'); - for (const t of typeNames) { - lines.push(` ${t},`); - } - lines.push("} from './entities.js';"); - lines.push(''); - - // Generate a format function for each entity - for (const entity of entities) { - const typeName = `WorkOS${entity.name}`; - const paramName = toCamelCase(entity.objectType); - const fnName = `format${entity.name}`; - - lines.push(`export function ${fnName}(${paramName}: ${typeName}): Record {`); - lines.push(' return {'); - - // object field - if (entity.fields.some((f) => f.name === 'object')) { - lines.push(` object: '${entity.objectType}',`); - } - lines.push(` id: ${paramName}.id,`); - - for (const field of entity.fields) { - if (field.name === 'object') continue; - lines.push(` ${field.name}: ${paramName}.${field.name},`); - } - - lines.push(` created_at: ${paramName}.created_at,`); - lines.push(` updated_at: ${paramName}.updated_at,`); - lines.push(' };'); - lines.push('}'); - lines.push(''); - } - - // parseListParams helper - lines.push('export function parseListParams(url: URL) {'); - lines.push(" const limit = Math.max(1, Math.min(parseInt(url.searchParams.get('limit') ?? '10'), 100));"); - lines.push(" const order = (url.searchParams.get('order') as 'asc' | 'desc') ?? 'desc';"); - lines.push(" const before = url.searchParams.get('before') ?? undefined;"); - lines.push(" const after = url.searchParams.get('after') ?? undefined;"); - lines.push(' return { limit, order, before, after };'); - lines.push('}'); - lines.push(''); - - return lines.join('\n'); -} - -export function generateRoutes(route: ParsedRoute): string { - const lines: string[] = []; - - lines.push("import { type RouteContext, notFound, validationError, parseJsonBody } from '../../../core/index.js';"); - lines.push("import { getWorkOSGeneratedStore } from '../store.js';"); - lines.push(`import { ${route.formatterName}, parseListParams } from '../helpers.js';`); - lines.push(''); - - lines.push(`export function ${route.functionName}(ctx: RouteContext): void {`); - lines.push(' const { app, store } = ctx;'); - lines.push(' const ws = getWorkOSGeneratedStore(store);'); - lines.push(''); - - for (const op of route.operations) { - lines.push(` // ${op.summary ?? op.operationId ?? `${op.method.toUpperCase()} ${op.path}`}`); - - if (op.method === 'post') { - lines.push(` app.post('${op.path}', async (c) => {`); - lines.push(' const body = await parseJsonBody(c);'); - lines.push(''); - lines.push(` const item = ws.${route.storeAccessor}.insert({`); - lines.push(' ...body,'); - lines.push(' });'); - lines.push(''); - lines.push(` return c.json(${route.formatterName}(item), 201);`); - lines.push(' });'); - } else if (op.method === 'get' && op.isList) { - lines.push(` app.get('${op.path}', (c) => {`); - lines.push(' const url = new URL(c.req.url);'); - lines.push(' const params = parseListParams(url);'); - lines.push(''); - lines.push(` const result = ws.${route.storeAccessor}.list({`); - lines.push(' ...params,'); - lines.push(' });'); - lines.push(''); - lines.push(' return c.json({'); - lines.push(" object: 'list',"); - lines.push(` data: result.data.map(${route.formatterName}),`); - lines.push(' list_metadata: result.list_metadata,'); - lines.push(' });'); - lines.push(' });'); - } else if (op.method === 'get' && op.hasIdParam) { - lines.push(` app.get('${op.path}', (c) => {`); - lines.push(` const item = ws.${route.storeAccessor}.get(c.req.param('id'));`); - lines.push(` if (!item) throw notFound('${toPascalCase(singularize(route.tag))}');`); - lines.push(` return c.json(${route.formatterName}(item));`); - lines.push(' });'); - } else if (op.method === 'put' && op.hasIdParam) { - lines.push(` app.put('${op.path}', async (c) => {`); - lines.push(` const item = ws.${route.storeAccessor}.get(c.req.param('id'));`); - lines.push(` if (!item) throw notFound('${toPascalCase(singularize(route.tag))}');`); - lines.push(''); - lines.push(' const body = await parseJsonBody(c);'); - lines.push(` const updated = ws.${route.storeAccessor}.update(item.id, body);`); - lines.push(` return c.json(${route.formatterName}(updated!));`); - lines.push(' });'); - } else if (op.method === 'patch' && op.hasIdParam) { - lines.push(` app.patch('${op.path}', async (c) => {`); - lines.push(` const item = ws.${route.storeAccessor}.get(c.req.param('id'));`); - lines.push(` if (!item) throw notFound('${toPascalCase(singularize(route.tag))}');`); - lines.push(''); - lines.push(' const body = await parseJsonBody(c);'); - lines.push(` const updated = ws.${route.storeAccessor}.update(item.id, body);`); - lines.push(` return c.json(${route.formatterName}(updated!));`); - lines.push(' });'); - } else if (op.method === 'delete' && op.hasIdParam) { - lines.push(` app.delete('${op.path}', (c) => {`); - lines.push(` const item = ws.${route.storeAccessor}.get(c.req.param('id'));`); - lines.push(` if (!item) throw notFound('${toPascalCase(singularize(route.tag))}');`); - lines.push(` ws.${route.storeAccessor}.delete(item.id);`); - lines.push(' return c.body(null, 204);'); - lines.push(' });'); - } else { - // Fallback: generate a TODO stub - lines.push(` // TODO: implement ${op.method.toUpperCase()} ${op.path}`); - } - - lines.push(''); - } - - lines.push('}'); - lines.push(''); - - return lines.join('\n'); -} diff --git a/scripts/gen-routes.ts b/scripts/gen-routes.ts deleted file mode 100644 index 4ce45ff3..00000000 --- a/scripts/gen-routes.ts +++ /dev/null @@ -1,96 +0,0 @@ -#!/usr/bin/env tsx -/** - * Codegen script: reads a WorkOS OpenAPI spec and generates emulator TypeScript - * files (entities, store, helpers, route stubs). - * - * Usage: - * pnpm gen:routes path/to/openapi.yaml [--out-dir src/emulate/workos/generated] - * pnpm gen:routes path/to/openapi.json --dry-run - * - * The generated code matches the hand-written patterns in src/emulate/workos/. - * Running twice on the same spec produces identical output (idempotent). - */ - -import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'node:fs'; -import { resolve, extname, join } from 'node:path'; -import YAML from 'yaml'; - -import { - type OpenAPISpec, - parseSpec, - generateEntities, - generateStore, - generateHelpers, - generateRoutes, -} from './gen-routes-lib.js'; - -function main(): void { - const args = process.argv.slice(2); - const flags = args.filter((a) => a.startsWith('--')); - const positional = args.filter((a) => !a.startsWith('--')); - - const specPath = positional[0]; - if (!specPath) { - console.error('Usage: gen-routes [--out-dir ] [--dry-run]'); - process.exit(1); - } - - const dryRun = flags.includes('--dry-run'); - const outDirIdx = args.indexOf('--out-dir'); - const outDir = outDirIdx !== -1 ? args[outDirIdx + 1] : 'src/emulate/workos/generated'; - - const resolvedSpec = resolve(specPath); - if (!existsSync(resolvedSpec)) { - console.error(`Spec file not found: ${resolvedSpec}`); - process.exit(1); - } - - const raw = readFileSync(resolvedSpec, 'utf-8'); - const ext = extname(resolvedSpec).toLowerCase(); - let spec: OpenAPISpec; - - if (ext === '.yaml' || ext === '.yml') { - spec = YAML.parse(raw) as OpenAPISpec; - } else { - spec = JSON.parse(raw) as OpenAPISpec; - } - - const parsed = parseSpec(spec); - const output = generateAll(parsed); - - if (dryRun) { - for (const [filename, content] of Object.entries(output)) { - console.log(`--- ${filename} ---`); - console.log(content); - console.log(''); - } - return; - } - - const resolvedOutDir = resolve(outDir); - mkdirSync(resolvedOutDir, { recursive: true }); - - for (const [filename, content] of Object.entries(output)) { - const filePath = join(resolvedOutDir, filename); - writeFileSync(filePath, content, 'utf-8'); - console.log(` wrote ${filePath}`); - } - - console.log(`\nGenerated ${Object.keys(output).length} files in ${resolvedOutDir}`); -} - -function generateAll(parsed: ReturnType): Record { - const output: Record = {}; - - output['entities.ts'] = generateEntities(parsed.entities); - output['store.ts'] = generateStore(parsed.entities); - output['helpers.ts'] = generateHelpers(parsed.entities); - - for (const route of parsed.routes) { - output[`routes/${route.filename}`] = generateRoutes(route); - } - - return output; -} - -main(); diff --git a/src/bin.ts b/src/bin.ts index ecb7ddc1..22ffccfe 100644 --- a/src/bin.ts +++ b/src/bin.ts @@ -2413,32 +2413,52 @@ async function runCli(): Promise { }, ) .command( - 'emulate', + 'dev', false, // Hidden: unreleased beta feature (yargs) => yargs.options({ - port: { type: 'number', default: 4100, describe: 'Port to listen on' }, - seed: { type: 'string', describe: 'Path to seed config file (YAML or JSON)' }, + port: { type: 'number', default: 4100, describe: 'Emulator port' }, + seed: { type: 'string', describe: 'Path to seed config file' }, }), async (argv) => { - const { runEmulate } = await import('./commands/emulate.js'); - await runEmulate({ port: argv.port, seed: argv.seed, json: argv.json as boolean }); + const { runDev } = await import('./commands/dev.js'); + await runDev({ + port: argv.port, + seed: argv.seed, + '--': argv['--'] as string[] | undefined, + }); }, ) .command( - 'dev', - false, // Hidden: unreleased beta feature + 'emulate', + 'Start a local WorkOS API emulator', (yargs) => yargs.options({ - port: { type: 'number', default: 4100, describe: 'Emulator port' }, - seed: { type: 'string', describe: 'Path to seed config file' }, + port: { + alias: 'p', + type: 'number', + default: 4100, + describe: 'Port to listen on', + }, + seed: { + alias: 's', + type: 'string', + describe: 'Path to seed config file (YAML or JSON)', + }, + interactive: { + alias: 'i', + type: 'boolean', + default: false, + describe: 'Show login pages for SSO/AuthKit', + }, }), async (argv) => { - const { runDev } = await import('./commands/dev.js'); - await runDev({ + const { runEmulate } = await import('./commands/emulate.js'); + await runEmulate({ port: argv.port, seed: argv.seed, - '--': argv['--'] as string[] | undefined, + json: argv.json as boolean, + interactive: argv.interactive, }); }, ) diff --git a/src/commands/dev.ts b/src/commands/dev.ts index 4a940f39..9ef66c10 100644 --- a/src/commands/dev.ts +++ b/src/commands/dev.ts @@ -1,4 +1,4 @@ -import { createEmulator, type EmulatorSeedConfig } from '../emulate/index.js'; +import { createEmulator, type EmulatorSeedConfig } from '@workos/emulate'; import { resolveDevCommand } from '../lib/dev-command.js'; import { spawn, type ChildProcess } from 'node:child_process'; import { readFileSync, existsSync } from 'node:fs'; @@ -40,19 +40,10 @@ function autoDetectSeedFile(): EmulatorSeedConfig | null { return null; } -/** - * Build the env vars object to inject into the child process. - * - * Sets both the base URL style (`WORKOS_API_BASE_URL`) and the decomposed - * style (`WORKOS_API_HOSTNAME` + `WORKOS_API_PORT` + `WORKOS_API_HTTPS`) - * so the emulator works with authkit SDKs (which read the decomposed vars) - * and direct SDK consumers (which may use the base URL). - */ /** * Default seed data for `workos dev` so the AuthKit login flow works - * out of the box. Provides a test user, an organization with a verified - * domain, and a membership linking the two. Skipped when the user - * provides `--seed` or a `workos-emulate.config.*` file is auto-detected. + * out of the box. Skipped when the user provides `--seed` or a + * `workos-emulate.config.*` file is auto-detected. */ export const DEFAULT_DEV_SEED: EmulatorSeedConfig = { users: [ @@ -72,6 +63,14 @@ export const DEFAULT_DEV_SEED: EmulatorSeedConfig = { ], }; +/** + * Build the env vars object to inject into the child process. + * + * Sets both the base URL style (`WORKOS_API_BASE_URL`) and the decomposed + * style (`WORKOS_API_HOSTNAME` + `WORKOS_API_PORT` + `WORKOS_API_HTTPS`) + * so the emulator works with authkit SDKs (which read the decomposed vars) + * and direct SDK consumers (which may use the base URL). + */ export function buildDevEnv(emulatorUrl: string, apiKey = 'sk_test_default'): Record { const url = new URL(emulatorUrl); return { diff --git a/src/commands/emulate.spec.ts b/src/commands/emulate.spec.ts index 3e496204..264df583 100644 --- a/src/commands/emulate.spec.ts +++ b/src/commands/emulate.spec.ts @@ -1,147 +1,117 @@ -import { describe, it, expect, afterEach } from 'vitest'; -import { createEmulator, type Emulator } from '../emulate/index.js'; - -describe('createEmulator', () => { - let emulator: Emulator | undefined; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; + +const mocks = vi.hoisted(() => ({ + createEmulator: vi.fn(), + close: vi.fn(), +})); + +vi.mock('@workos/emulate', () => ({ + createEmulator: mocks.createEmulator, +})); + +const { runEmulate } = await import('./emulate.js'); + +const SIGNALS = ['SIGINT', 'SIGTERM', 'SIGBREAK'] as const; + +describe('runEmulate', () => { + let originalListeners: Record<(typeof SIGNALS)[number], ReturnType>; + let cwd: string; + let tempDir: string; + + beforeEach(() => { + originalListeners = { + SIGINT: process.listeners('SIGINT'), + SIGTERM: process.listeners('SIGTERM'), + SIGBREAK: process.listeners('SIGBREAK'), + }; + cwd = process.cwd(); + tempDir = mkdtempSync(join(tmpdir(), 'workos-emulate-')); + process.chdir(tempDir); + + mocks.close.mockResolvedValue(undefined); + mocks.createEmulator.mockResolvedValue({ + url: 'http://localhost:4100', + port: 4100, + apiKey: 'sk_test_default', + close: mocks.close, + }); - afterEach(async () => { - if (emulator) { - await emulator.close(); - emulator = undefined; - } + vi.spyOn(console, 'log').mockImplementation(() => undefined); }); - it('starts on random port and serves health check', async () => { - emulator = await createEmulator({ port: 0 }); - expect(emulator.port).toBeGreaterThan(0); - expect(emulator.url).toContain(`localhost:${emulator.port}`); - - const res = await fetch(`${emulator.url}/health`); - expect(res.status).toBe(200); - const body = await res.json(); - expect(body.status).toBe('ok'); + afterEach(() => { + process.chdir(cwd); + rmSync(tempDir, { recursive: true, force: true }); + vi.restoreAllMocks(); + mocks.createEmulator.mockReset(); + mocks.close.mockReset(); + + for (const signal of SIGNALS) { + process.removeAllListeners(signal); + for (const listener of originalListeners[signal]) { + process.on(signal, listener); + } + } }); - it('accepts API key and returns user list', async () => { - emulator = await createEmulator({ port: 0 }); + it('starts the external emulator package on the requested port', async () => { + await runEmulate({ port: 0, interactive: false }); - const res = await fetch(`${emulator.url}/user_management/users`, { - headers: { Authorization: 'Bearer sk_test_default' }, + expect(mocks.createEmulator).toHaveBeenCalledWith({ + port: 0, + seed: undefined, + interactiveAuth: false, }); - expect(res.status).toBe(200); - const body = (await res.json()) as any; - expect(body.object).toBe('list'); - expect(body.data).toEqual([]); }); - it('rejects missing API key', async () => { - emulator = await createEmulator({ port: 0 }); - - const res = await fetch(`${emulator.url}/user_management/users`); - expect(res.status).toBe(401); + it('prints startup details as JSON when requested', async () => { + await runEmulate({ port: 4100, json: true }); + + expect(console.log).toHaveBeenCalledWith( + JSON.stringify({ + url: 'http://localhost:4100', + port: 4100, + apiKey: 'sk_test_default', + health: 'http://localhost:4100/health', + }), + ); }); - it('seeds users from config', async () => { - emulator = await createEmulator({ - port: 0, - seed: { - users: [{ email: 'seeded@test.com', first_name: 'Seeded' }], - }, - }); + it('passes the interactive flag through to the emulator package', async () => { + await runEmulate({ port: 4100, interactive: true }); - const res = await fetch(`${emulator.url}/user_management/users`, { - headers: { Authorization: 'Bearer sk_test_default' }, + expect(mocks.createEmulator).toHaveBeenCalledWith({ + port: 4100, + seed: undefined, + interactiveAuth: true, }); - const body = (await res.json()) as any; - expect(body.data).toHaveLength(1); - expect(body.data[0].email).toBe('seeded@test.com'); }); - it('reset() clears and re-seeds data', async () => { - emulator = await createEmulator({ - port: 0, - seed: { - users: [{ email: 'reset@test.com' }], - }, - }); + it('loads an explicit JSON seed file', async () => { + const seedPath = join(tempDir, 'seed.json'); + writeFileSync(seedPath, JSON.stringify({ users: [{ email: 'test@example.com' }] })); - // Create an extra user - await fetch(`${emulator.url}/user_management/users`, { - method: 'POST', - headers: { - Authorization: 'Bearer sk_test_default', - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ email: 'extra@test.com' }), - }); - - const before = (await ( - await fetch(`${emulator.url}/user_management/users`, { - headers: { Authorization: 'Bearer sk_test_default' }, - }) - ).json()) as any; - expect(before.data).toHaveLength(2); - - emulator.reset(); - - const after = (await ( - await fetch(`${emulator.url}/user_management/users`, { - headers: { Authorization: 'Bearer sk_test_default' }, - }) - ).json()) as any; - expect(after.data).toHaveLength(1); - expect(after.data[0].email).toBe('reset@test.com'); - }); + await runEmulate({ port: 4100, seed: seedPath }); - it('supports custom API keys', async () => { - emulator = await createEmulator({ - port: 0, - seed: { - apiKeys: { sk_test_custom: { environment: 'staging' } }, - }, - }); - - // Default key should not work - const res1 = await fetch(`${emulator.url}/user_management/users`, { - headers: { Authorization: 'Bearer sk_test_default' }, + expect(mocks.createEmulator).toHaveBeenCalledWith({ + port: 4100, + seed: { users: [{ email: 'test@example.com' }] }, + interactiveAuth: undefined, }); - expect(res1.status).toBe(401); - - // Custom key should work - const res2 = await fetch(`${emulator.url}/user_management/users`, { - headers: { Authorization: 'Bearer sk_test_custom' }, - }); - expect(res2.status).toBe(200); }); - it('exposes the primary API key on the emulator object', async () => { - emulator = await createEmulator({ port: 0 }); - expect(emulator.apiKey).toBe('sk_test_default'); - }); + it('auto-detects workos-emulate config files', async () => { + writeFileSync('workos-emulate.config.yaml', 'users:\n - email: yaml@example.com\n'); - it('exposes custom API key when seed.apiKeys is provided', async () => { - emulator = await createEmulator({ - port: 0, - seed: { apiKeys: { sk_test_custom: { environment: 'staging' } } }, - }); - expect(emulator.apiKey).toBe('sk_test_custom'); - }); - - it('issues JWT tokens with correct issuer when using port 0', async () => { - emulator = await createEmulator({ - port: 0, - seed: { users: [{ email: 'jwt@test.com', password: 'pass' }] }, - }); + await runEmulate({ port: 4100 }); - const res = await fetch(`${emulator.url}/user_management/authenticate`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ grant_type: 'password', email: 'jwt@test.com', password: 'pass' }), + expect(mocks.createEmulator).toHaveBeenCalledWith({ + port: 4100, + seed: { users: [{ email: 'yaml@example.com' }] }, + interactiveAuth: undefined, }); - expect(res.status).toBe(200); - - const body = (await res.json()) as any; - const payload = JSON.parse(Buffer.from(body.access_token.split('.')[1], 'base64url').toString('utf-8')); - expect(payload.iss).toBe(emulator.url); }); }); diff --git a/src/commands/emulate.ts b/src/commands/emulate.ts index b853191a..c6a11c6c 100644 --- a/src/commands/emulate.ts +++ b/src/commands/emulate.ts @@ -1,4 +1,4 @@ -import { createEmulator, type EmulatorSeedConfig } from '../emulate/index.js'; +import { createEmulator, type Emulator, type EmulatorSeedConfig } from '@workos/emulate'; import { readFileSync, existsSync } from 'node:fs'; import { resolve } from 'node:path'; import { parse as parseYaml } from 'yaml'; @@ -10,6 +10,7 @@ export interface EmulateArgs { port: number; seed?: string; json?: boolean; + interactive?: boolean; } function loadSeedFile(filePath: string): EmulatorSeedConfig { @@ -37,7 +38,7 @@ function autoDetectSeedFile(): EmulatorSeedConfig | null { return null; } -function printBanner(emulator: { url: string; port: number; apiKey: string }): void { +function printBanner(emulator: Pick): void { console.log(); console.log(chalk.bold(' WorkOS Emulator')); console.log(); @@ -55,6 +56,7 @@ export async function runEmulate(argv: EmulateArgs): Promise { const emulator = await createEmulator({ port: argv.port, seed: seedConfig ?? undefined, + interactiveAuth: argv.interactive, }); if (argv.json) { @@ -72,7 +74,10 @@ export async function runEmulate(argv: EmulateArgs): Promise { const shutdown = () => { if (!argv.json) console.log(`\n${chalk.dim('Shutting down...')}`); - emulator.close().then(() => process.exit(0)); + emulator.close().then( + () => process.exit(0), + () => process.exit(1), + ); }; process.once('SIGINT', shutdown); process.once('SIGTERM', shutdown); diff --git a/src/emulate/core/id.spec.ts b/src/emulate/core/id.spec.ts deleted file mode 100644 index ed87cf65..00000000 --- a/src/emulate/core/id.spec.ts +++ /dev/null @@ -1,58 +0,0 @@ -import { describe, it, expect, beforeEach } from 'vitest'; -import { generateId, resetIdState, ID_PREFIXES } from './id.js'; - -beforeEach(() => { - resetIdState(); -}); - -describe('generateId', () => { - it('generates an ID with the given prefix', () => { - const id = generateId('user'); - expect(id).toMatch(/^user_[0-9A-Z]{26}$/); - }); - - it('generates IDs with different prefixes', () => { - expect(generateId('org')).toMatch(/^org_/); - expect(generateId('conn')).toMatch(/^conn_/); - expect(generateId('om')).toMatch(/^om_/); - }); - - it('generates 1000 unique IDs', () => { - const ids = new Set(); - for (let i = 0; i < 1000; i++) { - ids.add(generateId('user')); - } - expect(ids.size).toBe(1000); - }); - - it('generates sortable IDs (creation order)', () => { - const ids: string[] = []; - for (let i = 0; i < 100; i++) { - ids.push(generateId('user')); - } - const sorted = [...ids].sort(); - expect(sorted).toEqual(ids); - }); - - it('handles monotonic time correctly', () => { - const id1 = generateId('user'); - const id2 = generateId('user'); - expect(id1).not.toBe(id2); - expect(id1 < id2).toBe(true); - }); -}); - -describe('ID_PREFIXES', () => { - it('contains expected prefix mappings', () => { - expect(ID_PREFIXES.user).toBe('user'); - expect(ID_PREFIXES.organization).toBe('org'); - expect(ID_PREFIXES.organization_membership).toBe('om'); - expect(ID_PREFIXES.connection).toBe('conn'); - expect(ID_PREFIXES.session).toBe('session'); - }); - - it('has all expected keys', () => { - const prefixes: Record = { ...ID_PREFIXES }; - expect(Object.keys(prefixes).length).toBeGreaterThan(10); - }); -}); diff --git a/src/emulate/core/id.ts b/src/emulate/core/id.ts deleted file mode 100644 index bf84fc75..00000000 --- a/src/emulate/core/id.ts +++ /dev/null @@ -1,79 +0,0 @@ -const ENCODING = '0123456789ABCDEFGHJKMNPQRSTVWXYZ'; // Crockford's Base32 -const ENCODING_LEN = ENCODING.length; // 32 -const TIME_LEN = 10; // 10 chars encodes 48-bit ms timestamp -const RANDOM_LEN = 16; // 16 chars of randomness - -let lastTime = 0; - -export function generateId(prefix: string): string { - let now = Date.now(); - if (now <= lastTime) { - now = lastTime + 1; - } - lastTime = now; - - let timeStr = ''; - let t = now; - for (let i = TIME_LEN - 1; i >= 0; i--) { - timeStr = ENCODING[t % ENCODING_LEN] + timeStr; - t = Math.floor(t / ENCODING_LEN); - } - - let randStr = ''; - for (let i = 0; i < RANDOM_LEN; i++) { - randStr += ENCODING[Math.floor(Math.random() * ENCODING_LEN)]; - } - - return `${prefix}_${timeStr}${randStr}`; -} - -export function resetIdState(): void { - lastTime = 0; -} - -export const ID_PREFIXES = { - user: 'user', - organization: 'org', - organization_membership: 'om', - organization_domain: 'org_domain', - connection: 'conn', - connection_domain: 'conn_domain', - directory: 'directory', - directory_user: 'directory_user', - directory_group: 'directory_grp', - event: 'evt', - invitation: 'inv', - session: 'session', - email_verification: 'email_verification', - password_reset: 'password_reset', - magic_auth: 'magic_auth', - authentication_factor: 'auth_factor', - authentication_challenge: 'auth_challenge', - authorization_code: 'auth_code', - identity: 'identity', - sso_authorization: 'sso_auth', - refresh_token: 'ref', - device_authorization: 'dev_auth', - api_key: 'api_key', - profile: 'prof', - pipe_connection: 'pipe_conn', - redirect_uri: 'redir', - cors_origin: 'cors', - authorized_application: 'auth_app', - connected_account: 'conn_acct', - role: 'role', - permission: 'perm', - role_permission: 'rp', - authorization_resource: 'auth_res', - role_assignment: 'ra', - audit_log_action: 'audit_action', - audit_log_event: 'audit_event', - audit_log_export: 'audit_export', - feature_flag: 'ff', - flag_target: 'ff_target', - connect_application: 'connect_app', - client_secret: 'client_secret', - data_integration_auth: 'di_auth', - radar_attempt: 'radar_attempt', - webhook_endpoint: 'we', -} as const; diff --git a/src/emulate/core/index.ts b/src/emulate/core/index.ts deleted file mode 100644 index fc4d9233..00000000 --- a/src/emulate/core/index.ts +++ /dev/null @@ -1,30 +0,0 @@ -export { - Store, - Collection, - type Entity, - type InsertInput, - type FilterFn, - type SortFn, - type CollectionHooks, -} from './store.js'; -export { generateId, resetIdState, ID_PREFIXES } from './id.js'; -export { - parseListParams, - cursorPaginate, - type CursorPaginationOptions, - type CursorPaginatedResult, -} from './pagination.js'; -export { JWTManager, type JWTPayload } from './jwt.js'; -export { createServer, type ServerOptions } from './server.js'; -export { type ServicePlugin, type RouteContext } from './plugin.js'; -export { - WorkOSApiError, - createApiErrorHandler, - requestIdMiddleware, - notFound, - validationError, - unauthorized, - forbidden, - parseJsonBody, -} from './middleware/error-handler.js'; -export { authMiddleware, type WorkOSAppEnv, type WorkOSAuthContext, type ApiKeyMap } from './middleware/auth.js'; diff --git a/src/emulate/core/jwt.spec.ts b/src/emulate/core/jwt.spec.ts deleted file mode 100644 index 2129aa94..00000000 --- a/src/emulate/core/jwt.spec.ts +++ /dev/null @@ -1,91 +0,0 @@ -import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'; -import { JWTManager } from './jwt.js'; - -describe('JWTManager', () => { - let jwt: JWTManager; - - beforeEach(() => { - jwt = new JWTManager('https://api.workos.test'); - }); - - afterEach(() => { - vi.useRealTimers(); - }); - - it('signs a token and verifies it', () => { - const token = jwt.sign({ - sub: 'user_01ABC', - aud: 'client_01XYZ', - sid: 'session_01DEF', - org_id: 'org_01GHI', - }); - - expect(token).toMatch(/^eyJ/); - expect(token.split('.')).toHaveLength(3); - - const payload = jwt.verify(token); - expect(payload.sub).toBe('user_01ABC'); - expect(payload.aud).toBe('client_01XYZ'); - expect(payload.sid).toBe('session_01DEF'); - expect(payload.org_id).toBe('org_01GHI'); - expect(payload.iss).toBe('https://api.workos.test'); - expect(payload.exp).toBe(payload.iat + 3600); - }); - - it('preserves optional fields like role and permissions', () => { - const token = jwt.sign({ - sub: 'user_01ABC', - aud: 'client_01XYZ', - role: 'admin', - permissions: ['read', 'write'], - }); - - const payload = jwt.verify(token); - expect(payload.role).toBe('admin'); - expect(payload.permissions).toEqual(['read', 'write']); - }); - - it('supports custom expiration', () => { - const token = jwt.sign({ sub: 'user_01ABC', aud: 'client_01XYZ' }, { expiresIn: 300 }); - const payload = jwt.verify(token); - expect(payload.exp).toBe(payload.iat + 300); - }); - - it('throws on expired token', () => { - vi.useFakeTimers(); - vi.setSystemTime(new Date('2020-01-01T00:00:00Z')); - - const token = jwt.sign({ sub: 'user_01ABC', aud: 'client_01XYZ' }, { expiresIn: 60 }); - - vi.setSystemTime(new Date('2020-01-01T00:02:00Z')); - expect(() => jwt.verify(token)).toThrow('Token has expired'); - }); - - it('throws on tampered token', () => { - const token = jwt.sign({ sub: 'user_01ABC', aud: 'client_01XYZ' }); - const parts = token.split('.'); - parts[1] = Buffer.from(JSON.stringify({ sub: 'hacker' })).toString('base64url'); - expect(() => jwt.verify(parts.join('.'))).toThrow('Invalid token signature'); - }); - - it('a different JWTManager cannot verify the token', () => { - const token = jwt.sign({ sub: 'user_01ABC', aud: 'client_01XYZ' }); - const otherJwt = new JWTManager(); - expect(() => otherJwt.verify(token)).toThrow('Invalid token signature'); - }); - - it('returns JWKS with correct structure', () => { - const jwks = jwt.getJWKS(); - expect(jwks.keys).toHaveLength(1); - const key = jwks.keys[0]; - expect(key.kty).toBe('RSA'); - expect(key.alg).toBe('RS256'); - expect(key.use).toBe('sig'); - expect(key.kid).toBeDefined(); - }); - - it('returns a PEM-encoded public key', () => { - const pem = jwt.getPublicKeyPem(); - expect(pem).toContain('-----BEGIN PUBLIC KEY-----'); - }); -}); diff --git a/src/emulate/core/jwt.ts b/src/emulate/core/jwt.ts deleted file mode 100644 index b4f7df27..00000000 --- a/src/emulate/core/jwt.ts +++ /dev/null @@ -1,111 +0,0 @@ -import { createSign, createVerify, generateKeyPairSync, type KeyObject } from 'node:crypto'; - -export interface JWTPayload { - sub: string; - sid?: string; - org_id?: string; - role?: string; - permissions?: string[]; - iss: string; - aud: string; - exp: number; - iat: number; -} - -interface SignOptions { - expiresIn?: number; -} - -function base64url(input: Buffer | string): string { - const buf = typeof input === 'string' ? Buffer.from(input) : input; - return buf.toString('base64url'); -} - -function base64urlDecode(input: string): Buffer { - return Buffer.from(input, 'base64url'); -} - -export class JWTManager { - private privateKey: KeyObject; - private publicKey: KeyObject; - private kid: string; - issuer: string; - - constructor(issuer = 'https://api.workos.com') { - this.issuer = issuer; - const { privateKey, publicKey } = generateKeyPairSync('rsa', { - modulusLength: 2048, - }); - this.privateKey = privateKey; - this.publicKey = publicKey; - this.kid = `workos_emulate_${Date.now()}`; - } - - sign(payload: Omit, options?: SignOptions): string { - const now = Math.floor(Date.now() / 1000); - const expiresIn = options?.expiresIn ?? 3600; - - const fullPayload: JWTPayload = { - ...payload, - iss: this.issuer, - iat: now, - exp: now + expiresIn, - }; - - const header = { alg: 'RS256', typ: 'JWT', kid: this.kid }; - const headerB64 = base64url(JSON.stringify(header)); - const payloadB64 = base64url(JSON.stringify(fullPayload)); - const signingInput = `${headerB64}.${payloadB64}`; - - const signer = createSign('RSA-SHA256'); - signer.update(signingInput); - const signature = signer.sign(this.privateKey, 'base64url'); - - return `${signingInput}.${signature}`; - } - - verify(token: string): JWTPayload { - const parts = token.split('.'); - if (parts.length !== 3) { - throw new Error('Invalid token format'); - } - - const [headerB64, payloadB64, signature] = parts; - const signingInput = `${headerB64}.${payloadB64}`; - - const verifier = createVerify('RSA-SHA256'); - verifier.update(signingInput); - const valid = verifier.verify(this.publicKey, signature, 'base64url'); - - if (!valid) { - throw new Error('Invalid token signature'); - } - - const payload = JSON.parse(base64urlDecode(payloadB64).toString('utf-8')) as JWTPayload; - - const now = Math.floor(Date.now() / 1000); - if (payload.exp && payload.exp < now) { - throw new Error('Token has expired'); - } - - return payload; - } - - getJWKS(): { keys: Record[] } { - const jwk = this.publicKey.export({ format: 'jwk' }); - return { - keys: [ - { - ...jwk, - kid: this.kid, - alg: 'RS256', - use: 'sig', - }, - ], - }; - } - - getPublicKeyPem(): string { - return this.publicKey.export({ type: 'spki', format: 'pem' }) as string; - } -} diff --git a/src/emulate/core/middleware/auth.ts b/src/emulate/core/middleware/auth.ts deleted file mode 100644 index 7c52644d..00000000 --- a/src/emulate/core/middleware/auth.ts +++ /dev/null @@ -1,32 +0,0 @@ -import type { Context, Next } from 'hono'; -import { unauthorized } from './error-handler.js'; - -export interface WorkOSAuthContext { - environment: string; - apiKey: string; -} - -export type WorkOSAppEnv = { - Variables: { - auth?: WorkOSAuthContext; - requestId?: string; - }; -}; - -export type ApiKeyMap = Record; - -export function authMiddleware(apiKeys: ApiKeyMap) { - return async (c: Context, next: Next) => { - const authHeader = c.req.header('Authorization'); - if (!authHeader) throw unauthorized(); - - const token = authHeader.replace(/^Bearer\s+/i, '').trim(); - if (!token.startsWith('sk_')) throw unauthorized(); - - const keyInfo = apiKeys[token]; - if (!keyInfo) throw unauthorized(); - - c.set('auth', { environment: keyInfo.environment, apiKey: token } satisfies WorkOSAuthContext); - await next(); - }; -} diff --git a/src/emulate/core/middleware/error-handler.ts b/src/emulate/core/middleware/error-handler.ts deleted file mode 100644 index 2ec1466d..00000000 --- a/src/emulate/core/middleware/error-handler.ts +++ /dev/null @@ -1,83 +0,0 @@ -import type { Context, ErrorHandler, MiddlewareHandler } from 'hono'; -import type { ContentfulStatusCode } from 'hono/utils/http-status'; - -export class WorkOSApiError extends Error { - constructor( - public status: number, - message: string, - public code: string, - public errors?: Array<{ field: string; code: string; message?: string }>, - ) { - super(message); - this.name = 'WorkOSApiError'; - } -} - -export function createApiErrorHandler(): ErrorHandler { - return (err, c) => { - if (err instanceof WorkOSApiError) { - const body: Record = { - message: err.message, - code: err.code, - }; - if (err.errors) { - body.errors = err.errors; - } - return c.json(body, err.status as ContentfulStatusCode); - } - - const status = errorStatus(err); - return c.json( - { - message: 'Internal Server Error', - code: 'server_error', - }, - status as ContentfulStatusCode, - ); - }; -} - -export function requestIdMiddleware(): MiddlewareHandler { - return async (c, next) => { - const requestId = c.req.header('X-Request-ID') ?? `req_${crypto.randomUUID()}`; - c.set('requestId', requestId); - c.header('X-Request-ID', requestId); - await next(); - }; -} - -export function notFound(resource?: string): WorkOSApiError { - return new WorkOSApiError(404, resource ? `${resource} not found` : 'Not Found', 'not_found'); -} - -export function validationError(message: string, errors?: WorkOSApiError['errors']): WorkOSApiError { - return new WorkOSApiError(422, message, 'unprocessable_entity', errors); -} - -export function unauthorized(): WorkOSApiError { - return new WorkOSApiError(401, 'Unauthorized', 'unauthorized'); -} - -export function forbidden(): WorkOSApiError { - return new WorkOSApiError(403, 'Forbidden', 'forbidden'); -} - -export async function parseJsonBody(c: Context): Promise> { - try { - const body = await c.req.json(); - if (body && typeof body === 'object' && !Array.isArray(body)) { - return body as Record; - } - return {}; - } catch { - throw new WorkOSApiError(400, 'Problems parsing JSON', 'invalid_request_body'); - } -} - -function errorStatus(err: unknown): number { - if (err && typeof err === 'object' && 'status' in err) { - const s = (err as { status: unknown }).status; - if (typeof s === 'number' && Number.isFinite(s)) return s; - } - return 500; -} diff --git a/src/emulate/core/pagination.spec.ts b/src/emulate/core/pagination.spec.ts deleted file mode 100644 index b2b4716e..00000000 --- a/src/emulate/core/pagination.spec.ts +++ /dev/null @@ -1,101 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import { cursorPaginate, type Entity } from './pagination.js'; - -interface TestItem extends Entity { - name: string; -} - -function makeItems(count: number): TestItem[] { - const items: TestItem[] = []; - for (let i = 1; i <= count; i++) { - const ts = new Date(2024, 0, 1, 0, 0, i).toISOString(); - items.push({ - id: `item_${String(i).padStart(4, '0')}`, - name: `item-${i}`, - created_at: ts, - updated_at: ts, - }); - } - return items; -} - -describe('cursorPaginate', () => { - it('returns first page with default limit of 10', () => { - const result = cursorPaginate(makeItems(25), {}); - expect(result.data).toHaveLength(10); - expect(result.list_metadata.after).toBeDefined(); - expect(result.list_metadata.before).toBeNull(); - }); - - it('returns all items when fewer than limit', () => { - const result = cursorPaginate(makeItems(5), { limit: 10 }); - expect(result.data).toHaveLength(5); - expect(result.list_metadata.after).toBeNull(); - }); - - it('returns empty result for empty input', () => { - const result = cursorPaginate([], {}); - expect(result.data).toHaveLength(0); - expect(result.list_metadata.before).toBeNull(); - expect(result.list_metadata.after).toBeNull(); - }); - - it('returns items in desc order by default', () => { - const result = cursorPaginate(makeItems(5), {}); - expect(result.data[0].name).toBe('item-5'); - expect(result.data[4].name).toBe('item-1'); - }); - - it('returns items in asc order when specified', () => { - const result = cursorPaginate(makeItems(5), { order: 'asc' }); - expect(result.data[0].name).toBe('item-1'); - expect(result.data[4].name).toBe('item-5'); - }); - - it('caps limit at 100', () => { - const result = cursorPaginate(makeItems(150), { limit: 200 }); - expect(result.data).toHaveLength(100); - }); - - it('enforces minimum limit of 1', () => { - const result = cursorPaginate(makeItems(5), { limit: 0 }); - expect(result.data).toHaveLength(1); - }); - - it('paginates forward with no duplicates', () => { - const items = makeItems(25); - const allIds: string[] = []; - - const p1 = cursorPaginate(items, { limit: 10, order: 'asc' }); - allIds.push(...p1.data.map((i) => i.id)); - - const p2 = cursorPaginate(items, { limit: 10, order: 'asc', after: p1.list_metadata.after! }); - allIds.push(...p2.data.map((i) => i.id)); - - const p3 = cursorPaginate(items, { limit: 10, order: 'asc', after: p2.list_metadata.after! }); - allIds.push(...p3.data.map((i) => i.id)); - - expect(new Set(allIds).size).toBe(25); - expect(allIds).toHaveLength(25); - }); - - it('returns items before the given cursor', () => { - const items = makeItems(10); - const full = cursorPaginate(items, { limit: 10, order: 'asc' }); - const fifthId = full.data[4].id; - - const result = cursorPaginate(items, { limit: 10, order: 'asc', before: fifthId }); - expect(result.data).toHaveLength(4); - expect(result.data.map((i) => i.name)).toEqual(['item-1', 'item-2', 'item-3', 'item-4']); - }); - - it('applies filter before pagination', () => { - const result = cursorPaginate(makeItems(20), { - filter: (item) => parseInt(item.name.split('-')[1]) % 2 === 0, - order: 'asc', - limit: 100, - }); - expect(result.data).toHaveLength(10); - expect(result.data.every((i) => parseInt(i.name.split('-')[1]) % 2 === 0)).toBe(true); - }); -}); diff --git a/src/emulate/core/pagination.ts b/src/emulate/core/pagination.ts deleted file mode 100644 index f0bb8e4c..00000000 --- a/src/emulate/core/pagination.ts +++ /dev/null @@ -1,79 +0,0 @@ -export interface Entity { - id: string; - created_at: string; - updated_at: string; -} - -export interface CursorPaginationOptions { - filter?: (item: T) => boolean; - sort?: (a: T, b: T) => number; - limit?: number; - order?: 'asc' | 'desc'; - before?: string; - after?: string; -} - -export interface CursorPaginatedResult { - data: T[]; - list_metadata: { - before: string | null; - after: string | null; - }; -} - -export function parseListParams(url: URL) { - const limit = parseInt(url.searchParams.get('limit') ?? '10') || 10; - const order = (url.searchParams.get('order') as 'asc' | 'desc') ?? 'desc'; - const before = url.searchParams.get('before') ?? undefined; - const after = url.searchParams.get('after') ?? undefined; - return { limit, order, before, after }; -} - -export function cursorPaginate( - items: T[], - options: CursorPaginationOptions = {}, -): CursorPaginatedResult { - // Callers must pass a fresh array (e.g. Collection.all()) — sort mutates in-place - let filtered = options.filter ? items.filter(options.filter) : items; - - const order = options.order ?? 'desc'; - const defaultSort = (a: T, b: T) => - order === 'desc' - ? b.created_at.localeCompare(a.created_at) || b.id.localeCompare(a.id) - : a.created_at.localeCompare(b.created_at) || a.id.localeCompare(b.id); - - filtered.sort(options.sort ?? defaultSort); - - const limit = Math.max(1, Math.min(options.limit ?? 10, 100)); - - let startIndex = 0; - let endIndex = filtered.length; - - if (options.after) { - const afterIndex = filtered.findIndex((item) => item.id === options.after); - if (afterIndex !== -1) { - startIndex = afterIndex + 1; - } - } - - if (options.before) { - const beforeIndex = filtered.findIndex((item) => item.id === options.before); - if (beforeIndex !== -1) { - endIndex = beforeIndex; - } - } - - const window = filtered.slice(startIndex, endIndex); - const page = window.slice(0, limit); - - const hasMore = window.length > limit; - const hasPrev = startIndex > 0; - - return { - data: page, - list_metadata: { - before: page.length > 0 && hasPrev ? page[0].id : null, - after: page.length > 0 && hasMore ? page[page.length - 1].id : null, - }, - }; -} diff --git a/src/emulate/core/plugin.ts b/src/emulate/core/plugin.ts deleted file mode 100644 index 5f7d1415..00000000 --- a/src/emulate/core/plugin.ts +++ /dev/null @@ -1,17 +0,0 @@ -import type { Hono } from 'hono'; -import type { Store } from './store.js'; -import type { JWTManager } from './jwt.js'; -import type { WorkOSAppEnv } from './middleware/auth.js'; - -export interface RouteContext { - app: Hono; - store: Store; - jwt: JWTManager; - baseUrl: string; -} - -export interface ServicePlugin { - name: string; - register(ctx: RouteContext): void; - seed?(store: Store, baseUrl: string): void; -} diff --git a/src/emulate/core/server.ts b/src/emulate/core/server.ts deleted file mode 100644 index db62e7d5..00000000 --- a/src/emulate/core/server.ts +++ /dev/null @@ -1,124 +0,0 @@ -import { Hono } from 'hono'; -import { cors } from 'hono/cors'; -import { Store } from './store.js'; -import { JWTManager } from './jwt.js'; -import { createApiErrorHandler, requestIdMiddleware } from './middleware/error-handler.js'; -import { authMiddleware, type ApiKeyMap, type WorkOSAppEnv } from './middleware/auth.js'; -import type { ServicePlugin } from './plugin.js'; - -export interface ServerOptions { - port?: number; - baseUrl?: string; - apiKeys?: ApiKeyMap; -} - -export function createServer(plugin: ServicePlugin, options: ServerOptions = {}) { - const port = options.port ?? 4100; - const baseUrl = options.baseUrl ?? `http://localhost:${port}`; - - const app = new Hono(); - const store = new Store(); - const jwt = new JWTManager(baseUrl); - - const apiKeys: ApiKeyMap = options.apiKeys ?? { - sk_test_default: { environment: 'test' }, - }; - - app.onError(createApiErrorHandler()); - app.use('*', cors()); - app.use('*', requestIdMiddleware()); - - // JWKS endpoint (public, no auth) - app.get('/sso/jwks/:client_id', (c) => { - return c.json(jwt.getJWKS()); - }); - - // Auth middleware — single catch-all instance - const auth = authMiddleware(apiKeys); - - const PUBLIC_PATHS = new Set([ - '/health', - '/user_management/authorize', - '/user_management/authenticate', - '/user_management/sessions/logout', - ]); - - const PUBLIC_PATH_PREFIXES = ['/sso/', '/user_management/sessions/jwks/', '/data-integrations/']; - - app.use('*', async (c, next) => { - const path = new URL(c.req.url).pathname; - - // Skip auth for public paths - if (PUBLIC_PATHS.has(path)) return next(); - for (const prefix of PUBLIC_PATH_PREFIXES) { - if (path.startsWith(prefix)) { - // data-integrations: only /authorize subpath is public - if (prefix === '/data-integrations/' && !path.endsWith('/authorize')) break; - return next(); - } - } - - return auth(c, next); - }); - - // Rate limiting - const rateLimitCounters = new Map(); - let lastPruneAt = Math.floor(Date.now() / 1000); - - app.use('*', async (c, next) => { - const auth = c.get('auth'); - const key = auth?.apiKey ?? '__anonymous__'; - const now = Math.floor(Date.now() / 1000); - - if (now - lastPruneAt > 3600) { - for (const [k, val] of rateLimitCounters) { - if (val.resetAt <= now) rateLimitCounters.delete(k); - } - lastPruneAt = now; - } - - let counter = rateLimitCounters.get(key); - if (!counter || counter.resetAt <= now) { - counter = { remaining: 1000, resetAt: now + 60 }; - rateLimitCounters.set(key, counter); - } - - counter.remaining = Math.max(0, counter.remaining - 1); - - c.header('X-RateLimit-Limit', '1000'); - c.header('X-RateLimit-Remaining', String(counter.remaining)); - c.header('X-RateLimit-Reset', String(counter.resetAt)); - - if (counter.remaining === 0) { - c.header('Retry-After', String(counter.resetAt - now)); - return c.json( - { - message: 'Too Many Requests', - code: 'rate_limit_exceeded', - }, - 429, - ); - } - - await next(); - }); - - // Store API key map for route access - store.setData('apiKeyMap', apiKeys); - - // Register plugin routes - plugin.register({ app, store, jwt, baseUrl }); - - // Not found handler - app.notFound((c) => - c.json( - { - message: 'Not Found', - code: 'not_found', - }, - 404, - ), - ); - - return { app, store, jwt, port, baseUrl }; -} diff --git a/src/emulate/core/store.spec.ts b/src/emulate/core/store.spec.ts deleted file mode 100644 index 2f35a530..00000000 --- a/src/emulate/core/store.spec.ts +++ /dev/null @@ -1,214 +0,0 @@ -import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; -import { Collection, Store, type Entity } from './store.js'; - -interface User extends Entity { - name: string; - email?: string; - status?: string; -} - -describe('Collection', () => { - describe('CRUD', () => { - let col: Collection; - - beforeEach(() => { - col = new Collection('user'); - }); - - it('insert returns item with string ID and timestamps; get retrieves by id', () => { - const item = col.insert({ name: 'alice' }); - expect(item.id).toMatch(/^user_/); - expect(item.id.length).toBeGreaterThan(5); - expect(item.created_at).toBe(item.updated_at); - expect(new Date(item.created_at).toString()).not.toBe('Invalid Date'); - expect(col.get(item.id)).toEqual(item); - }); - - it('insert with explicit ID uses the provided ID', () => { - const item = col.insert({ id: 'user_custom123', name: 'bob' }); - expect(item.id).toBe('user_custom123'); - expect(col.get('user_custom123')).toEqual(item); - }); - - it('update merges data and updates updated_at; delete removes item', () => { - vi.useFakeTimers(); - vi.setSystemTime(new Date('2020-01-01T00:00:00.000Z')); - const inserted = col.insert({ name: 'bob' }); - const createdAt = inserted.created_at; - - vi.setSystemTime(new Date('2020-01-02T00:00:00.000Z')); - const updated = col.update(inserted.id, { name: 'robert', status: 'active' }); - expect(updated).toBeDefined(); - expect(updated!.name).toBe('robert'); - expect(updated!.status).toBe('active'); - expect(updated!.id).toBe(inserted.id); - expect(updated!.created_at).toBe(createdAt); - expect(updated!.updated_at).not.toBe(createdAt); - - expect(col.delete(inserted.id)).toBe(true); - expect(col.get(inserted.id)).toBeUndefined(); - vi.useRealTimers(); - }); - - it('update returns undefined for nonexistent ID', () => { - expect(col.update('nonexistent', { name: 'x' })).toBeUndefined(); - }); - - it('delete returns false for nonexistent ID', () => { - expect(col.delete('nonexistent')).toBe(false); - }); - }); - - describe('unique string IDs', () => { - it('generates unique IDs for successive inserts', () => { - const col = new Collection('user'); - const ids = new Set(); - for (let i = 0; i < 50; i++) { - ids.add(col.insert({ name: `user-${i}` }).id); - } - expect(ids.size).toBe(50); - }); - - it('all generated IDs have the correct prefix', () => { - const col = new Collection('org'); - for (let i = 0; i < 10; i++) { - expect(col.insert({ name: `org-${i}` }).id).toMatch(/^org_/); - } - }); - }); - - describe('index lookups', () => { - it('findBy uses indexes when indexFields are provided', () => { - const col = new Collection('user', ['name']); - col.insert({ name: 'dup', status: 'a' }); - col.insert({ name: 'dup', status: 'b' }); - col.insert({ name: 'other' }); - - const matches = col.findBy('name', 'dup'); - expect(matches).toHaveLength(2); - expect(matches.map((m) => m.status).sort()).toEqual(['a', 'b']); - }); - - it('findOneBy returns the first match', () => { - const col = new Collection('user', ['name']); - const first = col.insert({ name: 'same' }); - col.insert({ name: 'same' }); - - const one = col.findOneBy('name', 'same'); - expect(one).toBeDefined(); - expect(one!.id).toBe(first.id); - }); - - it('index updates when item is updated', () => { - const col = new Collection('user', ['email']); - const item = col.insert({ name: 'alice', email: 'alice@test.com' }); - expect(col.findBy('email', 'alice@test.com')).toHaveLength(1); - - col.update(item.id, { email: 'new@test.com' }); - expect(col.findBy('email', 'alice@test.com')).toHaveLength(0); - expect(col.findBy('email', 'new@test.com')).toHaveLength(1); - }); - - it('index updates when item is deleted', () => { - const col = new Collection('user', ['name']); - const item = col.insert({ name: 'toDelete' }); - expect(col.findBy('name', 'toDelete')).toHaveLength(1); - - col.delete(item.id); - expect(col.findBy('name', 'toDelete')).toHaveLength(0); - }); - }); - - describe('cursor pagination via list()', () => { - let col: Collection; - - beforeEach(() => { - col = new Collection('user'); - for (let i = 1; i <= 25; i++) { - col.insert({ name: `user-${i}` }); - } - }); - - it('returns first page with default settings', () => { - const r = col.list(); - expect(r.data).toHaveLength(10); - }); - - it('paginates forward through all items', () => { - const allIds: string[] = []; - let after: string | undefined; - - for (let page = 0; page < 10; page++) { - const r = col.list({ limit: 10, order: 'asc', after }); - allIds.push(...r.data.map((i) => i.id)); - if (!r.list_metadata.after) break; - after = r.list_metadata.after; - } - - expect(new Set(allIds).size).toBe(25); - }); - }); - - describe('count', () => { - it('returns total size without filter and filtered count with filter', () => { - const col = new Collection('user'); - col.insert({ name: 'a' }); - col.insert({ name: 'b' }); - col.insert({ name: 'c' }); - - expect(col.count()).toBe(3); - expect(col.count((u) => u.name === 'b')).toBe(1); - }); - }); - - describe('clear', () => { - it('resets items and indexes', () => { - const col = new Collection('user', ['name']); - col.insert({ name: 'x' }); - col.insert({ name: 'y' }); - expect(col.findBy('name', 'x')).toHaveLength(1); - - col.clear(); - expect(col.all()).toHaveLength(0); - expect(col.findBy('name', 'x')).toHaveLength(0); - }); - }); -}); - -describe('Store', () => { - let store: Store; - - beforeEach(() => { - store = new Store(); - }); - - it('collection returns the same Collection for the same name', () => { - const a = store.collection('users', 'user'); - const b = store.collection('users', 'user'); - expect(a).toBe(b); - }); - - it('throws when re-requesting collection with different indexes', () => { - store.collection('users', 'user', ['name']); - expect(() => store.collection('users', 'user', ['email'])).toThrow(/already exists with indexes/); - }); - - it('reset clears all collections and data', () => { - const u = store.collection('users', 'user'); - u.insert({ name: 'u' }); - store.setData('key', 'value'); - - store.reset(); - expect(u.all()).toHaveLength(0); - expect(store.getData('key')).toBeUndefined(); - }); - - it('getData/setData stores arbitrary values', () => { - store.setData('session', { token: 'abc' }); - expect(store.getData<{ token: string }>('session')).toEqual({ token: 'abc' }); - }); -}); - -afterEach(() => { - vi.useRealTimers(); -}); diff --git a/src/emulate/core/store.ts b/src/emulate/core/store.ts deleted file mode 100644 index a8996983..00000000 --- a/src/emulate/core/store.ts +++ /dev/null @@ -1,198 +0,0 @@ -import { generateId } from './id.js'; -import { cursorPaginate, type Entity, type CursorPaginationOptions, type CursorPaginatedResult } from './pagination.js'; - -export type { Entity }; - -export type InsertInput = Omit & { - id?: string; -}; - -export type FilterFn = (item: T) => boolean; -export type SortFn = (a: T, b: T) => number; - -export interface CollectionHooks { - onInsert?: (item: T) => void; - onUpdate?: (item: T) => void; - onDelete?: (item: T) => void; -} - -export class Collection { - private items = new Map(); - private indexes = new Map>>(); - private hooks: CollectionHooks = {}; - readonly fieldNames: string[]; - - constructor( - private prefix: string, - private indexFields: (keyof T)[] = [], - ) { - this.fieldNames = indexFields.map(String).sort(); - for (const field of indexFields) { - this.indexes.set(String(field), new Map()); - } - } - - private addToIndex(item: T): void { - for (const field of this.indexFields) { - const value = item[field]; - if (value === undefined || value === null) continue; - const indexMap = this.indexes.get(String(field))!; - const key = String(value); - if (!indexMap.has(key)) { - indexMap.set(key, new Set()); - } - indexMap.get(key)!.add(item.id); - } - } - - private removeFromIndex(item: T): void { - for (const field of this.indexFields) { - const value = item[field]; - if (value === undefined || value === null) continue; - const indexMap = this.indexes.get(String(field))!; - const key = String(value); - indexMap.get(key)?.delete(item.id); - } - } - - insert(data: InsertInput): T { - const now = new Date().toISOString(); - const id = data.id ?? generateId(this.prefix); - const item = { - ...data, - id, - created_at: now, - updated_at: now, - } as unknown as T; - this.items.set(id, item); - this.addToIndex(item); - this.hooks.onInsert?.(item); - return item; - } - - get(id: string): T | undefined { - return this.items.get(id); - } - - findBy(field: keyof T, value: string | number): T[] { - if (this.indexes.has(String(field))) { - const ids = this.indexes.get(String(field))!.get(String(value)); - if (!ids) return []; - return Array.from(ids) - .map((id) => this.items.get(id)!) - .filter(Boolean); - } - return this.all().filter((item) => item[field] === value); - } - - findOneBy(field: keyof T, value: string | number): T | undefined { - return this.findBy(field, value)[0]; - } - - update(id: string, data: Partial): T | undefined { - const existing = this.items.get(id); - if (!existing) return undefined; - this.removeFromIndex(existing); - const updated = { - ...existing, - ...data, - id, - updated_at: new Date().toISOString(), - } as T; - this.items.set(id, updated); - this.addToIndex(updated); - this.hooks.onUpdate?.(updated); - return updated; - } - - delete(id: string): boolean { - const existing = this.items.get(id); - if (!existing) return false; - this.hooks.onDelete?.(existing); - this.removeFromIndex(existing); - return this.items.delete(id); - } - - deleteBy(field: keyof T, value: string | number): number { - const items = this.findBy(field, value); - for (const item of items) this.delete(item.id); - return items.length; - } - - setHooks(hooks: CollectionHooks): void { - this.hooks = hooks; - } - - all(): T[] { - return Array.from(this.items.values()); - } - - list(options: CursorPaginationOptions = {}): CursorPaginatedResult { - return cursorPaginate(this.all(), options); - } - - count(filter?: FilterFn): number { - if (!filter) return this.items.size; - let n = 0; - for (const item of this.items.values()) { - if (filter(item)) n++; - } - return n; - } - - clear(): void { - this.items.clear(); - for (const indexMap of this.indexes.values()) { - indexMap.clear(); - } - } -} - -export class Store { - private collections = new Map>(); - private _data = new Map(); - - collection(name: string, prefix: string, indexFields: (keyof T)[] = []): Collection { - const existing = this.collections.get(name); - if (existing) { - if (indexFields.length > 0) { - const requested = indexFields.map(String).sort(); - if (existing.fieldNames.length !== requested.length || existing.fieldNames.some((f, i) => f !== requested[i])) { - throw new Error( - `Collection "${name}" already exists with indexes [${existing.fieldNames}] but was requested with [${requested}]`, - ); - } - } - return existing as Collection; - } - const col = new Collection(prefix, indexFields); - this.collections.set(name, col); - return col; - } - - getData(key: string): V | undefined { - return this._data.get(key) as V | undefined; - } - - setData(key: string, value: V): void { - this._data.set(key, value); - } - - deleteDataByPrefix(prefix: string): number { - let count = 0; - for (const key of this._data.keys()) { - if (key.startsWith(prefix)) { - this._data.delete(key); - count++; - } - } - return count; - } - - reset(): void { - for (const collection of this.collections.values()) { - collection.clear(); - } - this._data.clear(); - } -} diff --git a/src/emulate/index.ts b/src/emulate/index.ts deleted file mode 100644 index 3f33a1f9..00000000 --- a/src/emulate/index.ts +++ /dev/null @@ -1,80 +0,0 @@ -import { createServer, type ApiKeyMap } from './core/index.js'; -import { workosPlugin, seedFromConfig, type WorkOSSeedConfig } from './workos/index.js'; -import { serve } from '@hono/node-server'; - -export interface EmulatorSeedConfig { - apiKeys?: Record; - organizations?: WorkOSSeedConfig['organizations']; - users?: WorkOSSeedConfig['users']; - connections?: WorkOSSeedConfig['connections']; - invitations?: WorkOSSeedConfig['invitations']; - roles?: WorkOSSeedConfig['roles']; - permissions?: WorkOSSeedConfig['permissions']; - webhookEndpoints?: WorkOSSeedConfig['webhookEndpoints']; -} - -export interface EmulatorOptions { - port?: number; - seed?: EmulatorSeedConfig; -} - -export interface Emulator { - url: string; - port: number; - apiKey: string; - close(): Promise; - reset(): void; -} - -export async function createEmulator(options: EmulatorOptions = {}): Promise { - const port = options.port ?? 4100; - const baseUrl = `http://localhost:${port}`; - - const apiKeys: ApiKeyMap = options.seed?.apiKeys ?? { - sk_test_default: { environment: 'test' }, - }; - - const { app, store, jwt } = createServer(workosPlugin, { - port, - baseUrl, - apiKeys, - }); - - // Health check endpoint - app.get('/health', (c) => c.json({ status: 'ok' })); - - const seedFn = () => { - workosPlugin.seed?.(store, baseUrl); - if (options.seed) { - seedFromConfig(store, baseUrl, options.seed); - } - }; - seedFn(); - - const httpServer = serve({ fetch: app.fetch, port }); - - // Resolve actual port (important for port: 0) - const addr = httpServer.address(); - const actualPort = typeof addr === 'object' && addr ? addr.port : port; - const url = `http://localhost:${actualPort}`; - - // Update JWT issuer to reflect the actual bound URL (matters when port: 0) - jwt.issuer = url; - - const primaryApiKey = Object.keys(apiKeys)[0]; - - return { - url, - port: actualPort, - apiKey: primaryApiKey, - reset() { - store.reset(); - seedFn(); - }, - close(): Promise { - return new Promise((resolve, reject) => { - httpServer.close((err) => (err ? reject(err) : resolve())); - }); - }, - }; -} diff --git a/src/emulate/workos/constants.ts b/src/emulate/workos/constants.ts deleted file mode 100644 index 851808f6..00000000 --- a/src/emulate/workos/constants.ts +++ /dev/null @@ -1,59 +0,0 @@ -/** Typed keys for Store.getData/setData */ -export const STORE_KEYS = { - workosStore: '_workos_store', - eventBus: 'eventBus', - apiKeyMap: 'apiKeyMap', - jwtTemplate: 'jwt_template', -} as const; - -/** Prefix for dynamic store keys */ -export const STORE_KEY_PREFIXES = { - pendingAuth: 'pending_auth:', - ssoToken: 'sso_token:', - ssoLogout: 'sso_logout:', - auditSchema: 'audit_schema_', - radarIpList: 'radar_ip_list', -} as const; - -/** All WorkOS webhook event names */ -export const EVENTS = { - userCreated: 'user.created', - userUpdated: 'user.updated', - userDeleted: 'user.deleted', - organizationCreated: 'organization.created', - organizationUpdated: 'organization.updated', - organizationDeleted: 'organization.deleted', - organizationDomainCreated: 'organization_domain.created', - organizationDomainVerified: 'organization_domain.verified', - organizationDomainUpdated: 'organization_domain.updated', - organizationDomainDeleted: 'organization_domain.deleted', - organizationMembershipCreated: 'organization_membership.created', - organizationMembershipUpdated: 'organization_membership.updated', - organizationMembershipDeleted: 'organization_membership.deleted', - connectionCreated: 'connection.created', - connectionUpdated: 'connection.updated', - connectionDeleted: 'connection.deleted', - sessionCreated: 'session.created', - sessionRevoked: 'session.revoked', - invitationCreated: 'invitation.created', - invitationAccepted: 'invitation.accepted', - invitationRevoked: 'invitation.revoked', - invitationResent: 'invitation.resent', - roleCreated: 'role.created', - roleUpdated: 'role.updated', - roleDeleted: 'role.deleted', - permissionCreated: 'permission.created', - permissionUpdated: 'permission.updated', - permissionDeleted: 'permission.deleted', - directoryCreated: 'directory.created', - directoryUpdated: 'directory.updated', - directoryDeleted: 'directory.deleted', - directoryUserCreated: 'directory_user.created', - directoryUserUpdated: 'directory_user.updated', - directoryUserDeleted: 'directory_user.deleted', - directoryGroupCreated: 'directory_group.created', - directoryGroupUpdated: 'directory_group.updated', - directoryGroupDeleted: 'directory_group.deleted', -} as const; - -export type WorkOSEventName = (typeof EVENTS)[keyof typeof EVENTS]; diff --git a/src/emulate/workos/entities.ts b/src/emulate/workos/entities.ts deleted file mode 100644 index 2171d079..00000000 --- a/src/emulate/workos/entities.ts +++ /dev/null @@ -1,401 +0,0 @@ -import type { Entity } from '../core/index.js'; - -export interface WorkOSOrganization extends Entity { - object: 'organization'; - name: string; - external_id: string | null; - metadata: Record; - stripe_customer_id: string | null; -} - -export interface WorkOSOrganizationDomain extends Entity { - object: 'organization_domain'; - organization_id: string; - domain: string; - state: 'verified' | 'pending'; - verification_strategy: 'manual' | 'dns'; - verification_token: string; - verification_prefix: string; -} - -export interface WorkOSOrganizationMembership extends Entity { - object: 'organization_membership'; - organization_id: string; - user_id: string; - role: { slug: string }; - status: 'active' | 'inactive' | 'pending'; - external_id: string | null; - metadata: Record; -} - -export interface WorkOSUser extends Entity { - object: 'user'; - email: string; - first_name: string | null; - last_name: string | null; - email_verified: boolean; - profile_picture_url: string | null; - last_sign_in_at: string | null; - external_id: string | null; - metadata: Record; - locale: string | null; - password_hash: string | null; - impersonator: { email: string; reason: string } | null; -} - -export interface WorkOSSession extends Entity { - object: 'session'; - user_id: string; - organization_id: string | null; - ip_address: string | null; - user_agent: string | null; -} - -export interface WorkOSEmailVerification extends Entity { - object: 'email_verification'; - user_id: string; - email: string; - code: string; - expires_at: string; -} - -export interface WorkOSPasswordReset extends Entity { - object: 'password_reset'; - user_id: string; - email: string; - token: string; - expires_at: string; -} - -export interface WorkOSMagicAuth extends Entity { - object: 'magic_auth'; - user_id: string; - email: string; - code: string; - expires_at: string; -} - -export interface WorkOSAuthenticationFactor extends Entity { - object: 'authentication_factor'; - user_id: string; - type: 'totp'; - totp: { - issuer: string; - user: string; - uri: string; - }; -} - -export interface WorkOSAuthorizationCode extends Entity { - user_id: string; - organization_id: string | null; - code: string; - redirect_uri: string; - expires_at: string; - code_challenge: string | null; - code_challenge_method: string | null; -} - -export interface WorkOSIdentity extends Entity { - object: 'identity'; - user_id: string; - provider: string; - provider_id: string; - type: 'OAuth'; -} - -export type WorkOSConnectionType = - | 'ADFSSAML' - | 'AzureSAML' - | 'GenericOIDC' - | 'GenericSAML' - | 'GoogleOAuth' - | 'GoogleSAML' - | 'OktaSAML' - | 'OneLoginSAML' - | 'PingFederateSAML' - | 'PingOneSAML' - | 'GitHubOAuth' - | 'MicrosoftOAuth' - | 'AppleOAuth'; - -export interface WorkOSConnectionDomain { - object: 'connection_domain'; - id: string; - domain: string; -} - -export interface WorkOSConnection extends Entity { - object: 'connection'; - organization_id: string; - connection_type: WorkOSConnectionType; - name: string; - state: 'active' | 'inactive' | 'validating'; - domains: WorkOSConnectionDomain[]; -} - -export interface WorkOSSSOProfile extends Entity { - object: 'profile'; - connection_id: string; - connection_type: WorkOSConnectionType; - organization_id: string; - idp_id: string; - email: string; - first_name: string | null; - last_name: string | null; - groups: string[]; - raw_attributes: Record; -} - -export interface WorkOSSSOAuthorization extends Entity { - code: string; - connection_id: string; - organization_id: string; - profile_id: string; - redirect_uri: string; - state: string | null; - expires_at: string; -} - -export interface WorkOSInvitation extends Entity { - object: 'invitation'; - email: string; - state: 'pending' | 'accepted' | 'expired' | 'revoked'; - token: string; - accept_invitation_url: string; - organization_id: string | null; - inviter_user_id: string | null; - role_slug: string | null; - expires_at: string; -} - -export interface WorkOSRedirectUri extends Entity { - object: 'redirect_uri'; - uri: string; -} - -export interface WorkOSCorsOrigin extends Entity { - object: 'cors_origin'; - origin: string; -} - -export interface WorkOSAuthorizedApplication extends Entity { - object: 'authorized_application'; - user_id: string; - name: string; - redirect_uri: string; -} - -export interface WorkOSConnectedAccount extends Entity { - object: 'connected_account'; - user_id: string; - provider: string; - provider_id: string; -} - -export type PipeProvider = 'github' | 'slack' | 'google' | 'salesforce'; -export type PipeConnectionStatus = 'connected' | 'disconnected' | 'requires_reauth'; - -export interface WorkOSPipeConnection extends Entity { - object: 'pipe_connection'; - user_id: string; - provider: PipeProvider; - scopes: string[]; - status: PipeConnectionStatus; - external_account_id: string | null; -} - -export interface WorkOSRefreshToken extends Entity { - token: string; - user_id: string; - organization_id: string | null; - session_id: string; - expires_at: string; -} - -export interface WorkOSAuthenticationChallenge extends Entity { - object: 'authentication_challenge'; - user_id: string; - factor_id: string; - expires_at: string; - code: string | null; -} - -export interface WorkOSDeviceAuthorization extends Entity { - device_code: string; - user_code: string; - user_id: string | null; - client_id: string; - expires_at: string; - interval: number; -} - -export interface WorkOSRole extends Entity { - object: 'role'; - slug: string; - name: string; - description: string | null; - type: 'EnvironmentRole' | 'OrganizationRole'; - organization_id: string | null; - is_default_role: boolean; - priority: number; -} - -export interface WorkOSPermission extends Entity { - object: 'permission'; - slug: string; - name: string; - description: string | null; -} - -export interface WorkOSRolePermission extends Entity { - role_id: string; - permission_id: string; -} - -export interface WorkOSAuthorizationResource extends Entity { - object: 'authorization_resource'; - resource_type_slug: string; - external_id: string; - organization_id: string; - metadata: Record; -} - -export interface WorkOSRoleAssignment extends Entity { - object: 'role_assignment'; - organization_membership_id: string; - role_id: string; -} - -export interface WorkOSDirectory extends Entity { - object: 'directory'; - name: string; - organization_id: string | null; - domain: string | null; - type: string; - state: 'linked' | 'unlinked' | 'deleting' | 'invalid_credentials'; - external_key: string | null; -} - -export interface WorkOSDirectoryUser extends Entity { - object: 'directory_user'; - directory_id: string; - organization_id: string | null; - idp_id: string; - first_name: string | null; - last_name: string | null; - email: string | null; - username: string | null; - state: 'active' | 'inactive'; - role: { slug: string } | null; - custom_attributes: Record; - raw_attributes: Record; - groups: Array<{ object: 'directory_group'; id: string; name: string }>; -} - -export interface WorkOSDirectoryGroup extends Entity { - object: 'directory_group'; - directory_id: string; - organization_id: string | null; - idp_id: string; - name: string; - raw_attributes: Record; -} - -export interface WorkOSAuditLogAction extends Entity { - object: 'audit_log_action'; - name: string; - description: string | null; - condition: string | null; -} - -export interface WorkOSAuditLogEvent extends Entity { - object: 'audit_log_event'; - organization_id: string; - action: { name: string; type: string; id: string }; - actor: Record; - targets: Array>; - metadata: Record | null; - occurred_at: string; -} - -export interface WorkOSAuditLogExport extends Entity { - object: 'audit_log_export'; - organization_id: string; - state: 'pending' | 'ready' | 'error'; - url: string | null; - filters: Record; -} - -export interface WorkOSFeatureFlag extends Entity { - object: 'feature_flag'; - slug: string; - name: string; - description: string | null; - type: 'boolean' | 'string' | 'number'; - default_value: unknown; - enabled: boolean; -} - -export interface WorkOSFlagTarget extends Entity { - object: 'flag_target'; - flag_slug: string; - resource_id: string; - resource_type: string; - value: unknown; -} - -export interface WorkOSConnectApplication extends Entity { - object: 'connect_application'; - name: string; - redirect_uris: string[]; - client_id: string; - logo_url: string | null; -} - -export interface WorkOSClientSecret extends Entity { - object: 'client_secret'; - application_id: string; - value: string; - last_four: string; -} - -export interface WorkOSDataIntegrationAuth extends Entity { - slug: string; - code: string; - redirect_uri: string; - state: string | null; - expires_at: string; -} - -export interface WorkOSRadarAttempt extends Entity { - object: 'radar_attempt'; - user_id: string | null; - ip_address: string; - user_agent: string | null; - verdict: 'allow' | 'deny' | 'challenge'; - signals: Array<{ type: string; confidence: number }>; -} - -export interface WorkOSApiKey extends Entity { - object: 'api_key'; - name: string; - key: string; - environment: string; -} - -export interface WorkOSEvent extends Entity { - object: 'event'; - event: string; - data: Record; - environment_id: string | null; -} - -export interface WorkOSWebhookEndpoint extends Entity { - object: 'webhook_endpoint'; - endpoint_url: string; - secret: string; - enabled: boolean; - events: string[]; - description: string | null; -} diff --git a/src/emulate/workos/event-bus.spec.ts b/src/emulate/workos/event-bus.spec.ts deleted file mode 100644 index 9819bdf2..00000000 --- a/src/emulate/workos/event-bus.spec.ts +++ /dev/null @@ -1,150 +0,0 @@ -import { describe, it, expect, beforeEach, vi } from 'vitest'; -import { createHmac } from 'node:crypto'; -import { Store } from '../core/store.js'; -import { getWorkOSStore } from './store.js'; -import { EventBus } from './event-bus.js'; - -describe('EventBus', () => { - let store: Store; - let bus: EventBus; - - beforeEach(() => { - store = new Store(); - bus = new EventBus(store); - }); - - it('stores events on emit', () => { - const ws = getWorkOSStore(store); - bus.emit({ event: 'user.created', data: { id: 'user_1', email: 'test@example.com' } }); - - const events = ws.events.all(); - expect(events).toHaveLength(1); - expect(events[0].event).toBe('user.created'); - expect(events[0].data).toEqual({ id: 'user_1', email: 'test@example.com' }); - expect(events[0].environment_id).toBeNull(); - }); - - it('stores environment_id when provided', () => { - const ws = getWorkOSStore(store); - bus.emit({ event: 'user.created', data: {}, environment_id: 'env_123' }); - - const events = ws.events.all(); - expect(events[0].environment_id).toBe('env_123'); - }); - - it('stores multiple events in order', () => { - const ws = getWorkOSStore(store); - bus.emit({ event: 'user.created', data: { id: '1' } }); - bus.emit({ event: 'user.updated', data: { id: '1' } }); - bus.emit({ event: 'organization.created', data: { id: '2' } }); - - const events = ws.events.all(); - expect(events).toHaveLength(3); - expect(events.map((e) => e.event)).toEqual(['user.created', 'user.updated', 'organization.created']); - }); - - it('does not deliver to disabled webhook endpoints', () => { - const ws = getWorkOSStore(store); - ws.webhookEndpoints.insert({ - object: 'webhook_endpoint', - endpoint_url: 'http://localhost:9999/webhook', - secret: 'whsec_test', - enabled: false, - events: [], - description: null, - }); - - bus.rebuildIndex(); - // This should not attempt delivery (no fetch error even though URL is unreachable) - bus.emit({ event: 'user.created', data: {} }); - expect(ws.events.all()).toHaveLength(1); - }); - - it('filters webhook endpoints by event subscription', () => { - const ws = getWorkOSStore(store); - ws.webhookEndpoints.insert({ - object: 'webhook_endpoint', - endpoint_url: 'http://localhost:9999/webhook', - secret: 'whsec_test', - enabled: true, - events: ['organization.created'], - description: null, - }); - - bus.rebuildIndex(); - // user.created should not match the endpoint's filter - bus.emit({ event: 'user.created', data: {} }); - expect(ws.events.all()).toHaveLength(1); - }); - - it('delivers to webhook endpoint with correct HMAC signature', async () => { - const ws = getWorkOSStore(store); - const secret = 'whsec_test_verify_signature'; - let receivedBody: string | undefined; - let receivedSignature: string | undefined; - - // Mock fetch to capture the delivery - const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(new Response('ok')); - - ws.webhookEndpoints.insert({ - object: 'webhook_endpoint', - endpoint_url: 'http://localhost:9999/webhook', - secret, - enabled: true, - events: [], - description: null, - }); - - bus.rebuildIndex(); - bus.emit({ event: 'user.created', data: { id: 'user_1' } }); - - // Wait for async delivery - await new Promise((resolve) => setTimeout(resolve, 50)); - - expect(fetchSpy).toHaveBeenCalledOnce(); - const [, init] = fetchSpy.mock.calls[0]; - receivedBody = init!.body as string; - receivedSignature = (init!.headers as Record)['WorkOS-Signature']; - - // Verify signature format - expect(receivedSignature).toMatch(/^t=\d+,v1=[a-f0-9]{64}$/); - - // Verify HMAC is correct - const match = receivedSignature!.match(/^t=(\d+),v1=([a-f0-9]+)$/)!; - const [, timestamp, hash] = match; - const expectedHash = createHmac('sha256', secret).update(`${timestamp}.${receivedBody}`).digest('hex'); - expect(hash).toBe(expectedHash); - - fetchSpy.mockRestore(); - }); - - it('does not block when webhook delivery times out', async () => { - const ws = getWorkOSStore(store); - - // Mock fetch to simulate a slow endpoint - const fetchSpy = vi - .spyOn(globalThis, 'fetch') - .mockImplementation(() => new Promise((resolve) => setTimeout(() => resolve(new Response('ok')), 10000))); - - ws.webhookEndpoints.insert({ - object: 'webhook_endpoint', - endpoint_url: 'http://localhost:9999/webhook', - secret: 'whsec_test', - enabled: true, - events: [], - description: null, - }); - - bus.rebuildIndex(); - // emit() should return immediately (fire-and-forget) - const start = Date.now(); - bus.emit({ event: 'user.created', data: {} }); - const elapsed = Date.now() - start; - - // Should complete in under 100ms (not waiting for 10s fetch) - expect(elapsed).toBeLessThan(100); - expect(ws.events.all()).toHaveLength(1); - - fetchSpy.mockRestore(); - }); -}); diff --git a/src/emulate/workos/event-bus.ts b/src/emulate/workos/event-bus.ts deleted file mode 100644 index f41413ae..00000000 --- a/src/emulate/workos/event-bus.ts +++ /dev/null @@ -1,81 +0,0 @@ -import type { Store } from '../core/index.js'; -import { getWorkOSStore } from './store.js'; -import type { WorkOSWebhookEndpoint, WorkOSEvent } from './entities.js'; -import { signWebhookPayload } from './webhook-signer.js'; -import type { WorkOSEventName } from './constants.js'; - -export interface EventPayload { - event: WorkOSEventName | string; - data: Record; - environment_id?: string; -} - -export class EventBus { - private endpointsByEvent = new Map>(); - private catchAllEndpoints = new Set(); - - constructor(private store: Store) {} - - /** Rebuild the event-type index. Auto-called via collection hooks; call manually only in tests. */ - rebuildIndex(): void { - this.endpointsByEvent.clear(); - this.catchAllEndpoints.clear(); - const ws = getWorkOSStore(this.store); - for (const ep of ws.webhookEndpoints.all()) { - if (!ep.enabled) continue; - if (ep.events.length === 0) { - this.catchAllEndpoints.add(ep.id); - } else { - for (const evt of ep.events) { - const set = this.endpointsByEvent.get(evt) ?? new Set(); - set.add(ep.id); - this.endpointsByEvent.set(evt, set); - } - } - } - } - - emit(payload: EventPayload): void { - const ws = getWorkOSStore(this.store); - - const event = ws.events.insert({ - object: 'event', - event: payload.event, - data: payload.data, - environment_id: payload.environment_id ?? null, - }); - - // Pre-filtered: only endpoints that care about this event - const targetIds = new Set(this.catchAllEndpoints); - const eventSpecific = this.endpointsByEvent.get(payload.event); - if (eventSpecific) { - for (const id of eventSpecific) targetIds.add(id); - } - - for (const id of targetIds) { - const endpoint = ws.webhookEndpoints.get(id); - if (endpoint) this.deliver(endpoint, event).catch(() => {}); - } - } - - private async deliver(endpoint: WorkOSWebhookEndpoint, event: WorkOSEvent): Promise { - const body = JSON.stringify({ - id: event.id, - event: event.event, - data: event.data, - created_at: event.created_at, - }); - - const signature = signWebhookPayload(body, endpoint.secret); - - await fetch(endpoint.endpoint_url, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'WorkOS-Signature': signature, - }, - body, - signal: AbortSignal.timeout(5000), - }); - } -} diff --git a/src/emulate/workos/helpers.ts b/src/emulate/workos/helpers.ts deleted file mode 100644 index 5b665c01..00000000 --- a/src/emulate/workos/helpers.ts +++ /dev/null @@ -1,322 +0,0 @@ -import { randomBytes, createHash, createCipheriv } from 'node:crypto'; -import { WorkOSApiError, type CursorPaginatedResult, type Entity } from '../core/index.js'; -import type { WorkOSStore } from './store.js'; -import type { - WorkOSOrganization, - WorkOSOrganizationDomain, - WorkOSOrganizationMembership, - WorkOSUser, - WorkOSSession, - WorkOSEmailVerification, - WorkOSPasswordReset, - WorkOSMagicAuth, - WorkOSAuthenticationFactor, - WorkOSIdentity, - WorkOSConnection, - WorkOSSSOProfile, - WorkOSPipeConnection, - WorkOSInvitation, - WorkOSRedirectUri, - WorkOSCorsOrigin, - WorkOSAuthorizedApplication, - WorkOSConnectedAccount, - WorkOSAuthenticationChallenge, - WorkOSDeviceAuthorization, - WorkOSRole, - WorkOSPermission, - WorkOSAuthorizationResource, - WorkOSRoleAssignment, - WorkOSDirectory, - WorkOSDirectoryUser, - WorkOSDirectoryGroup, - WorkOSAuditLogAction, - WorkOSAuditLogEvent, - WorkOSAuditLogExport, - WorkOSFeatureFlag, - WorkOSFlagTarget, - WorkOSConnectApplication, - WorkOSClientSecret, - WorkOSRadarAttempt, - WorkOSApiKey, - WorkOSEvent, - WorkOSWebhookEndpoint, -} from './entities.js'; - -const INTERNAL_FIELDS = new Set(['password_hash', 'code_challenge', 'code_challenge_method']); - -export function formatEntity(entity: T, opts?: { exclude?: Set }): Record { - const exclude = opts?.exclude ?? INTERNAL_FIELDS; - const result: Record = {}; - for (const [key, value] of Object.entries(entity)) { - if (!exclude.has(key)) result[key] = value; - } - return result; -} - -export function formatListResponse( - result: CursorPaginatedResult, - formatter: (item: T) => Record, -): { object: 'list'; data: Record[]; list_metadata: { before: string | null; after: string | null } } { - return { - object: 'list', - data: result.data.map(formatter), - list_metadata: result.list_metadata, - }; -} - -export function formatOrganization( - org: WorkOSOrganization, - ws: WorkOSStore, - opts?: { domains?: WorkOSOrganizationDomain[] }, -): Record { - const domains = (opts?.domains ?? ws.organizationDomains.findBy('organization_id', org.id)).map(formatDomain); - - return { - object: 'organization', - id: org.id, - name: org.name, - external_id: org.external_id, - metadata: org.metadata, - domains, - stripe_customer_id: org.stripe_customer_id, - created_at: org.created_at, - updated_at: org.updated_at, - }; -} - -export function formatDomain(domain: WorkOSOrganizationDomain): Record { - return formatEntity(domain); -} - -export function formatMembership(m: WorkOSOrganizationMembership): Record { - return formatEntity(m); -} - -const USER_EXCLUDE = new Set([...INTERNAL_FIELDS, 'impersonator']); - -export function formatUser(user: WorkOSUser): Record { - return formatEntity(user, { exclude: USER_EXCLUDE }); -} - -export function formatSession(s: WorkOSSession): Record { - return formatEntity(s); -} - -export function formatEmailVerification(ev: WorkOSEmailVerification): Record { - return formatEntity(ev); -} - -export function formatPasswordReset(pr: WorkOSPasswordReset): Record { - return formatEntity(pr); -} - -export function formatMagicAuth(ma: WorkOSMagicAuth): Record { - return formatEntity(ma); -} - -export function formatAuthFactor(f: WorkOSAuthenticationFactor): Record { - return formatEntity(f); -} - -export function formatIdentity(i: WorkOSIdentity): Record { - return formatEntity(i); -} - -export function generateVerificationToken(): string { - return randomBytes(16).toString('hex'); -} - -export function generateCode(): string { - return String(Math.floor(100000 + Math.random() * 900000)); -} - -export function hashPassword(password: string): string { - return createHash('sha256').update(password).digest('hex'); -} - -export function verifyPassword(password: string, hash: string): boolean { - return hashPassword(password) === hash; -} - -export function expiresIn(minutes: number): string { - return new Date(Date.now() + minutes * 60 * 1000).toISOString(); -} - -export function isExpired(expiresAt: string): boolean { - return new Date(expiresAt).getTime() < Date.now(); -} - -export function formatConnection(conn: WorkOSConnection): Record { - return formatEntity(conn); -} - -export function formatSSOProfile(p: WorkOSSSOProfile): Record { - return formatEntity(p); -} - -export function formatPipeConnection(pc: WorkOSPipeConnection): Record { - return formatEntity(pc); -} - -export function formatInvitation(inv: WorkOSInvitation): Record { - return formatEntity(inv); -} - -export function formatRedirectUri(r: WorkOSRedirectUri): Record { - return formatEntity(r); -} - -export function formatCorsOrigin(o: WorkOSCorsOrigin): Record { - return formatEntity(o); -} - -export function formatAuthorizedApplication(a: WorkOSAuthorizedApplication): Record { - return formatEntity(a); -} - -export function formatConnectedAccount(a: WorkOSConnectedAccount): Record { - return formatEntity(a); -} - -/** Allowed redirect URI hosts for the emulator's authorize endpoints. */ -const ALLOWED_REDIRECT_HOSTS = new Set(['localhost', '127.0.0.1', '[::1]']); - -/** - * Validate that a redirect_uri points to a localhost origin. - * Prevents the emulator from being used as an open redirect. - */ -export function assertLocalRedirectUri(uri: string): void { - let parsed: URL; - try { - parsed = new URL(uri); - } catch { - throw new WorkOSApiError(400, 'Invalid redirect_uri', 'invalid_redirect_uri'); - } - if (!ALLOWED_REDIRECT_HOSTS.has(parsed.hostname)) { - throw new WorkOSApiError( - 400, - `redirect_uri must point to localhost, got ${parsed.hostname}`, - 'invalid_redirect_uri', - ); - } -} - -const AUTH_CHALLENGE_EXCLUDE = new Set([...INTERNAL_FIELDS, 'code']); - -export function formatAuthChallenge(c: WorkOSAuthenticationChallenge): Record { - return formatEntity(c, { exclude: AUTH_CHALLENGE_EXCLUDE }); -} - -export function formatRole(role: WorkOSRole): Record { - return formatEntity(role); -} - -export function formatPermission(p: WorkOSPermission): Record { - return formatEntity(p); -} - -export function formatAuthorizationResource(r: WorkOSAuthorizationResource): Record { - return formatEntity(r); -} - -export function formatRoleAssignment(ra: WorkOSRoleAssignment): Record { - return formatEntity(ra); -} - -export function formatDeviceAuthorization(d: WorkOSDeviceAuthorization): Record { - return { - device_code: d.device_code, - user_code: d.user_code, - verification_uri: 'http://localhost:0/user_management/authorize/device/verify', - expires_in: Math.max(0, Math.floor((new Date(d.expires_at).getTime() - Date.now()) / 1000)), - interval: d.interval, - }; -} - -export function formatDirectory(d: WorkOSDirectory): Record { - return formatEntity(d); -} - -export function formatDirectoryUser(u: WorkOSDirectoryUser): Record { - return formatEntity(u); -} - -export function formatDirectoryGroup(g: WorkOSDirectoryGroup): Record { - return formatEntity(g); -} - -export function formatAuditLogAction(a: WorkOSAuditLogAction): Record { - return formatEntity(a); -} - -export function formatAuditLogEvent(e: WorkOSAuditLogEvent): Record { - return formatEntity(e); -} - -export function formatAuditLogExport(ex: WorkOSAuditLogExport): Record { - return formatEntity(ex); -} - -export function formatFeatureFlag(f: WorkOSFeatureFlag): Record { - return formatEntity(f); -} - -export function formatFlagTarget(t: WorkOSFlagTarget): Record { - return formatEntity(t); -} - -export function formatConnectApplication(a: WorkOSConnectApplication): Record { - return formatEntity(a); -} - -const CLIENT_SECRET_EXCLUDE = new Set([...INTERNAL_FIELDS, 'value']); - -export function formatClientSecret(s: WorkOSClientSecret): Record { - return formatEntity(s, { exclude: CLIENT_SECRET_EXCLUDE }); -} - -export function formatRadarAttempt(a: WorkOSRadarAttempt): Record { - return formatEntity(a); -} - -const API_KEY_EXCLUDE = new Set([...INTERNAL_FIELDS, 'key', 'environment']); - -export function formatApiKeyRecord(k: WorkOSApiKey): Record { - return formatEntity(k, { exclude: API_KEY_EXCLUDE }); -} - -const EVENT_EXCLUDE = new Set([...INTERNAL_FIELDS, 'updated_at']); - -export function formatEvent(e: WorkOSEvent): Record { - return formatEntity(e, { exclude: EVENT_EXCLUDE }); -} - -export function formatWebhookEndpoint( - ep: WorkOSWebhookEndpoint, - opts?: { includeSecret?: boolean }, -): Record { - return { - object: 'webhook_endpoint', - id: ep.id, - endpoint_url: ep.endpoint_url, - secret: opts?.includeSecret ? ep.secret : `${ep.secret.slice(0, 8)}****`, - enabled: ep.enabled, - events: ep.events, - description: ep.description, - created_at: ep.created_at, - updated_at: ep.updated_at, - }; -} - -export function sealSession( - data: { access_token: string; refresh_token: string; session_id: string }, - apiKey: string, -): string { - const key = createHash('sha256').update(apiKey).digest(); - const iv = randomBytes(12); - const cipher = createCipheriv('aes-256-gcm', key, iv); - const plaintext = JSON.stringify(data); - const encrypted = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]); - const tag = cipher.getAuthTag(); - return Buffer.concat([iv, tag, encrypted]).toString('base64'); -} diff --git a/src/emulate/workos/index.ts b/src/emulate/workos/index.ts deleted file mode 100644 index a5541c3c..00000000 --- a/src/emulate/workos/index.ts +++ /dev/null @@ -1,452 +0,0 @@ -import { randomBytes } from 'node:crypto'; -import type { ServicePlugin, Store, RouteContext } from '../core/index.js'; -import { generateId } from '../core/index.js'; -import { getWorkOSStore, type WorkOSStore } from './store.js'; -import { organizationRoutes } from './routes/organizations.js'; -import { organizationDomainRoutes } from './routes/organization-domains.js'; -import { membershipRoutes } from './routes/memberships.js'; -import { userRoutes } from './routes/users.js'; -import { emailVerificationRoutes } from './routes/email-verification.js'; -import { passwordResetRoutes } from './routes/password-reset.js'; -import { magicAuthRoutes } from './routes/magic-auth.js'; -import { authFactorRoutes } from './routes/auth-factors.js'; -import { sessionRoutes } from './routes/sessions.js'; -import { authRoutes } from './routes/auth.js'; -import { connectionRoutes } from './routes/connections.js'; -import { ssoRoutes } from './routes/sso.js'; -import { pipeRoutes } from './routes/pipes.js'; -import { authChallengeRoutes } from './routes/auth-challenges.js'; -import { invitationRoutes } from './routes/invitations.js'; -import { configRoutes } from './routes/config.js'; -import { userFeatureRoutes } from './routes/user-features.js'; -import { widgetRoutes } from './routes/widgets.js'; -import { authorizationRoleRoutes } from './routes/authorization-roles.js'; -import { authorizationPermissionRoutes } from './routes/authorization-permissions.js'; -import { authorizationOrgRoleRoutes } from './routes/authorization-org-roles.js'; -import { authorizationResourceRoutes } from './routes/authorization-resources.js'; -import { authorizationCheckRoutes } from './routes/authorization-checks.js'; -import { portalRoutes } from './routes/portal.js'; -import { legacyMfaRoutes } from './routes/legacy-mfa.js'; -import { apiKeyRoutes } from './routes/api-keys.js'; -import { radarRoutes } from './routes/radar.js'; -import { connectRoutes } from './routes/connect.js'; -import { directoryRoutes } from './routes/directories.js'; -import { auditLogRoutes } from './routes/audit-logs.js'; -import { featureFlagRoutes } from './routes/feature-flags.js'; -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 { - generateVerificationToken, - hashPassword, - expiresIn, - formatUser, - formatOrganization, - formatMembership, - formatConnection, - formatSession, - formatInvitation, - formatRole, - formatPermission, - formatDirectory, - formatDirectoryUser, - formatDirectoryGroup, - formatDomain, -} from './helpers.js'; -import type { WorkOSConnectionType, PipeProvider, PipeConnectionStatus } from './entities.js'; - -export { getWorkOSStore, type WorkOSStore } from './store.js'; -export * from './entities.js'; - -export interface WorkOSSeedOrganization { - name: string; - external_id?: string; - metadata?: Record; - domains?: Array<{ domain: string; state?: 'verified' | 'pending' }>; - memberships?: Array<{ - user_id: string; - role?: string; - status?: 'active' | 'inactive' | 'pending'; - }>; -} - -export interface WorkOSSeedUser { - email: string; - first_name?: string; - last_name?: string; - password?: string; - email_verified?: boolean; - external_id?: string; - metadata?: Record; - impersonator?: { email: string; reason: string }; -} - -export interface WorkOSSeedConnection { - name: string; - connection_type?: WorkOSConnectionType; - organization: string; - state?: 'active' | 'inactive' | 'validating'; - domains?: string[]; - profiles?: Array<{ - email: string; - first_name?: string; - last_name?: string; - idp_id?: string; - groups?: string[]; - }>; -} - -export interface WorkOSSeedPipeConnection { - user_id: string; - provider: PipeProvider; - scopes: string[]; - status?: PipeConnectionStatus; - external_account_id?: string; -} - -export interface WorkOSSeedInvitation { - email: string; - organization_id?: string; - inviter_user_id?: string; - role_slug?: string; -} - -export interface WorkOSSeedRole { - slug: string; - name: string; - description?: string; - type?: 'EnvironmentRole' | 'OrganizationRole'; - organization_id?: string; - is_default_role?: boolean; - priority?: number; - permissions?: string[]; -} - -export interface WorkOSSeedPermission { - slug: string; - name: string; - description?: string; -} - -export interface WorkOSSeedWebhookEndpoint { - endpoint_url?: string; - /** @deprecated Use endpoint_url */ - url?: string; - events?: string[]; - enabled?: boolean; -} - -export interface WorkOSSeedConfig { - organizations?: WorkOSSeedOrganization[]; - users?: WorkOSSeedUser[]; - connections?: WorkOSSeedConnection[]; - pipeConnections?: WorkOSSeedPipeConnection[]; - invitations?: WorkOSSeedInvitation[]; - roles?: WorkOSSeedRole[]; - permissions?: WorkOSSeedPermission[]; - webhookEndpoints?: WorkOSSeedWebhookEndpoint[]; -} - -export function seedFromConfig(store: Store, _baseUrl: string, config: WorkOSSeedConfig): void { - const ws = getWorkOSStore(store); - - if (config.users) { - for (const userConfig of config.users) { - ws.users.insert({ - object: 'user', - email: userConfig.email, - first_name: userConfig.first_name ?? null, - last_name: userConfig.last_name ?? null, - email_verified: userConfig.email_verified ?? false, - profile_picture_url: null, - last_sign_in_at: null, - external_id: userConfig.external_id ?? null, - metadata: userConfig.metadata ?? {}, - locale: null, - password_hash: userConfig.password ? hashPassword(userConfig.password) : null, - impersonator: userConfig.impersonator ?? null, - }); - } - } - - if (config.organizations) { - for (const orgConfig of config.organizations) { - const org = ws.organizations.insert({ - object: 'organization', - name: orgConfig.name, - external_id: orgConfig.external_id ?? null, - metadata: orgConfig.metadata ?? {}, - stripe_customer_id: null, - }); - - if (orgConfig.domains) { - for (const dd of orgConfig.domains) { - ws.organizationDomains.insert({ - object: 'organization_domain', - organization_id: org.id, - domain: dd.domain, - state: dd.state ?? 'pending', - verification_strategy: 'manual', - verification_token: generateVerificationToken(), - verification_prefix: 'workos-verify', - }); - } - } - - if (orgConfig.memberships) { - for (const mm of orgConfig.memberships) { - ws.organizationMemberships.insert({ - object: 'organization_membership', - organization_id: org.id, - user_id: mm.user_id, - role: { slug: mm.role ?? 'member' }, - status: mm.status ?? 'active', - external_id: null, - metadata: {}, - }); - } - } - } - } - - if (config.connections) { - for (const connConfig of config.connections) { - const org = ws.organizations.findOneBy('name', connConfig.organization); - if (!org) continue; - - const domains = (connConfig.domains ?? []).map((d) => ({ - object: 'connection_domain' as const, - id: generateId('conn_domain'), - domain: d, - })); - - const conn = ws.connections.insert({ - object: 'connection', - organization_id: org.id, - connection_type: connConfig.connection_type ?? 'GenericSAML', - name: connConfig.name, - state: connConfig.state ?? 'active', - domains, - }); - - if (connConfig.profiles) { - for (const p of connConfig.profiles) { - ws.ssoProfiles.insert({ - object: 'profile', - connection_id: conn.id, - connection_type: conn.connection_type, - organization_id: org.id, - idp_id: p.idp_id ?? `idp_${generateId('usr')}`, - email: p.email, - first_name: p.first_name ?? null, - last_name: p.last_name ?? null, - groups: p.groups ?? [], - raw_attributes: { email: p.email }, - }); - } - } - } - } - - if (config.pipeConnections) { - for (const pc of config.pipeConnections) { - ws.pipeConnections.insert({ - object: 'pipe_connection', - user_id: pc.user_id, - provider: pc.provider, - scopes: pc.scopes, - status: pc.status ?? 'connected', - external_account_id: pc.external_account_id ?? null, - }); - } - } - - if (config.permissions) { - for (const permConfig of config.permissions) { - ws.permissions.insert({ - object: 'permission', - slug: permConfig.slug, - name: permConfig.name, - description: permConfig.description ?? null, - }); - } - } - - if (config.roles) { - for (const roleConfig of config.roles) { - const role = ws.roles.insert({ - object: 'role', - slug: roleConfig.slug, - name: roleConfig.name, - description: roleConfig.description ?? null, - type: roleConfig.type ?? 'EnvironmentRole', - organization_id: roleConfig.organization_id ?? null, - is_default_role: roleConfig.is_default_role ?? false, - priority: roleConfig.priority ?? 0, - }); - - if (roleConfig.permissions) { - for (const permSlug of roleConfig.permissions) { - const perm = ws.permissions.findOneBy('slug', permSlug); - if (perm) { - ws.rolePermissions.insert({ role_id: role.id, permission_id: perm.id }); - } - } - } - } - } - - if (config.invitations) { - for (const invConfig of config.invitations) { - const token = generateVerificationToken(); - ws.invitations.insert({ - object: 'invitation', - email: invConfig.email, - state: 'pending', - token, - accept_invitation_url: `${_baseUrl}/user_management/invitations/accept?token=${token}`, - organization_id: invConfig.organization_id ?? null, - inviter_user_id: invConfig.inviter_user_id ?? null, - role_slug: invConfig.role_slug ?? null, - expires_at: expiresIn(72 * 60), - }); - } - } - - if (config.webhookEndpoints) { - for (const whConfig of config.webhookEndpoints) { - const endpointUrl = whConfig.endpoint_url ?? whConfig.url; - if (!endpointUrl || typeof endpointUrl !== 'string') { - throw new Error('workos seed config: webhookEndpoints[].endpoint_url is required'); - } - ws.webhookEndpoints.insert({ - object: 'webhook_endpoint', - endpoint_url: endpointUrl, - secret: randomBytes(32).toString('hex'), - enabled: whConfig.enabled !== false, - events: whConfig.events ?? [], - description: null, - }); - } - } -} - -export const workosPlugin: ServicePlugin = { - name: 'workos', - register(ctx: RouteContext): void { - organizationRoutes(ctx); - organizationDomainRoutes(ctx); - membershipRoutes(ctx); - userRoutes(ctx); - emailVerificationRoutes(ctx); - passwordResetRoutes(ctx); - magicAuthRoutes(ctx); - authFactorRoutes(ctx); - authChallengeRoutes(ctx); - sessionRoutes(ctx); - authRoutes(ctx); - connectionRoutes(ctx); - ssoRoutes(ctx); - pipeRoutes(ctx); - invitationRoutes(ctx); - configRoutes(ctx); - userFeatureRoutes(ctx); - widgetRoutes(ctx); - authorizationRoleRoutes(ctx); - authorizationPermissionRoutes(ctx); - authorizationOrgRoleRoutes(ctx); - authorizationResourceRoutes(ctx); - authorizationCheckRoutes(ctx); - portalRoutes(ctx); - legacyMfaRoutes(ctx); - apiKeyRoutes(ctx); - radarRoutes(ctx); - connectRoutes(ctx); - directoryRoutes(ctx); - auditLogRoutes(ctx); - featureFlagRoutes(ctx); - dataIntegrationRoutes(ctx); - webhookEndpointRoutes(ctx); - eventRoutes(ctx); - - // Set up event bus with collection hooks (Option A from spec) - // Store on ctx.store for route-level access (hybrid Option A+B for action events) - const eventBus = new EventBus(ctx.store); - ctx.store.setData(STORE_KEYS.eventBus, eventBus); - const ws = getWorkOSStore(ctx.store); - - ws.users.setHooks({ - onInsert: (u) => eventBus.emit({ event: EVENTS.userCreated, data: formatUser(u) }), - onUpdate: (u) => eventBus.emit({ event: EVENTS.userUpdated, data: formatUser(u) }), - onDelete: (u) => eventBus.emit({ event: EVENTS.userDeleted, data: formatUser(u) }), - }); - ws.organizations.setHooks({ - onInsert: (o) => eventBus.emit({ event: EVENTS.organizationCreated, data: formatOrganization(o, ws) }), - onUpdate: (o) => eventBus.emit({ event: EVENTS.organizationUpdated, data: formatOrganization(o, ws) }), - onDelete: (o) => eventBus.emit({ event: EVENTS.organizationDeleted, data: formatOrganization(o, ws) }), - }); - ws.organizationDomains.setHooks({ - onInsert: (d) => eventBus.emit({ event: EVENTS.organizationDomainCreated, data: formatDomain(d) }), - onUpdate: (d) => - eventBus.emit({ - event: d.state === 'verified' ? EVENTS.organizationDomainVerified : EVENTS.organizationDomainUpdated, - data: formatDomain(d), - }), - onDelete: (d) => eventBus.emit({ event: EVENTS.organizationDomainDeleted, data: formatDomain(d) }), - }); - ws.organizationMemberships.setHooks({ - onInsert: (m) => eventBus.emit({ event: EVENTS.organizationMembershipCreated, data: formatMembership(m) }), - onUpdate: (m) => eventBus.emit({ event: EVENTS.organizationMembershipUpdated, data: formatMembership(m) }), - onDelete: (m) => eventBus.emit({ event: EVENTS.organizationMembershipDeleted, data: formatMembership(m) }), - }); - ws.connections.setHooks({ - onInsert: (c) => eventBus.emit({ event: EVENTS.connectionCreated, data: formatConnection(c) }), - onUpdate: (c) => eventBus.emit({ event: EVENTS.connectionUpdated, data: formatConnection(c) }), - onDelete: (c) => eventBus.emit({ event: EVENTS.connectionDeleted, data: formatConnection(c) }), - }); - ws.sessions.setHooks({ - onInsert: (s) => eventBus.emit({ event: EVENTS.sessionCreated, data: formatSession(s) }), - onDelete: (s) => eventBus.emit({ event: EVENTS.sessionRevoked, data: formatSession(s) }), - }); - ws.invitations.setHooks({ - onInsert: (i) => eventBus.emit({ event: EVENTS.invitationCreated, data: formatInvitation(i) }), - }); - ws.roles.setHooks({ - onInsert: (r) => eventBus.emit({ event: EVENTS.roleCreated, data: formatRole(r) }), - onUpdate: (r) => eventBus.emit({ event: EVENTS.roleUpdated, data: formatRole(r) }), - onDelete: (r) => eventBus.emit({ event: EVENTS.roleDeleted, data: formatRole(r) }), - }); - ws.permissions.setHooks({ - onInsert: (p) => eventBus.emit({ event: EVENTS.permissionCreated, data: formatPermission(p) }), - onUpdate: (p) => eventBus.emit({ event: EVENTS.permissionUpdated, data: formatPermission(p) }), - onDelete: (p) => eventBus.emit({ event: EVENTS.permissionDeleted, data: formatPermission(p) }), - }); - ws.directories.setHooks({ - onInsert: (d) => eventBus.emit({ event: EVENTS.directoryCreated, data: formatDirectory(d) }), - onUpdate: (d) => eventBus.emit({ event: EVENTS.directoryUpdated, data: formatDirectory(d) }), - onDelete: (d) => eventBus.emit({ event: EVENTS.directoryDeleted, data: formatDirectory(d) }), - }); - ws.directoryUsers.setHooks({ - onInsert: (u) => eventBus.emit({ event: EVENTS.directoryUserCreated, data: formatDirectoryUser(u) }), - onUpdate: (u) => eventBus.emit({ event: EVENTS.directoryUserUpdated, data: formatDirectoryUser(u) }), - onDelete: (u) => eventBus.emit({ event: EVENTS.directoryUserDeleted, data: formatDirectoryUser(u) }), - }); - ws.directoryGroups.setHooks({ - onInsert: (g) => eventBus.emit({ event: EVENTS.directoryGroupCreated, data: formatDirectoryGroup(g) }), - onUpdate: (g) => eventBus.emit({ event: EVENTS.directoryGroupUpdated, data: formatDirectoryGroup(g) }), - onDelete: (g) => eventBus.emit({ event: EVENTS.directoryGroupDeleted, data: formatDirectoryGroup(g) }), - }); - ws.webhookEndpoints.setHooks({ - onInsert: () => eventBus.rebuildIndex(), - onUpdate: () => eventBus.rebuildIndex(), - onDelete: () => eventBus.rebuildIndex(), - }); - }, - seed(_store: Store, _baseUrl: string): void { - // No default seed data — users provide their own via seedFromConfig - }, -}; - -export default workosPlugin; diff --git a/src/emulate/workos/role-helpers.ts b/src/emulate/workos/role-helpers.ts deleted file mode 100644 index 086aea86..00000000 --- a/src/emulate/workos/role-helpers.ts +++ /dev/null @@ -1,167 +0,0 @@ -import type { Context } from 'hono'; -import { type RouteContext, 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'; -import { formatRole, formatPermission, formatListResponse } from './helpers.js'; - -export function findEnvRole(ws: WorkOSStore, slug: string): WorkOSRole | undefined { - return ws.roles.findBy('slug', slug).find((r) => r.type === 'EnvironmentRole'); -} - -export function findOrgRole(ws: WorkOSStore, orgId: string, slug: string): WorkOSRole | undefined { - return ws.roles.findBy('organization_id', orgId).find((r) => r.slug === slug && r.type === 'OrganizationRole'); -} - -export function requireEnvRole(ws: WorkOSStore, slug: string): WorkOSRole { - const role = findEnvRole(ws, slug); - if (!role) throw notFound('Role'); - return role; -} - -export function requireOrgRole(ws: WorkOSStore, orgId: string, slug: string): WorkOSRole { - const role = findOrgRole(ws, orgId, slug); - if (!role) throw notFound('Role'); - return role; -} - -export function getRolePermissions(ws: WorkOSStore, roleId: string): WorkOSPermission[] { - const rps = ws.rolePermissions.findBy('role_id', roleId); - 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 - 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 }); - } - - return getRolePermissions(ws, roleId); -} - -export interface RoleRouteConfig { - pathPrefix: string; - roleType: 'EnvironmentRole' | 'OrganizationRole'; - requireRole: (ws: WorkOSStore, c: Context) => WorkOSRole; - findRole: (ws: WorkOSStore, c: Context, slug: string) => WorkOSRole | undefined; - listFilter: (c: Context) => (r: WorkOSRole) => boolean; - insertDefaults: (c: Context) => Partial; - duplicateMessage: string; - validateBeforeCreate?: (ws: WorkOSStore, c: Context) => void; -} - -export function registerRoleRoutes(ctx: RouteContext, config: RoleRouteConfig): void { - const { app, store } = ctx; - const ws = getWorkOSStore(store); - const { pathPrefix } = config; - - app.post(pathPrefix, async (c) => { - config.validateBeforeCreate?.(ws, c); - - const body = await parseJsonBody(c); - const slug = body.slug as string; - const name = body.name as string; - - if (!slug || typeof slug !== 'string') { - throw validationError('slug is required', [{ field: 'slug', code: 'required' }]); - } - if (!name || typeof name !== 'string') { - throw validationError('name is required', [{ field: 'name', code: 'required' }]); - } - - const existing = config.findRole(ws, c, slug); - if (existing) { - throw validationError(config.duplicateMessage, [{ field: 'slug', code: 'duplicate' }]); - } - - const defaults = config.insertDefaults(c); - const role = ws.roles.insert({ - object: 'role', - slug, - name, - description: (body.description as string) ?? null, - type: config.roleType, - organization_id: defaults.organization_id ?? null, - is_default_role: Boolean(body.is_default_role), - priority: typeof body.priority === 'number' ? body.priority : 0, - }); - - return c.json(formatRole(role), 201); - }); - - app.get(pathPrefix, (c) => { - const url = new URL(c.req.url); - const params = parseListParams(url); - - const result = ws.roles.list({ - ...params, - filter: config.listFilter(c), - }); - - return c.json(formatListResponse(result, formatRole)); - }); - - app.get(`${pathPrefix}/:slug`, (c) => { - const role = config.requireRole(ws, c); - return c.json(formatRole(role)); - }); - - app.put(`${pathPrefix}/:slug`, async (c) => { - const role = config.requireRole(ws, c); - - const body = await parseJsonBody(c); - const updates: Record = {}; - if ('name' in body) updates.name = body.name; - if ('description' in body) updates.description = body.description ?? null; - if ('is_default_role' in body) updates.is_default_role = Boolean(body.is_default_role); - if ('priority' in body) updates.priority = body.priority; - - const updated = ws.roles.update(role.id, updates); - return c.json(formatRole(updated!)); - }); - - app.delete(`${pathPrefix}/:slug`, (c) => { - const role = config.requireRole(ws, c); - - ws.rolePermissions.deleteBy('role_id', role.id); - ws.roleAssignments.deleteBy('role_id', role.id); - - ws.roles.delete(role.id); - return c.body(null, 204); - }); - - // Role permissions management - app.get(`${pathPrefix}/:slug/permissions`, (c) => { - const role = config.requireRole(ws, c); - const permissions = getRolePermissions(ws, role.id); - - 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 permissionSlugs = body.permissions as string[]; - if (!Array.isArray(permissionSlugs)) { - throw validationError('permissions must be an array of slugs', [{ field: 'permissions', code: 'invalid' }]); - } - - const permissions = replaceRolePermissions(ws, role.id, permissionSlugs); - - return c.json({ - object: 'list', - data: permissions.map((p) => formatPermission(p)), - list_metadata: { before: null, after: null }, - }); - }); -} diff --git a/src/emulate/workos/routes/api-keys.spec.ts b/src/emulate/workos/routes/api-keys.spec.ts deleted file mode 100644 index 21d0e17d..00000000 --- a/src/emulate/workos/routes/api-keys.spec.ts +++ /dev/null @@ -1,74 +0,0 @@ -import { describe, it, expect, beforeEach } from 'vitest'; -import { createServer, type ApiKeyMap } from '../../core/index.js'; -import { workosPlugin } from '../index.js'; -import { getWorkOSStore } from '../store.js'; -import type { Store } from '../../core/index.js'; - -const apiKeys: ApiKeyMap = { sk_test_org: { environment: 'test' }, sk_live_key: { environment: 'production' } }; -const headers = { Authorization: 'Bearer sk_test_org', 'Content-Type': 'application/json' }; - -function createTestApp() { - return createServer(workosPlugin, { port: 0, baseUrl: 'http://localhost:0', apiKeys }); -} - -describe('API Keys routes', () => { - let app: ReturnType['app']; - let store: Store; - - beforeEach(() => { - const server = createTestApp(); - app = server.app; - store = server.store; - }); - - const req = (path: string, init?: RequestInit) => app.request(path, { headers, ...init }); - const json = (res: Response) => res.json() as Promise; - - it('validates a known API key', async () => { - const res = await req('/api_keys/validations', { - method: 'POST', - body: JSON.stringify({ key: 'sk_test_org' }), - }); - expect(res.status).toBe(200); - expect((await json(res)).valid).toBe(true); - }); - - it('rejects an unknown API key', async () => { - const res = await req('/api_keys/validations', { - method: 'POST', - body: JSON.stringify({ key: 'sk_unknown' }), - }); - expect(res.status).toBe(200); - expect((await json(res)).valid).toBe(false); - }); - - it('deletes an API key record', async () => { - const ws = getWorkOSStore(store); - const record = ws.apiKeyRecords.insert({ - object: 'api_key', - name: 'test-key', - key: 'sk_test_deletable', - environment: 'test', - }); - - const res = await req(`/api_keys/${record.id}`, { method: 'DELETE' }); - expect(res.status).toBe(204); - }); - - it('returns 404 for nonexistent API key', async () => { - const res = await req('/api_keys/api_key_nonexistent', { method: 'DELETE' }); - expect(res.status).toBe(404); - }); - - it('lists API key records', async () => { - const ws = getWorkOSStore(store); - ws.apiKeyRecords.insert({ object: 'api_key', name: 'key-1', key: 'sk_1', environment: 'test' }); - ws.apiKeyRecords.insert({ object: 'api_key', name: 'key-2', key: 'sk_2', environment: 'test' }); - - const res = await req('/organizations/org_123/api_keys'); - expect(res.status).toBe(200); - const list = await json(res); - expect(list.object).toBe('list'); - expect(list.data).toHaveLength(2); - }); -}); diff --git a/src/emulate/workos/routes/api-keys.ts b/src/emulate/workos/routes/api-keys.ts deleted file mode 100644 index b699b1de..00000000 --- a/src/emulate/workos/routes/api-keys.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { type RouteContext, notFound, parseJsonBody, parseListParams } from '../../core/index.js'; -import { getWorkOSStore } from '../store.js'; -import { formatApiKeyRecord, formatListResponse } from '../helpers.js'; -import type { ApiKeyMap } from '../../core/index.js'; -import { STORE_KEYS } from '../constants.js'; - -export function apiKeyRoutes(ctx: RouteContext): void { - const { app, store } = ctx; - const ws = getWorkOSStore(store); - - // Validate an API key - app.post('/api_keys/validations', async (c) => { - const body = await parseJsonBody(c); - const key = body.key as string | undefined; - const apiKeyMap = store.getData(STORE_KEYS.apiKeyMap) ?? {}; - const valid = !!key && key in apiKeyMap; - return c.json({ valid }); - }); - - // Delete an API key record - app.delete('/api_keys/:id', (c) => { - const record = ws.apiKeyRecords.get(c.req.param('id')); - if (!record) throw notFound('ApiKey'); - ws.apiKeyRecords.delete(record.id); - return c.body(null, 204); - }); - - // List API keys for an organization - app.get('/organizations/:orgId/api_keys', (c) => { - const url = new URL(c.req.url); - const params = parseListParams(url); - const result = ws.apiKeyRecords.list({ ...params }); - return c.json(formatListResponse(result, formatApiKeyRecord)); - }); -} diff --git a/src/emulate/workos/routes/audit-logs.spec.ts b/src/emulate/workos/routes/audit-logs.spec.ts deleted file mode 100644 index b66c82a8..00000000 --- a/src/emulate/workos/routes/audit-logs.spec.ts +++ /dev/null @@ -1,114 +0,0 @@ -import { describe, it, expect, beforeEach } from 'vitest'; -import { createServer, type ApiKeyMap } from '../../core/index.js'; -import { workosPlugin } from '../index.js'; - -const apiKeys: ApiKeyMap = { sk_test_org: { environment: 'test' } }; -const headers = { Authorization: 'Bearer sk_test_org', 'Content-Type': 'application/json' }; - -function createTestApp() { - return createServer(workosPlugin, { port: 0, baseUrl: 'http://localhost:0', apiKeys }); -} - -describe('Audit Logs routes', () => { - let app: ReturnType['app']; - - beforeEach(() => { - app = createTestApp().app; - }); - - const req = (path: string, init?: RequestInit) => app.request(path, { headers, ...init }); - const json = (res: Response) => res.json() as Promise; - - it('creates an action schema', async () => { - const res = await req('/audit_logs/actions/user.login/schemas', { - method: 'POST', - body: JSON.stringify({ type: 'object', properties: {} }), - }); - expect(res.status).toBe(201); - const action = await json(res); - expect(action.object).toBe('audit_log_action'); - expect(action.name).toBe('user.login'); - }); - - it('lists actions', async () => { - await req('/audit_logs/actions/user.login/schemas', { - method: 'POST', - body: JSON.stringify({}), - }); - - const res = await req('/audit_logs/actions'); - expect(res.status).toBe(200); - const list = await json(res); - expect(list.object).toBe('list'); - expect(list.data).toHaveLength(1); - }); - - it('creates an audit log event', async () => { - const res = await req('/audit_logs/events', { - method: 'POST', - body: JSON.stringify({ - organization_id: 'org_123', - action: { name: 'user.login', type: 'C' }, - actor: { type: 'user', id: 'user_1' }, - targets: [{ type: 'team', id: 'team_1' }], - }), - }); - expect(res.status).toBe(201); - const event = await json(res); - expect(event.object).toBe('audit_log_event'); - expect(event.action.name).toBe('user.login'); - expect(event.organization_id).toBe('org_123'); - }); - - it('rejects event without organization_id', async () => { - const res = await req('/audit_logs/events', { - method: 'POST', - body: JSON.stringify({ action: { name: 'test' } }), - }); - expect(res.status).toBe(422); - }); - - it('creates an export (auto-ready)', async () => { - const res = await req('/audit_logs/exports', { - method: 'POST', - body: JSON.stringify({ organization_id: 'org_123' }), - }); - expect(res.status).toBe(201); - const exp = await json(res); - expect(exp.object).toBe('audit_log_export'); - expect(exp.state).toBe('ready'); - expect(exp.url).toBeDefined(); - }); - - it('gets an export by id', async () => { - const createRes = await req('/audit_logs/exports', { - method: 'POST', - body: JSON.stringify({ organization_id: 'org_123' }), - }); - const created = await json(createRes); - - const res = await req(`/audit_logs/exports/${created.id}`); - expect(res.status).toBe(200); - expect((await json(res)).state).toBe('ready'); - }); - - it('returns 404 for nonexistent export', async () => { - const res = await req('/audit_logs/exports/audit_export_nonexistent'); - expect(res.status).toBe(404); - }); - - it('returns org audit log configuration', async () => { - const res = await req('/organizations/org_123/audit_log_configuration'); - expect(res.status).toBe(200); - const data = await json(res); - expect(data.enabled).toBe(true); - expect(data.retention_days).toBe(365); - }); - - it('returns org audit logs retention', async () => { - const res = await req('/organizations/org_123/audit_logs_retention'); - expect(res.status).toBe(200); - const data = await json(res); - expect(data.retention_days).toBe(365); - }); -}); diff --git a/src/emulate/workos/routes/audit-logs.ts b/src/emulate/workos/routes/audit-logs.ts deleted file mode 100644 index 0721fc75..00000000 --- a/src/emulate/workos/routes/audit-logs.ts +++ /dev/null @@ -1,117 +0,0 @@ -import { type RouteContext, notFound, parseJsonBody, validationError, parseListParams } from '../../core/index.js'; -import { getWorkOSStore } from '../store.js'; -import { formatAuditLogAction, formatAuditLogEvent, formatAuditLogExport, formatListResponse } from '../helpers.js'; -import { STORE_KEY_PREFIXES } from '../constants.js'; - -export function auditLogRoutes(ctx: RouteContext): void { - const { app, store } = ctx; - const ws = getWorkOSStore(store); - - // List actions - app.get('/audit_logs/actions', (c) => { - const url = new URL(c.req.url); - const params = parseListParams(url); - const result = ws.auditLogActions.list({ ...params }); - return c.json(formatListResponse(result, formatAuditLogAction)); - }); - - // Create/update action schema - app.post('/audit_logs/actions/:actionName/schemas', async (c) => { - const actionName = c.req.param('actionName'); - const body = await parseJsonBody(c); - - // Upsert: find existing action or create new one - let action = ws.auditLogActions.findOneBy('name', actionName); - if (action) { - // Store schema in store data keyed by action name - store.setData(`${STORE_KEY_PREFIXES.auditSchema}${actionName}`, body); - return c.json(formatAuditLogAction(action)); - } - - action = ws.auditLogActions.insert({ - object: 'audit_log_action', - name: actionName, - description: null, - condition: null, - }); - store.setData(`${STORE_KEY_PREFIXES.auditSchema}${actionName}`, body); - return c.json(formatAuditLogAction(action), 201); - }); - - // Create audit log event - app.post('/audit_logs/events', async (c) => { - const body = await parseJsonBody(c); - const organizationId = body.organization_id as string | undefined; - if (!organizationId) { - throw validationError('organization_id is required', [{ field: 'organization_id', code: 'required' }]); - } - - const actionBody = body.action as Record | undefined; - if (!actionBody?.name) { - throw validationError('action.name is required', [{ field: 'action.name', code: 'required' }]); - } - - const event = ws.auditLogEvents.insert({ - object: 'audit_log_event', - organization_id: organizationId, - action: { - name: actionBody.name, - type: actionBody.type ?? 'C', - id: actionBody.id ?? actionBody.name, - }, - actor: (body.actor as Record) ?? {}, - targets: (body.targets as Array>) ?? [], - metadata: (body.metadata as Record) ?? null, - occurred_at: (body.occurred_at as string) ?? new Date().toISOString(), - }); - - return c.json(formatAuditLogEvent(event), 201); - }); - - // Create export (auto-transition to ready) - app.post('/audit_logs/exports', async (c) => { - const body = await parseJsonBody(c); - const organizationId = body.organization_id as string | undefined; - if (!organizationId) { - throw validationError('organization_id is required', [{ field: 'organization_id', code: 'required' }]); - } - - const exp = ws.auditLogExports.insert({ - object: 'audit_log_export', - organization_id: organizationId, - state: 'ready', - url: `https://emulator.workos.test/exports/audit_log_export_mock.csv`, - filters: (body.filters as Record) ?? {}, - }); - - return c.json(formatAuditLogExport(exp), 201); - }); - - // Get export - app.get('/audit_logs/exports/:id', (c) => { - const exp = ws.auditLogExports.get(c.req.param('id')); - if (!exp) throw notFound('AuditLogExport'); - return c.json(formatAuditLogExport(exp)); - }); - - // Get org audit log configuration - app.get('/organizations/:id/audit_log_configuration', (c) => { - const orgId = c.req.param('id'); - return c.json({ - object: 'audit_log_configuration', - organization_id: orgId, - enabled: true, - retention_days: 365, - }); - }); - - // Get org audit logs retention - app.get('/organizations/:id/audit_logs_retention', (c) => { - const orgId = c.req.param('id'); - return c.json({ - object: 'audit_logs_retention', - organization_id: orgId, - retention_days: 365, - }); - }); -} diff --git a/src/emulate/workos/routes/auth-challenges.spec.ts b/src/emulate/workos/routes/auth-challenges.spec.ts deleted file mode 100644 index 6497eeaf..00000000 --- a/src/emulate/workos/routes/auth-challenges.spec.ts +++ /dev/null @@ -1,136 +0,0 @@ -import { describe, it, expect, beforeEach } from 'vitest'; -import { createServer, type ApiKeyMap } from '../../core/index.js'; -import { workosPlugin } from '../index.js'; -import { getWorkOSStore } from '../store.js'; -import type { Store } from '../../core/index.js'; - -const apiKeys: ApiKeyMap = { sk_test_mfa: { environment: 'test' } }; -const headers = { Authorization: 'Bearer sk_test_mfa', 'Content-Type': 'application/json' }; - -function createTestApp() { - return createServer(workosPlugin, { port: 0, baseUrl: 'http://localhost:0', apiKeys }); -} - -describe('Auth challenge routes', () => { - let app: ReturnType['app']; - let store: Store; - - beforeEach(() => { - const server = createTestApp(); - app = server.app; - store = server.store; - }); - - const req = (path: string, init?: RequestInit) => app.request(path, { headers, ...init }); - const json = (res: Response) => res.json() as Promise; - - function seedUserWithFactor() { - const ws = getWorkOSStore(store); - const user = ws.users.insert({ - object: 'user', - email: 'mfa@test.com', - first_name: null, - last_name: null, - email_verified: false, - profile_picture_url: null, - last_sign_in_at: null, - external_id: null, - metadata: {}, - locale: null, - password_hash: null, - impersonator: null, - }); - const factor = ws.authFactors.insert({ - object: 'authentication_factor', - user_id: user.id, - type: 'totp', - totp: { issuer: 'Test', user: user.email, uri: 'otpauth://totp/test' }, - }); - return { user, factor }; - } - - it('creates a challenge for a factor', async () => { - const { factor } = seedUserWithFactor(); - - const res = await req(`/user_management/auth_factors/${factor.id}/challenges`, { - method: 'POST', - body: JSON.stringify({}), - }); - expect(res.status).toBe(201); - const body = await json(res); - expect(body.object).toBe('authentication_challenge'); - expect(body.factor_id).toBe(factor.id); - }); - - it('verifies a challenge with correct code', async () => { - const { factor } = seedUserWithFactor(); - const ws = getWorkOSStore(store); - - // Create a challenge directly - const challenge = ws.authChallenges.insert({ - object: 'authentication_challenge', - user_id: factor.user_id, - factor_id: factor.id, - expires_at: new Date(Date.now() + 600000).toISOString(), - code: '999999', - }); - - const res = await req(`/user_management/auth_challenges/${challenge.id}/verify`, { - method: 'POST', - body: JSON.stringify({ code: '999999' }), - }); - expect(res.status).toBe(200); - const body = await json(res); - expect(body.valid).toBe(true); - }); - - it('rejects invalid code', async () => { - const { factor } = seedUserWithFactor(); - const ws = getWorkOSStore(store); - - const challenge = ws.authChallenges.insert({ - object: 'authentication_challenge', - user_id: factor.user_id, - factor_id: factor.id, - expires_at: new Date(Date.now() + 600000).toISOString(), - code: '111111', - }); - - const res = await req(`/user_management/auth_challenges/${challenge.id}/verify`, { - method: 'POST', - body: JSON.stringify({ code: '000000' }), - }); - expect(res.status).toBe(400); - const body = await json(res); - expect(body.code).toBe('invalid_one_time_code'); - }); - - it('rejects expired challenge', async () => { - const { factor } = seedUserWithFactor(); - const ws = getWorkOSStore(store); - - const challenge = ws.authChallenges.insert({ - object: 'authentication_challenge', - user_id: factor.user_id, - factor_id: factor.id, - expires_at: new Date(Date.now() - 1000).toISOString(), // expired - code: '123456', - }); - - const res = await req(`/user_management/auth_challenges/${challenge.id}/verify`, { - method: 'POST', - body: JSON.stringify({ code: '123456' }), - }); - expect(res.status).toBe(400); - const body = await json(res); - expect(body.code).toBe('expired_challenge'); - }); - - it('returns 404 for nonexistent factor', async () => { - const res = await req('/user_management/auth_factors/auth_factor_bogus/challenges', { - method: 'POST', - body: JSON.stringify({}), - }); - expect(res.status).toBe(404); - }); -}); diff --git a/src/emulate/workos/routes/auth-challenges.ts b/src/emulate/workos/routes/auth-challenges.ts deleted file mode 100644 index 970e8cfe..00000000 --- a/src/emulate/workos/routes/auth-challenges.ts +++ /dev/null @@ -1,59 +0,0 @@ -import { type RouteContext, notFound, parseJsonBody, WorkOSApiError } from '../../core/index.js'; -import { getWorkOSStore } from '../store.js'; -import { formatAuthChallenge, expiresIn, isExpired, generateCode } from '../helpers.js'; - -export function authChallengeRoutes(ctx: RouteContext): void { - const { app, store } = ctx; - const ws = getWorkOSStore(store); - - app.post('/user_management/auth_factors/:id/challenges', async (c) => { - const factorId = c.req.param('id'); - const factor = ws.authFactors.get(factorId); - if (!factor) throw notFound('AuthenticationFactor'); - - const user = ws.users.get(factor.user_id); - if (!user) throw notFound('User'); - - // Emulator generates a code and stores it for verification - const code = generateCode(); - - const challenge = ws.authChallenges.insert({ - object: 'authentication_challenge', - user_id: user.id, - factor_id: factor.id, - expires_at: expiresIn(10), - code, - }); - - return c.json(formatAuthChallenge(challenge), 201); - }); - - app.post('/user_management/auth_challenges/:id/verify', async (c) => { - const challengeId = c.req.param('id'); - const challenge = ws.authChallenges.get(challengeId); - if (!challenge) throw notFound('AuthenticationChallenge'); - - if (isExpired(challenge.expires_at)) { - ws.authChallenges.delete(challenge.id); - throw new WorkOSApiError(400, 'Challenge has expired', 'expired_challenge'); - } - - const body = await parseJsonBody(c); - const code = body.code as string; - if (!code) { - throw new WorkOSApiError(400, 'code is required', 'invalid_request'); - } - - // In the emulator, accept the stored code or any 6-digit code for convenience - if (challenge.code && code !== challenge.code) { - throw new WorkOSApiError(400, 'Invalid one-time code', 'invalid_one_time_code'); - } - - ws.authChallenges.delete(challenge.id); - - return c.json({ - challenge: formatAuthChallenge(challenge), - valid: true, - }); - }); -} diff --git a/src/emulate/workos/routes/auth-factors.ts b/src/emulate/workos/routes/auth-factors.ts deleted file mode 100644 index ec9e9f1f..00000000 --- a/src/emulate/workos/routes/auth-factors.ts +++ /dev/null @@ -1,56 +0,0 @@ -import { type RouteContext, notFound, parseJsonBody } from '../../core/index.js'; -import { getWorkOSStore } from '../store.js'; -import { formatAuthFactor } from '../helpers.js'; -import { randomBytes } from 'node:crypto'; - -export function authFactorRoutes(ctx: RouteContext): void { - const { app, store } = ctx; - const ws = getWorkOSStore(store); - - app.post('/user_management/users/:userlandUserId/auth_factors', async (c) => { - const userId = c.req.param('userlandUserId'); - const user = ws.users.get(userId); - if (!user) throw notFound('User'); - - const body = await parseJsonBody(c); - const type = (body.type as string) ?? 'totp'; - const issuer = (body.totp_issuer as string) ?? 'WorkOS Emulator'; - const secret = randomBytes(20).toString('hex').slice(0, 32).toUpperCase(); - const uri = `otpauth://totp/${encodeURIComponent(issuer)}:${encodeURIComponent(user.email)}?secret=${secret}&issuer=${encodeURIComponent(issuer)}`; - - const factor = ws.authFactors.insert({ - object: 'authentication_factor', - user_id: user.id, - type: type as 'totp', - totp: { - issuer, - user: user.email, - uri, - }, - }); - - return c.json(formatAuthFactor(factor), 201); - }); - - app.get('/user_management/users/:userlandUserId/auth_factors', (c) => { - const userId = c.req.param('userlandUserId'); - const user = ws.users.get(userId); - if (!user) throw notFound('User'); - - const factors = ws.authFactors.findBy('user_id', user.id); - return c.json({ - object: 'list', - data: factors.map(formatAuthFactor), - list_metadata: { before: null, after: null }, - }); - }); - - app.delete('/user_management/auth_factors/:id', (c) => { - const factorId = c.req.param('id'); - const factor = ws.authFactors.get(factorId); - if (!factor) throw notFound('AuthenticationFactor'); - - ws.authFactors.delete(factor.id); - return c.body(null, 204); - }); -} diff --git a/src/emulate/workos/routes/auth.spec.ts b/src/emulate/workos/routes/auth.spec.ts deleted file mode 100644 index a7327ba8..00000000 --- a/src/emulate/workos/routes/auth.spec.ts +++ /dev/null @@ -1,477 +0,0 @@ -import { describe, it, expect, beforeEach } from 'vitest'; -import { createServer, type ApiKeyMap } from '../../core/index.js'; -import { workosPlugin } from '../index.js'; -import { getWorkOSStore } from '../store.js'; -import type { Store } from '../../core/index.js'; - -const apiKeys: ApiKeyMap = { sk_test_auth: { environment: 'test' } }; -const headers = { Authorization: 'Bearer sk_test_auth', 'Content-Type': 'application/json' }; - -function createTestApp() { - return createServer(workosPlugin, { port: 0, baseUrl: 'http://localhost:0', apiKeys }); -} - -describe('Auth routes', () => { - let app: ReturnType['app']; - let store: Store; - - beforeEach(() => { - const server = createTestApp(); - app = server.app; - store = server.store; - }); - - const req = (path: string, init?: RequestInit) => app.request(path, { headers, ...init }); - const json = (res: Response) => res.json() as Promise; - - async function createUser( - email: string, - opts?: { password?: string; impersonator?: { email: string; reason: string } }, - ) { - const ws = getWorkOSStore(store); - return ws.users.insert({ - object: 'user', - email, - first_name: null, - last_name: null, - email_verified: false, - profile_picture_url: null, - last_sign_in_at: null, - external_id: null, - metadata: {}, - locale: null, - password_hash: null, - impersonator: opts?.impersonator ?? null, - }); - } - - it('authorize redirects with code when user exists', async () => { - await req('/user_management/users', { - method: 'POST', - body: JSON.stringify({ email: 'auth@test.com' }), - }); - - const res = await app.request( - '/user_management/authorize?redirect_uri=http://localhost:3000/callback&response_type=code&state=mystate', - ); - expect(res.status).toBe(302); - const location = res.headers.get('location')!; - const url = new URL(location); - expect(url.searchParams.get('code')).toBeTruthy(); - expect(url.searchParams.get('state')).toBe('mystate'); - }); - - it('authenticate with password grant', async () => { - await req('/user_management/users', { - method: 'POST', - body: JSON.stringify({ email: 'pass@test.com', password: 'secret' }), - }); - - const res = await app.request('/user_management/authenticate', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - grant_type: 'password', - email: 'pass@test.com', - password: 'secret', - }), - }); - expect(res.status).toBe(200); - const body = await json(res); - expect(body.access_token).toBeDefined(); - expect(body.user.email).toBe('pass@test.com'); - expect(body.authentication_method).toBe('Password'); - }); - - it('rejects invalid password', async () => { - await req('/user_management/users', { - method: 'POST', - body: JSON.stringify({ email: 'bad@test.com', password: 'correct' }), - }); - - const res = await app.request('/user_management/authenticate', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - grant_type: 'password', - email: 'bad@test.com', - password: 'wrong', - }), - }); - expect(res.status).toBe(401); - }); - - it('authorization_code grant flow', async () => { - await req('/user_management/users', { - method: 'POST', - body: JSON.stringify({ email: 'code@test.com' }), - }); - - const authRes = await app.request( - '/user_management/authorize?redirect_uri=http://localhost:3000/callback&response_type=code', - ); - const location = authRes.headers.get('location')!; - const code = new URL(location).searchParams.get('code')!; - - const tokenRes = await app.request('/user_management/authenticate', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - grant_type: 'authorization_code', - code, - }), - }); - expect(tokenRes.status).toBe(200); - const body = await json(tokenRes); - expect(body.access_token).toBeDefined(); - expect(body.authentication_method).toBe('OAuth'); - }); - - it('authorize rejects non-localhost redirect_uri', async () => { - const res = await app.request( - '/user_management/authorize?redirect_uri=https://evil.example.com/callback&response_type=code', - ); - expect(res.status).toBe(400); - const body = await json(res); - expect(body.code).toBe('invalid_redirect_uri'); - }); - - it('authorize allows 127.0.0.1 redirect_uri', async () => { - await req('/user_management/users', { - method: 'POST', - body: JSON.stringify({ email: 'ip@test.com' }), - }); - - const res = await app.request( - '/user_management/authorize?redirect_uri=http://127.0.0.1:5000/callback&response_type=code', - ); - expect(res.status).toBe(302); - }); - - // --- login_hint tests --- - - it('authorize with login_hint selects correct user', async () => { - await createUser('first@test.com'); - await createUser('second@test.com'); - - const res = await app.request( - '/user_management/authorize?redirect_uri=http://localhost:3000/callback&login_hint=second@test.com', - ); - expect(res.status).toBe(302); - const location = res.headers.get('location')!; - const code = new URL(location).searchParams.get('code')!; - - // Exchange code and verify the correct user - const tokenRes = await app.request('/user_management/authenticate', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ grant_type: 'authorization_code', code }), - }); - const body = await json(tokenRes); - expect(body.user.email).toBe('second@test.com'); - }); - - it('authorize with unknown login_hint redirects with error', async () => { - await createUser('exists@test.com'); - - const res = await app.request( - '/user_management/authorize?redirect_uri=http://localhost:3000/callback&login_hint=nope@test.com&state=s1', - ); - expect(res.status).toBe(302); - const location = res.headers.get('location')!; - const url = new URL(location); - expect(url.searchParams.get('error')).toBe('user_not_found'); - expect(url.searchParams.get('state')).toBe('s1'); - }); - - // --- Refresh token tests --- - - it('refresh_token grant returns new tokens and invalidates old', async () => { - await req('/user_management/users', { - method: 'POST', - body: JSON.stringify({ email: 'refresh@test.com', password: 'pw' }), - }); - - // Authenticate to get a refresh token - const authRes = await app.request('/user_management/authenticate', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ grant_type: 'password', email: 'refresh@test.com', password: 'pw' }), - }); - const authBody = await json(authRes); - const oldRefresh = authBody.refresh_token; - expect(oldRefresh).toBeDefined(); - - // Use refresh token - const refreshRes = await app.request('/user_management/authenticate', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ grant_type: 'refresh_token', refresh_token: oldRefresh }), - }); - expect(refreshRes.status).toBe(200); - const refreshBody = await json(refreshRes); - expect(refreshBody.access_token).toBeDefined(); - expect(refreshBody.refresh_token).toBeDefined(); - expect(refreshBody.refresh_token).not.toBe(oldRefresh); - - // Old refresh token should be invalidated (rotation) - const retryRes = await app.request('/user_management/authenticate', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ grant_type: 'refresh_token', refresh_token: oldRefresh }), - }); - expect(retryRes.status).toBe(400); - const retryBody = await json(retryRes); - expect(retryBody.code).toBe('invalid_grant'); - }); - - it('rejects invalid refresh token', async () => { - const res = await app.request('/user_management/authenticate', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ grant_type: 'refresh_token', refresh_token: 'bogus_token' }), - }); - expect(res.status).toBe(400); - const body = await json(res); - expect(body.code).toBe('invalid_grant'); - }); - - // --- Impersonation tests --- - - it('includes impersonator in response when configured', async () => { - await createUser('target@test.com', { - impersonator: { email: 'admin@test.com', reason: 'debugging' }, - }); - - // Authorize + authenticate to get the response - const authRes = await app.request('/user_management/authorize?redirect_uri=http://localhost:3000/callback'); - const code = new URL(authRes.headers.get('location')!).searchParams.get('code')!; - const tokenRes = await app.request('/user_management/authenticate', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ grant_type: 'authorization_code', code }), - }); - const body = await json(tokenRes); - expect(body.impersonator).toEqual({ email: 'admin@test.com', reason: 'debugging' }); - }); - - it('omits impersonator when not configured', async () => { - await createUser('normal@test.com'); - - const authRes = await app.request('/user_management/authorize?redirect_uri=http://localhost:3000/callback'); - const code = new URL(authRes.headers.get('location')!).searchParams.get('code')!; - const tokenRes = await app.request('/user_management/authenticate', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ grant_type: 'authorization_code', code }), - }); - const body = await json(tokenRes); - expect(body.impersonator).toBeUndefined(); - }); - - // --- Sealed session tests --- - - it('returns sealed_session when client_secret provided', async () => { - await req('/user_management/users', { - method: 'POST', - body: JSON.stringify({ email: 'sealed@test.com', password: 'pw' }), - }); - - const res = await app.request('/user_management/authenticate', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - grant_type: 'password', - email: 'sealed@test.com', - password: 'pw', - client_secret: 'sk_test_secret', - }), - }); - const body = await json(res); - expect(body.sealed_session).toBeTruthy(); - expect(typeof body.sealed_session).toBe('string'); - }); - - // --- Grant type alias tests --- - - it('accepts new magic-auth:code grant type alias', async () => { - await req('/user_management/users', { - method: 'POST', - body: JSON.stringify({ email: 'magic@test.com' }), - }); - - // Create magic auth - const magicRes = await req('/user_management/magic_auth', { - method: 'POST', - body: JSON.stringify({ email: 'magic@test.com' }), - }); - const magicBody = await json(magicRes); - - const res = await app.request('/user_management/authenticate', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - grant_type: 'urn:workos:oauth:grant-type:magic-auth:code', - code: magicBody.code, - email: 'magic@test.com', - }), - }); - expect(res.status).toBe(200); - const body = await json(res); - expect(body.authentication_method).toBe('MagicAuth'); - }); - - // --- Device code tests --- - - it('device authorization + device_code grant flow', async () => { - await createUser('device@test.com'); - - // Create device authorization - const deviceRes = await req('/user_management/authorize/device', { - method: 'POST', - body: JSON.stringify({ client_id: 'test_client' }), - }); - expect(deviceRes.status).toBe(200); - const deviceBody = await json(deviceRes); - expect(deviceBody.device_code).toBeDefined(); - expect(deviceBody.user_code).toBeDefined(); - - // Exchange device code (auto-approved in emulator) - const tokenRes = await app.request('/user_management/authenticate', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - grant_type: 'urn:ietf:params:oauth:grant-type:device_code', - device_code: deviceBody.device_code, - }), - }); - expect(tokenRes.status).toBe(200); - const tokenBody = await json(tokenRes); - expect(tokenBody.access_token).toBeDefined(); - expect(tokenBody.user.email).toBe('device@test.com'); - }); - - // --- Organization selection grant tests --- - - it('organization-selection grant scopes session to selected org', async () => { - const user = await createUser('orgsel@test.com'); - const ws = getWorkOSStore(store); - const org = ws.organizations.insert({ - object: 'organization', - name: 'Test Org', - external_id: null, - metadata: {}, - stripe_customer_id: null, - }); - - // Create a pending auth token - const pendingToken = 'pending_test_token'; - store.setData(`pending_auth:${pendingToken}`, { - user_id: user.id, - organization_id: null, - auth_method: 'Password', - }); - - const res = await app.request('/user_management/authenticate', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - grant_type: 'urn:workos:oauth:grant-type:organization-selection', - pending_authentication_token: pendingToken, - organization_id: org.id, - }), - }); - expect(res.status).toBe(200); - const body = await json(res); - expect(body.organization_id).toBe(org.id); - expect(body.user.email).toBe('orgsel@test.com'); - }); - - // --- MFA TOTP grant tests --- - - it('mfa-totp grant with valid code succeeds', async () => { - const user = await createUser('mfa@test.com'); - const ws = getWorkOSStore(store); - - // Create an auth factor - const factor = ws.authFactors.insert({ - object: 'authentication_factor', - user_id: user.id, - type: 'totp', - totp: { issuer: 'Test', user: user.email, uri: 'otpauth://...' }, - }); - - // Create a challenge - const challenge = ws.authChallenges.insert({ - object: 'authentication_challenge', - user_id: user.id, - factor_id: factor.id, - expires_at: new Date(Date.now() + 600000).toISOString(), - code: '123456', - }); - - // Create pending auth - const pendingToken = 'pending_mfa_token'; - store.setData(`pending_auth:${pendingToken}`, { - user_id: user.id, - organization_id: null, - auth_method: 'MFA', - }); - - const res = await app.request('/user_management/authenticate', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - grant_type: 'urn:workos:oauth:grant-type:mfa-totp', - code: '123456', - pending_authentication_token: pendingToken, - authentication_challenge_id: challenge.id, - }), - }); - expect(res.status).toBe(200); - const body = await json(res); - expect(body.access_token).toBeDefined(); - expect(body.authentication_method).toBe('MFA'); - }); - - it('mfa-totp grant with invalid code returns error', async () => { - const user = await createUser('mfa2@test.com'); - const ws = getWorkOSStore(store); - - const factor = ws.authFactors.insert({ - object: 'authentication_factor', - user_id: user.id, - type: 'totp', - totp: { issuer: 'Test', user: user.email, uri: 'otpauth://...' }, - }); - - const challenge = ws.authChallenges.insert({ - object: 'authentication_challenge', - user_id: user.id, - factor_id: factor.id, - expires_at: new Date(Date.now() + 600000).toISOString(), - code: '123456', - }); - - const pendingToken = 'pending_mfa_bad'; - store.setData(`pending_auth:${pendingToken}`, { - user_id: user.id, - organization_id: null, - auth_method: 'MFA', - }); - - const res = await app.request('/user_management/authenticate', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - grant_type: 'urn:workos:oauth:grant-type:mfa-totp', - code: '000000', - pending_authentication_token: pendingToken, - authentication_challenge_id: challenge.id, - }), - }); - expect(res.status).toBe(400); - const body = await json(res); - expect(body.code).toBe('invalid_one_time_code'); - }); -}); diff --git a/src/emulate/workos/routes/auth.ts b/src/emulate/workos/routes/auth.ts deleted file mode 100644 index b7cd14c8..00000000 --- a/src/emulate/workos/routes/auth.ts +++ /dev/null @@ -1,423 +0,0 @@ -import { createHash } from 'node:crypto'; -import { type RouteContext, notFound, parseJsonBody, WorkOSApiError, generateId } from '../../core/index.js'; -import { getWorkOSStore } from '../store.js'; -import { - formatUser, - formatDeviceAuthorization, - verifyPassword, - isExpired, - expiresIn, - assertLocalRedirectUri, - sealSession, -} from '../helpers.js'; -import type { EventBus } from '../event-bus.js'; -import { STORE_KEYS, STORE_KEY_PREFIXES } from '../constants.js'; - -interface PendingAuth { - user_id: string; - organization_id: string | null; - auth_method: string; -} - -export function authRoutes(ctx: RouteContext): void { - const { app, store, jwt } = ctx; - const ws = getWorkOSStore(store); - - app.get('/user_management/authorize', (c) => { - const url = new URL(c.req.url); - const redirectUri = url.searchParams.get('redirect_uri'); - const state = url.searchParams.get('state'); - const codeChallenge = url.searchParams.get('code_challenge'); - const codeChallengeMethod = url.searchParams.get('code_challenge_method'); - const loginHint = url.searchParams.get('login_hint'); - - if (!redirectUri) { - throw new WorkOSApiError(400, 'redirect_uri is required', 'invalid_request'); - } - assertLocalRedirectUri(redirectUri); - - let user; - if (loginHint) { - user = ws.users.findOneBy('email', loginHint); - if (!user) { - const redirect = new URL(redirectUri); - redirect.searchParams.set('error', 'user_not_found'); - if (state) redirect.searchParams.set('state', state); - return c.redirect(redirect.toString()); - } - } else { - const users = ws.users.all(); - user = users[0]; - } - - if (!user) { - const redirect = new URL(redirectUri); - redirect.searchParams.set('error', 'no_users'); - if (state) redirect.searchParams.set('state', state); - return c.redirect(redirect.toString()); - } - - const authCode = ws.authCodes.insert({ - user_id: user.id, - organization_id: null, - code: generateId('auth_code'), - redirect_uri: redirectUri, - expires_at: expiresIn(10), - code_challenge: codeChallenge ?? null, - code_challenge_method: codeChallengeMethod ?? null, - }); - - const redirect = new URL(redirectUri); - redirect.searchParams.set('code', authCode.code); - if (state) redirect.searchParams.set('state', state); - return c.redirect(redirect.toString()); - }); - - // Device authorization endpoint - app.post('/user_management/authorize/device', async (c) => { - const body = await parseJsonBody(c); - const clientId = body.client_id as string; - if (!clientId) { - throw new WorkOSApiError(400, 'client_id is required', 'invalid_request'); - } - - // Auto-approve with first user for emulator convenience - const users = ws.users.all(); - const user = users[0] ?? null; - - const deviceAuth = ws.deviceAuthorizations.insert({ - device_code: generateId('dev_code'), - user_code: Math.random().toString(36).slice(2, 10).toUpperCase(), - user_id: user?.id ?? null, - client_id: clientId, - expires_at: expiresIn(15), - interval: 5, - }); - - return c.json(formatDeviceAuthorization(deviceAuth)); - }); - - // AuthKit SDK uses /x/authkit/users/authenticate for the same flow - const authenticateHandler = async (c: any) => { - const body = await parseJsonBody(c); - const grantType = body.grant_type as string | undefined; - const clientId = body.client_id as string | undefined; - const clientSecret = body.client_secret as string | undefined; - - if (!grantType) { - throw new WorkOSApiError(400, 'grant_type is required', 'invalid_request'); - } - - let user; - let organizationId: string | null = null; - let authMethod: string; - - switch (grantType) { - case 'authorization_code': { - const code = body.code as string; - if (!code) throw new WorkOSApiError(400, 'code is required', 'invalid_request'); - - const authCode = ws.authCodes.findOneBy('code', code); - if (!authCode) throw new WorkOSApiError(400, 'Invalid code', 'invalid_code'); - if (isExpired(authCode.expires_at)) { - throw new WorkOSApiError(400, 'Code has expired', 'expired_code'); - } - - if (authCode.code_challenge) { - const codeVerifier = body.code_verifier as string; - if (!codeVerifier) { - throw new WorkOSApiError(400, 'code_verifier is required', 'invalid_request'); - } - const method = authCode.code_challenge_method ?? 'S256'; - let challenge: string; - if (method === 'S256') { - challenge = createHash('sha256').update(codeVerifier).digest('base64url'); - } else { - challenge = codeVerifier; - } - if (challenge !== authCode.code_challenge) { - throw new WorkOSApiError(400, 'Invalid code_verifier', 'invalid_code_verifier'); - } - } - - user = ws.users.get(authCode.user_id); - organizationId = authCode.organization_id; - ws.authCodes.delete(authCode.id); - authMethod = 'OAuth'; - break; - } - - case 'password': { - const email = body.email as string; - const password = body.password as string; - if (!email || !password) { - throw new WorkOSApiError(400, 'email and password are required', 'invalid_request'); - } - - user = ws.users.findOneBy('email', email); - if (!user || !user.password_hash || !verifyPassword(password, user.password_hash)) { - throw new WorkOSApiError(401, 'Invalid credentials', 'invalid_credentials'); - } - authMethod = 'Password'; - break; - } - - // Accept both old and new grant type names for magic-auth - case 'urn:workos:oauth:grant-type:magic-auth': - case 'urn:workos:oauth:grant-type:magic-auth:code': { - const code = body.code as string; - const email = body.email as string; - if (!code || !email) { - throw new WorkOSApiError(400, 'code and email are required', 'invalid_request'); - } - - const magicAuth = ws.magicAuths.all().find((ma) => ma.code === code && ma.email === email); - if (!magicAuth) { - throw new WorkOSApiError(400, 'Invalid code', 'invalid_code'); - } - if (isExpired(magicAuth.expires_at)) { - throw new WorkOSApiError(400, 'Code has expired', 'expired_code'); - } - - user = ws.users.get(magicAuth.user_id); - ws.magicAuths.delete(magicAuth.id); - authMethod = 'MagicAuth'; - break; - } - - // Accept both old and new grant type names for email-verification - case 'urn:workos:oauth:grant-type:email-verification': - case 'urn:workos:oauth:grant-type:email-verification:code': { - const code = body.code as string; - const userId = body.user_id as string; - if (!code || !userId) { - throw new WorkOSApiError(400, 'code and user_id are required', 'invalid_request'); - } - - const ev = ws.emailVerifications.findBy('user_id', userId).find((v) => v.code === code); - if (!ev) { - throw new WorkOSApiError(400, 'Invalid code', 'invalid_code'); - } - if (isExpired(ev.expires_at)) { - throw new WorkOSApiError(400, 'Code has expired', 'expired_code'); - } - - ws.users.update(userId, { email_verified: true }); - ws.emailVerifications.delete(ev.id); - user = ws.users.get(userId); - authMethod = 'EmailVerification'; - break; - } - - case 'refresh_token': { - const token = body.refresh_token as string; - if (!token) { - throw new WorkOSApiError(400, 'refresh_token is required', 'invalid_request'); - } - - const refreshToken = ws.refreshTokens.findOneBy('token', token); - if (!refreshToken) { - throw new WorkOSApiError(400, 'Invalid refresh token', 'invalid_grant'); - } - if (isExpired(refreshToken.expires_at)) { - ws.refreshTokens.delete(refreshToken.id); - throw new WorkOSApiError(400, 'Refresh token has expired', 'invalid_grant'); - } - - user = ws.users.get(refreshToken.user_id); - // Allow body.organization_id to switch org context (switchToOrganization) - organizationId = (body.organization_id as string) ?? refreshToken.organization_id; - - // Rotate: delete old, issue new below - ws.refreshTokens.delete(refreshToken.id); - authMethod = 'OAuth'; - break; - } - - case 'urn:workos:oauth:grant-type:mfa-totp': { - const code = body.code as string; - const pendingToken = body.pending_authentication_token as string; - const challengeId = body.authentication_challenge_id as string; - - if (!code || !pendingToken || !challengeId) { - throw new WorkOSApiError( - 400, - 'code, pending_authentication_token, and authentication_challenge_id are required', - 'invalid_request', - ); - } - - const pending = store.getData(`${STORE_KEY_PREFIXES.pendingAuth}${pendingToken}`); - if (!pending) { - throw new WorkOSApiError(400, 'Invalid pending authentication token', 'invalid_pending_authentication_token'); - } - - const challenge = ws.authChallenges.get(challengeId); - if (!challenge) { - throw new WorkOSApiError(400, 'Invalid authentication challenge', 'invalid_request'); - } - if (isExpired(challenge.expires_at)) { - ws.authChallenges.delete(challenge.id); - throw new WorkOSApiError(400, 'Challenge has expired', 'expired_challenge'); - } - - // Verify code against the challenge's stored code - if (challenge.code && code !== challenge.code) { - throw new WorkOSApiError(400, 'Invalid one-time code', 'invalid_one_time_code'); - } - - ws.authChallenges.delete(challenge.id); - store.setData(`${STORE_KEY_PREFIXES.pendingAuth}${pendingToken}`, undefined); - - user = ws.users.get(pending.user_id); - organizationId = pending.organization_id; - authMethod = 'MFA'; - break; - } - - case 'urn:workos:oauth:grant-type:organization-selection': { - const pendingToken = body.pending_authentication_token as string; - const orgId = body.organization_id as string; - - if (!pendingToken || !orgId) { - throw new WorkOSApiError( - 400, - 'pending_authentication_token and organization_id are required', - 'invalid_request', - ); - } - - const pending = store.getData(`${STORE_KEY_PREFIXES.pendingAuth}${pendingToken}`); - if (!pending) { - throw new WorkOSApiError(400, 'Invalid pending authentication token', 'invalid_pending_authentication_token'); - } - - const org = ws.organizations.get(orgId); - if (!org) throw notFound('Organization'); - - store.setData(`${STORE_KEY_PREFIXES.pendingAuth}${pendingToken}`, undefined); - - user = ws.users.get(pending.user_id); - organizationId = orgId; - authMethod = pending.auth_method; - break; - } - - case 'urn:ietf:params:oauth:grant-type:device_code': { - const deviceCode = body.device_code as string; - if (!deviceCode) { - throw new WorkOSApiError(400, 'device_code is required', 'invalid_request'); - } - - const deviceAuth = ws.deviceAuthorizations.findOneBy('device_code', deviceCode); - if (!deviceAuth) { - throw new WorkOSApiError(400, 'Invalid device code', 'invalid_grant'); - } - if (isExpired(deviceAuth.expires_at)) { - ws.deviceAuthorizations.delete(deviceAuth.id); - throw new WorkOSApiError(400, 'Device code has expired', 'expired_token'); - } - if (!deviceAuth.user_id) { - throw new WorkOSApiError(400, 'Authorization pending', 'authorization_pending'); - } - - user = ws.users.get(deviceAuth.user_id); - ws.deviceAuthorizations.delete(deviceAuth.id); - authMethod = 'OAuth'; - break; - } - - default: - throw new WorkOSApiError(400, `Unsupported grant_type: ${grantType}`, 'invalid_request'); - } - - if (!user) throw notFound('User'); - - ws.users.update(user.id, { last_sign_in_at: new Date().toISOString() }); - const updatedUser = ws.users.get(user.id)!; - - const session = ws.sessions.insert({ - object: 'session', - user_id: user.id, - organization_id: organizationId, - ip_address: c.req.header('x-forwarded-for') ?? null, - user_agent: c.req.header('user-agent') ?? null, - }); - - // Resolve role + permissions for org-scoped sessions - let roleSlug: string | undefined; - let permissionSlugs: string[] | undefined; - if (organizationId) { - const membership = ws.organizationMemberships - .findBy('organization_id', organizationId) - .find((m) => m.user_id === user.id); - if (membership) { - roleSlug = membership.role.slug; - const role = ws.roles - .findBy('slug', membership.role.slug) - .find((r) => r.organization_id === organizationId || r.type === 'EnvironmentRole'); - if (role) { - const rps = ws.rolePermissions.findBy('role_id', role.id); - permissionSlugs = rps - .map((rp) => ws.permissions.get(rp.permission_id)) - .filter(Boolean) - .map((p) => p!.slug); - } - } - } - - const accessToken = jwt.sign({ - sub: user.id, - sid: session.id, - org_id: organizationId ?? undefined, - role: roleSlug, - permissions: permissionSlugs, - aud: clientId ?? 'workos-emulate', - }); - - // Store a real refresh token - const newRefreshToken = ws.refreshTokens.insert({ - token: generateId('ref'), - user_id: user.id, - organization_id: organizationId, - session_id: session.id, - expires_at: expiresIn(30 * 24 * 60), // 30 days - }); - - // Compute sealed session when client_secret is provided - const apiKey = c.req - .header('Authorization') - ?.replace(/^Bearer\s+/i, '') - .trim(); - const sealKey = clientSecret ?? apiKey; - const sealedSession = sealKey - ? sealSession( - { access_token: accessToken, refresh_token: newRefreshToken.token, session_id: session.id }, - sealKey, - ) - : null; - - // Emit authentication event (hybrid Option B for action-specific events) - const eventBus = store.getData(STORE_KEYS.eventBus); - if (eventBus) { - const authEventType = `authentication.${authMethod.toLowerCase()}_succeeded`; - eventBus.emit({ - event: authEventType, - data: { user_id: user.id, email: updatedUser.email, method: authMethod, ip_address: session.ip_address }, - }); - } - - return c.json({ - user: formatUser(updatedUser), - organization_id: organizationId, - access_token: accessToken, - refresh_token: newRefreshToken.token, - authentication_method: authMethod, - sealed_session: sealedSession, - impersonator: updatedUser.impersonator ?? undefined, - }); - }; - - app.post('/user_management/authenticate', authenticateHandler); - app.post('/x/authkit/users/authenticate', authenticateHandler); -} diff --git a/src/emulate/workos/routes/authorization-checks.spec.ts b/src/emulate/workos/routes/authorization-checks.spec.ts deleted file mode 100644 index 421bf7a9..00000000 --- a/src/emulate/workos/routes/authorization-checks.spec.ts +++ /dev/null @@ -1,190 +0,0 @@ -import { describe, it, expect, beforeEach } from 'vitest'; -import { createServer, type ApiKeyMap } from '../../core/index.js'; -import { workosPlugin } from '../index.js'; - -const apiKeys: ApiKeyMap = { sk_test_check: { environment: 'test' } }; -const headers = { Authorization: 'Bearer sk_test_check', 'Content-Type': 'application/json' }; - -function createTestApp() { - return createServer(workosPlugin, { port: 0, baseUrl: 'http://localhost:0', apiKeys }); -} - -describe('Authorization check + role assignment routes', () => { - let app: ReturnType['app']; - - beforeEach(() => { - app = createTestApp().app; - }); - - const req = (path: string, init?: RequestInit) => app.request(path, { headers, ...init }); - const json = (res: Response) => res.json() as Promise; - - async function setup() { - // Create user - const userRes = await req('/user_management/users', { - method: 'POST', - body: JSON.stringify({ email: 'check@test.com' }), - }); - const user = await json(userRes); - - // Create org - const orgRes = await req('/organizations', { - method: 'POST', - body: JSON.stringify({ name: 'Check Org' }), - }); - const org = await json(orgRes); - - // Create membership with role_slug 'editor' - const memRes = await req('/user_management/organization_memberships', { - method: 'POST', - body: JSON.stringify({ organization_id: org.id, user_id: user.id, role_slug: 'editor' }), - }); - const membership = await json(memRes); - - // Create permissions - await req('/authorization/permissions', { - method: 'POST', - body: JSON.stringify({ slug: 'posts:read', name: 'Read Posts' }), - }); - await req('/authorization/permissions', { - method: 'POST', - body: JSON.stringify({ slug: 'posts:write', name: 'Write Posts' }), - }); - await req('/authorization/permissions', { - method: 'POST', - body: JSON.stringify({ slug: 'admin:manage', name: 'Admin Manage' }), - }); - - // Create environment role 'editor' with read+write permissions - await req('/authorization/roles', { - method: 'POST', - body: JSON.stringify({ slug: 'editor', name: 'Editor' }), - }); - await req('/authorization/roles/editor/permissions', { - method: 'POST', - body: JSON.stringify({ permissions: ['posts:read', 'posts:write'] }), - }); - - // Create environment role 'admin' with admin:manage - const adminRes = await req('/authorization/roles', { - method: 'POST', - body: JSON.stringify({ slug: 'admin-role', name: 'Admin' }), - }); - const adminRole = await json(adminRes); - await req('/authorization/roles/admin-role/permissions', { - method: 'POST', - body: JSON.stringify({ permissions: ['admin:manage'] }), - }); - - return { user, org, membership, adminRole }; - } - - it('returns authorized true when membership has permission via primary role', async () => { - const { membership } = await setup(); - const res = await req(`/authorization/organization_memberships/${membership.id}/check`, { - method: 'POST', - body: JSON.stringify({ permission: 'posts:read' }), - }); - expect(res.status).toBe(200); - const body = await json(res); - expect(body.authorized).toBe(true); - }); - - it('returns authorized false when permission not assigned', async () => { - const { membership } = await setup(); - const res = await req(`/authorization/organization_memberships/${membership.id}/check`, { - method: 'POST', - body: JSON.stringify({ permission: 'admin:manage' }), - }); - const body = await json(res); - expect(body.authorized).toBe(false); - }); - - it('returns authorized true via additional role assignment', async () => { - const { membership, adminRole } = await setup(); - - // Assign the admin role to the membership - await req(`/authorization/organization_memberships/${membership.id}/role_assignments`, { - method: 'POST', - body: JSON.stringify({ role_id: adminRole.id }), - }); - - // Now should have admin:manage - const res = await req(`/authorization/organization_memberships/${membership.id}/check`, { - method: 'POST', - body: JSON.stringify({ permission: 'admin:manage' }), - }); - const body = await json(res); - expect(body.authorized).toBe(true); - }); - - it('lists role assignments', async () => { - const { membership, adminRole } = await setup(); - - await req(`/authorization/organization_memberships/${membership.id}/role_assignments`, { - method: 'POST', - body: JSON.stringify({ role_id: adminRole.id }), - }); - - const res = await req(`/authorization/organization_memberships/${membership.id}/role_assignments`); - expect(res.status).toBe(200); - const body = await json(res); - expect(body.data.length).toBe(1); - expect(body.data[0].role_id).toBe(adminRole.id); - expect(body.data[0].organization_membership_id).toBe(membership.id); - }); - - it('deletes a role assignment', async () => { - const { membership, adminRole } = await setup(); - - const createRes = await req(`/authorization/organization_memberships/${membership.id}/role_assignments`, { - method: 'POST', - body: JSON.stringify({ role_id: adminRole.id }), - }); - const assignment = await json(createRes); - - const delRes = await req( - `/authorization/organization_memberships/${membership.id}/role_assignments/${assignment.id}`, - { method: 'DELETE' }, - ); - expect(delRes.status).toBe(204); - - // Verify it's gone - const listRes = await req(`/authorization/organization_memberships/${membership.id}/role_assignments`); - const body = await json(listRes); - expect(body.data.length).toBe(0); - }); - - it('lists resources accessible to membership', async () => { - const { membership, org } = await setup(); - - // Create a resource in the org - await req('/authorization/resources', { - method: 'POST', - body: JSON.stringify({ resource_type_slug: 'doc', external_id: 'res1', organization_id: org.id }), - }); - - const res = await req(`/authorization/organization_memberships/${membership.id}/resources`); - expect(res.status).toBe(200); - const body = await json(res); - expect(body.data.length).toBe(1); - expect(body.data[0].external_id).toBe('res1'); - }); - - it('returns 404 for nonexistent membership', async () => { - const res = await req('/authorization/organization_memberships/om_nonexistent/check', { - method: 'POST', - body: JSON.stringify({ permission: 'anything' }), - }); - expect(res.status).toBe(404); - }); - - it('requires permission field in check', async () => { - const { membership } = await setup(); - const res = await req(`/authorization/organization_memberships/${membership.id}/check`, { - method: 'POST', - body: JSON.stringify({}), - }); - expect(res.status).toBe(422); - }); -}); diff --git a/src/emulate/workos/routes/authorization-checks.ts b/src/emulate/workos/routes/authorization-checks.ts deleted file mode 100644 index 411114bd..00000000 --- a/src/emulate/workos/routes/authorization-checks.ts +++ /dev/null @@ -1,139 +0,0 @@ -import { type RouteContext, notFound, validationError, parseJsonBody, parseListParams } from '../../core/index.js'; -import { getWorkOSStore } from '../store.js'; -import { formatRoleAssignment, formatAuthorizationResource, formatListResponse } from '../helpers.js'; - -/** - * Gather all permission slugs for a given membership: - * 1. From the membership's role (role.slug field) - * 2. From any additional role assignments - */ -function getPermissionsForMembership(ws: ReturnType, membershipId: string): Set { - const membership = ws.organizationMemberships.get(membershipId); - if (!membership) return new Set(); - - const permSlugs = new Set(); - - // Permissions from the membership's primary role - const primaryRole = ws.roles - .findBy('slug', membership.role.slug) - .find((r) => r.organization_id === membership.organization_id || r.type === 'EnvironmentRole'); - if (primaryRole) { - const rps = ws.rolePermissions.findBy('role_id', primaryRole.id); - for (const rp of rps) { - const perm = ws.permissions.get(rp.permission_id); - if (perm) permSlugs.add(perm.slug); - } - } - - // Permissions from additional role assignments - const assignments = ws.roleAssignments.findBy('organization_membership_id', membershipId); - for (const assignment of assignments) { - const role = ws.roles.get(assignment.role_id); - if (!role) continue; - const rps = ws.rolePermissions.findBy('role_id', role.id); - for (const rp of rps) { - const perm = ws.permissions.get(rp.permission_id); - if (perm) permSlugs.add(perm.slug); - } - } - - return permSlugs; -} - -export function authorizationCheckRoutes(ctx: RouteContext): void { - const { app, store } = ctx; - const ws = getWorkOSStore(store); - - // Permission check - app.post('/authorization/organization_memberships/:id/check', async (c) => { - const membershipId = c.req.param('id'); - const membership = ws.organizationMemberships.get(membershipId); - if (!membership) throw notFound('OrganizationMembership'); - - const body = await parseJsonBody(c); - const permission = body.permission as string; - if (!permission) { - throw validationError('permission is required', [{ field: 'permission', code: 'required' }]); - } - - const permSlugs = getPermissionsForMembership(ws, membershipId); - return c.json({ authorized: permSlugs.has(permission) }); - }); - - // List resources accessible to a membership (all resources in the membership's org) - app.get('/authorization/organization_memberships/:id/resources', (c) => { - const membershipId = c.req.param('id'); - const membership = ws.organizationMemberships.get(membershipId); - if (!membership) throw notFound('OrganizationMembership'); - - const url = new URL(c.req.url); - const params = parseListParams(url); - - const result = ws.authorizationResources.list({ - ...params, - filter: (r) => r.organization_id === membership.organization_id, - }); - - return c.json(formatListResponse(result, formatAuthorizationResource)); - }); - - // List role assignments for a membership - app.get('/authorization/organization_memberships/:id/role_assignments', (c) => { - const membershipId = c.req.param('id'); - const membership = ws.organizationMemberships.get(membershipId); - if (!membership) throw notFound('OrganizationMembership'); - - const url = new URL(c.req.url); - const params = parseListParams(url); - - const result = ws.roleAssignments.list({ - ...params, - filter: (ra) => ra.organization_membership_id === membershipId, - }); - - return c.json(formatListResponse(result, formatRoleAssignment)); - }); - - // Create role assignment - app.post('/authorization/organization_memberships/:id/role_assignments', async (c) => { - const membershipId = c.req.param('id'); - const membership = ws.organizationMemberships.get(membershipId); - if (!membership) throw notFound('OrganizationMembership'); - - const body = await parseJsonBody(c); - const roleId = body.role_id as string; - if (!roleId) { - throw validationError('role_id is required', [{ field: 'role_id', code: 'required' }]); - } - - const role = ws.roles.get(roleId); - if (!role) throw notFound('Role'); - - const assignment = ws.roleAssignments.insert({ - object: 'role_assignment', - organization_membership_id: membershipId, - role_id: roleId, - }); - - return c.json(formatRoleAssignment(assignment), 201); - }); - - // Delete role assignment - app.delete('/authorization/organization_memberships/:id/role_assignments/:assignmentId', (c) => { - const membershipId = c.req.param('id'); - const assignmentId = c.req.param('assignmentId'); - - const membership = ws.organizationMemberships.get(membershipId); - if (!membership) throw notFound('OrganizationMembership'); - - const assignment = ws.roleAssignments.get(assignmentId); - if (!assignment || assignment.organization_membership_id !== membershipId) { - throw notFound('RoleAssignment'); - } - - ws.roleAssignments.delete(assignmentId); - return c.body(null, 204); - }); -} - -export { getPermissionsForMembership }; diff --git a/src/emulate/workos/routes/authorization-org-roles.spec.ts b/src/emulate/workos/routes/authorization-org-roles.spec.ts deleted file mode 100644 index 09870afc..00000000 --- a/src/emulate/workos/routes/authorization-org-roles.spec.ts +++ /dev/null @@ -1,194 +0,0 @@ -import { describe, it, expect, beforeEach } from 'vitest'; -import { createServer, type ApiKeyMap } from '../../core/index.js'; -import { workosPlugin } from '../index.js'; - -const apiKeys: ApiKeyMap = { sk_test_orgrole: { environment: 'test' } }; -const headers = { Authorization: 'Bearer sk_test_orgrole', 'Content-Type': 'application/json' }; - -function createTestApp() { - return createServer(workosPlugin, { port: 0, baseUrl: 'http://localhost:0', apiKeys }); -} - -describe('Authorization org role routes', () => { - let app: ReturnType['app']; - - beforeEach(() => { - app = createTestApp().app; - }); - - const req = (path: string, init?: RequestInit) => app.request(path, { headers, ...init }); - const json = (res: Response) => res.json() as Promise; - - async function createOrg(name: string) { - const res = await req('/organizations', { - method: 'POST', - body: JSON.stringify({ name }), - }); - return json(res); - } - - it('creates an org role', async () => { - const org = await createOrg('Test Org'); - const res = await req(`/authorization/organizations/${org.id}/roles`, { - method: 'POST', - body: JSON.stringify({ slug: 'org-admin', name: 'Org Admin' }), - }); - expect(res.status).toBe(201); - const role = await json(res); - expect(role.type).toBe('OrganizationRole'); - expect(role.organization_id).toBe(org.id); - expect(role.slug).toBe('org-admin'); - }); - - it('rejects duplicate slug within same org', async () => { - const org = await createOrg('Dup Org'); - await req(`/authorization/organizations/${org.id}/roles`, { - method: 'POST', - body: JSON.stringify({ slug: 'dup', name: 'Dup' }), - }); - const res = await req(`/authorization/organizations/${org.id}/roles`, { - method: 'POST', - body: JSON.stringify({ slug: 'dup', name: 'Dup 2' }), - }); - expect(res.status).toBe(422); - }); - - it('allows same slug in different orgs', async () => { - const org1 = await createOrg('Org1'); - const org2 = await createOrg('Org2'); - const res1 = await req(`/authorization/organizations/${org1.id}/roles`, { - method: 'POST', - body: JSON.stringify({ slug: 'shared', name: 'Shared' }), - }); - const res2 = await req(`/authorization/organizations/${org2.id}/roles`, { - method: 'POST', - body: JSON.stringify({ slug: 'shared', name: 'Shared' }), - }); - expect(res1.status).toBe(201); - expect(res2.status).toBe(201); - }); - - it('lists org roles scoped to org', async () => { - const org1 = await createOrg('List Org1'); - const org2 = await createOrg('List Org2'); - await req(`/authorization/organizations/${org1.id}/roles`, { - method: 'POST', - body: JSON.stringify({ slug: 'r1', name: 'R1' }), - }); - await req(`/authorization/organizations/${org2.id}/roles`, { - method: 'POST', - body: JSON.stringify({ slug: 'r2', name: 'R2' }), - }); - - const res = await req(`/authorization/organizations/${org1.id}/roles`); - const body = await json(res); - expect(body.data.length).toBe(1); - expect(body.data[0].slug).toBe('r1'); - }); - - it('gets an org role by slug', async () => { - const org = await createOrg('Get Org'); - await req(`/authorization/organizations/${org.id}/roles`, { - method: 'POST', - body: JSON.stringify({ slug: 'getter', name: 'Getter' }), - }); - const res = await req(`/authorization/organizations/${org.id}/roles/getter`); - expect(res.status).toBe(200); - const role = await json(res); - expect(role.slug).toBe('getter'); - }); - - it('updates an org role', async () => { - const org = await createOrg('Upd Org'); - await req(`/authorization/organizations/${org.id}/roles`, { - method: 'POST', - body: JSON.stringify({ slug: 'upd', name: 'Original' }), - }); - const res = await req(`/authorization/organizations/${org.id}/roles/upd`, { - method: 'PUT', - body: JSON.stringify({ name: 'Updated' }), - }); - expect(res.status).toBe(200); - const role = await json(res); - expect(role.name).toBe('Updated'); - }); - - it('deletes an org role', async () => { - const org = await createOrg('Del Org'); - await req(`/authorization/organizations/${org.id}/roles`, { - method: 'POST', - body: JSON.stringify({ slug: 'del', name: 'Del' }), - }); - const res = await req(`/authorization/organizations/${org.id}/roles/del`, { method: 'DELETE' }); - expect(res.status).toBe(204); - - const getRes = await req(`/authorization/organizations/${org.id}/roles/del`); - expect(getRes.status).toBe(404); - }); - - it('sets role priority ordering', async () => { - const org = await createOrg('Priority Org'); - await req(`/authorization/organizations/${org.id}/roles`, { - method: 'POST', - body: JSON.stringify({ slug: 'low', name: 'Low', priority: 99 }), - }); - await req(`/authorization/organizations/${org.id}/roles`, { - method: 'POST', - body: JSON.stringify({ slug: 'high', name: 'High', priority: 99 }), - }); - - const res = await req(`/authorization/organizations/${org.id}/roles/priority`, { - method: 'PUT', - body: JSON.stringify({ slugs: ['high', 'low'] }), - }); - expect(res.status).toBe(200); - const body = await json(res); - expect(body.data[0].slug).toBe('high'); - expect(body.data[0].priority).toBe(0); - expect(body.data[1].slug).toBe('low'); - expect(body.data[1].priority).toBe(1); - }); - - it('manages org role permissions', async () => { - const org = await createOrg('Perm Org'); - - // Create permissions - await req('/authorization/permissions', { - method: 'POST', - body: JSON.stringify({ slug: 'org-read', name: 'Read' }), - }); - await req('/authorization/permissions', { - method: 'POST', - body: JSON.stringify({ slug: 'org-write', name: 'Write' }), - }); - - // Create org role - await req(`/authorization/organizations/${org.id}/roles`, { - method: 'POST', - body: JSON.stringify({ slug: 'org-editor', name: 'Editor' }), - }); - - // Set permissions - await req(`/authorization/organizations/${org.id}/roles/org-editor/permissions`, { - method: 'POST', - body: JSON.stringify({ permissions: ['org-read', 'org-write'] }), - }); - - // Get permissions - const res = await req(`/authorization/organizations/${org.id}/roles/org-editor/permissions`); - const body = await json(res); - expect(body.data.length).toBe(2); - - // Remove one permission - const delRes = await req(`/authorization/organizations/${org.id}/roles/org-editor/permissions/org-write`, { - method: 'DELETE', - }); - expect(delRes.status).toBe(204); - - // Verify removal - const afterRes = await req(`/authorization/organizations/${org.id}/roles/org-editor/permissions`); - const afterBody = await json(afterRes); - expect(afterBody.data.length).toBe(1); - expect(afterBody.data[0].slug).toBe('org-read'); - }); -}); diff --git a/src/emulate/workos/routes/authorization-org-roles.ts b/src/emulate/workos/routes/authorization-org-roles.ts deleted file mode 100644 index f09c2d5d..00000000 --- a/src/emulate/workos/routes/authorization-org-roles.ts +++ /dev/null @@ -1,71 +0,0 @@ -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'; - -export function authorizationOrgRoleRoutes(ctx: RouteContext): void { - const { app, store } = ctx; - const ws = getWorkOSStore(store); - const prefix = '/authorization/organizations/:orgId/roles'; - - // Priority ordering — must be registered before :slug routes - app.put(`${prefix}/priority`, async (c) => { - const orgId = c.req.param('orgId'); - const body = await parseJsonBody(c); - const slugs = body.slugs as string[]; - - if (!Array.isArray(slugs)) { - throw validationError('slugs must be an array', [{ field: 'slugs', code: 'invalid' }]); - } - - // Fetch once, build slug map for O(1) lookups - const orgRoles = ws.roles.findBy('organization_id', orgId).filter((r) => r.type === 'OrganizationRole'); - const rolesBySlug = new Map(orgRoles.map((r) => [r.slug, r])); - - for (let i = 0; i < slugs.length; i++) { - const role = rolesBySlug.get(slugs[i]!); - if (!role) throw notFound('Role'); - ws.roles.update(role.id, { priority: i }); - } - - // Re-fetch for updated priority values - const updated = ws.roles - .findBy('organization_id', orgId) - .filter((r) => r.type === 'OrganizationRole') - .sort((a, b) => a.priority - b.priority); - - return c.json({ - object: 'list', - data: updated.map(formatRole), - list_metadata: { before: null, after: null }, - }); - }); - - registerRoleRoutes(ctx, { - pathPrefix: prefix, - roleType: 'OrganizationRole', - requireRole: (ws, c) => requireOrgRole(ws, c.req.param('orgId')!, c.req.param('slug')!), - findRole: (ws, c, slug) => findOrgRole(ws, c.req.param('orgId')!, slug), - 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', - validateBeforeCreate: (ws, c) => { - const org = ws.organizations.get(c.req.param('orgId')!); - if (!org) throw notFound('Organization'); - }, - }); - - // Org-specific: delete single permission from role - app.delete(`${prefix}/:slug/permissions/:permissionSlug`, (c) => { - const role = requireOrgRole(ws, c.req.param('orgId'), c.req.param('slug')); - - const perm = ws.permissions.findOneBy('slug', c.req.param('permissionSlug')); - if (!perm) throw notFound('Permission'); - - const rp = ws.rolePermissions.findBy('role_id', role.id).find((rp) => rp.permission_id === perm.id); - if (!rp) throw notFound('RolePermission'); - - ws.rolePermissions.delete(rp.id); - return c.body(null, 204); - }); -} diff --git a/src/emulate/workos/routes/authorization-permissions.spec.ts b/src/emulate/workos/routes/authorization-permissions.spec.ts deleted file mode 100644 index 4a4553fc..00000000 --- a/src/emulate/workos/routes/authorization-permissions.spec.ts +++ /dev/null @@ -1,137 +0,0 @@ -import { describe, it, expect, beforeEach } from 'vitest'; -import { createServer, type ApiKeyMap } from '../../core/index.js'; -import { workosPlugin } from '../index.js'; - -const apiKeys: ApiKeyMap = { sk_test_perm: { environment: 'test' } }; -const headers = { Authorization: 'Bearer sk_test_perm', 'Content-Type': 'application/json' }; - -function createTestApp() { - return createServer(workosPlugin, { port: 0, baseUrl: 'http://localhost:0', apiKeys }); -} - -describe('Authorization permission routes', () => { - let app: ReturnType['app']; - - beforeEach(() => { - app = createTestApp().app; - }); - - const req = (path: string, init?: RequestInit) => app.request(path, { headers, ...init }); - const json = (res: Response) => res.json() as Promise; - - it('creates a permission', async () => { - const res = await req('/authorization/permissions', { - method: 'POST', - body: JSON.stringify({ slug: 'posts:read', name: 'Read Posts' }), - }); - expect(res.status).toBe(201); - const perm = await json(res); - expect(perm.object).toBe('permission'); - expect(perm.slug).toBe('posts:read'); - expect(perm.name).toBe('Read Posts'); - expect(perm.id).toMatch(/^perm_/); - }); - - it('rejects duplicate slug', async () => { - await req('/authorization/permissions', { - method: 'POST', - body: JSON.stringify({ slug: 'dup', name: 'Dup' }), - }); - const res = await req('/authorization/permissions', { - method: 'POST', - body: JSON.stringify({ slug: 'dup', name: 'Dup 2' }), - }); - expect(res.status).toBe(422); - }); - - it('rejects missing slug', async () => { - const res = await req('/authorization/permissions', { - method: 'POST', - body: JSON.stringify({ name: 'No Slug' }), - }); - expect(res.status).toBe(422); - }); - - it('lists permissions', async () => { - await req('/authorization/permissions', { - method: 'POST', - body: JSON.stringify({ slug: 'a', name: 'A' }), - }); - await req('/authorization/permissions', { - method: 'POST', - body: JSON.stringify({ slug: 'b', name: 'B' }), - }); - const res = await req('/authorization/permissions'); - expect(res.status).toBe(200); - const body = await json(res); - expect(body.object).toBe('list'); - expect(body.data.length).toBe(2); - }); - - it('gets a permission by slug', async () => { - await req('/authorization/permissions', { - method: 'POST', - body: JSON.stringify({ slug: 'test-get', name: 'Test' }), - }); - const res = await req('/authorization/permissions/test-get'); - expect(res.status).toBe(200); - const perm = await json(res); - expect(perm.slug).toBe('test-get'); - }); - - it('returns 404 for unknown slug', async () => { - const res = await req('/authorization/permissions/nonexistent'); - expect(res.status).toBe(404); - }); - - it('updates a permission', async () => { - await req('/authorization/permissions', { - method: 'POST', - body: JSON.stringify({ slug: 'upd', name: 'Original' }), - }); - const res = await req('/authorization/permissions/upd', { - method: 'PUT', - body: JSON.stringify({ name: 'Updated', description: 'desc' }), - }); - expect(res.status).toBe(200); - const perm = await json(res); - expect(perm.name).toBe('Updated'); - expect(perm.description).toBe('desc'); - }); - - it('deletes a permission', async () => { - await req('/authorization/permissions', { - method: 'POST', - body: JSON.stringify({ slug: 'del', name: 'Del' }), - }); - const res = await req('/authorization/permissions/del', { method: 'DELETE' }); - expect(res.status).toBe(204); - - const getRes = await req('/authorization/permissions/del'); - expect(getRes.status).toBe(404); - }); - - it('cascade deletes permission from role-permission joins', async () => { - // Create permission + role + link - await req('/authorization/permissions', { - method: 'POST', - body: JSON.stringify({ slug: 'cascade-perm', name: 'Cascade' }), - }); - await req('/authorization/roles', { - method: 'POST', - body: JSON.stringify({ slug: 'cascade-role', name: 'Cascade Role' }), - }); - await req('/authorization/roles/cascade-role/permissions', { - method: 'POST', - body: JSON.stringify({ permissions: ['cascade-perm'] }), - }); - - // Delete the permission - await req('/authorization/permissions/cascade-perm', { method: 'DELETE' }); - - // Role should have no permissions now - const res = await req('/authorization/roles/cascade-role/permissions'); - const body = await json(res); - expect(body.data.length).toBe(0); - }); -}); diff --git a/src/emulate/workos/routes/authorization-permissions.ts b/src/emulate/workos/routes/authorization-permissions.ts deleted file mode 100644 index 1bfcc973..00000000 --- a/src/emulate/workos/routes/authorization-permissions.ts +++ /dev/null @@ -1,76 +0,0 @@ -import { type RouteContext, notFound, validationError, parseJsonBody, parseListParams } from '../../core/index.js'; -import { getWorkOSStore } from '../store.js'; -import { formatPermission, formatListResponse } from '../helpers.js'; - -export function authorizationPermissionRoutes(ctx: RouteContext): void { - const { app, store } = ctx; - const ws = getWorkOSStore(store); - - app.post('/authorization/permissions', async (c) => { - const body = await parseJsonBody(c); - const slug = body.slug as string; - const name = body.name as string; - - if (!slug || typeof slug !== 'string') { - throw validationError('slug is required', [{ field: 'slug', code: 'required' }]); - } - if (!name || typeof name !== 'string') { - throw validationError('name is required', [{ field: 'name', code: 'required' }]); - } - - const existing = ws.permissions.findOneBy('slug', slug); - if (existing) { - throw validationError('Permission with this slug already exists', [{ field: 'slug', code: 'duplicate' }]); - } - - const permission = ws.permissions.insert({ - object: 'permission', - slug, - name, - description: (body.description as string) ?? null, - }); - - return c.json(formatPermission(permission), 201); - }); - - app.get('/authorization/permissions', (c) => { - const url = new URL(c.req.url); - const params = parseListParams(url); - - const result = ws.permissions.list(params); - return c.json(formatListResponse(result, formatPermission)); - }); - - app.get('/authorization/permissions/:slug', (c) => { - const slug = c.req.param('slug'); - const permission = ws.permissions.findOneBy('slug', slug); - if (!permission) throw notFound('Permission'); - return c.json(formatPermission(permission)); - }); - - app.put('/authorization/permissions/:slug', async (c) => { - const slug = c.req.param('slug'); - const permission = ws.permissions.findOneBy('slug', slug); - if (!permission) throw notFound('Permission'); - - const body = await parseJsonBody(c); - const updates: Record = {}; - if ('name' in body) updates.name = body.name; - if ('description' in body) updates.description = body.description ?? null; - - const updated = ws.permissions.update(permission.id, updates); - return c.json(formatPermission(updated!)); - }); - - app.delete('/authorization/permissions/:slug', (c) => { - const slug = c.req.param('slug'); - const permission = ws.permissions.findOneBy('slug', slug); - if (!permission) throw notFound('Permission'); - - // Cascade: remove from all role-permission joins - ws.rolePermissions.deleteBy('permission_id', permission.id); - - ws.permissions.delete(permission.id); - return c.body(null, 204); - }); -} diff --git a/src/emulate/workos/routes/authorization-resources.spec.ts b/src/emulate/workos/routes/authorization-resources.spec.ts deleted file mode 100644 index fdf56de9..00000000 --- a/src/emulate/workos/routes/authorization-resources.spec.ts +++ /dev/null @@ -1,178 +0,0 @@ -import { describe, it, expect, beforeEach } from 'vitest'; -import { createServer, type ApiKeyMap } from '../../core/index.js'; -import { workosPlugin } from '../index.js'; - -const apiKeys: ApiKeyMap = { sk_test_res: { environment: 'test' } }; -const headers = { Authorization: 'Bearer sk_test_res', 'Content-Type': 'application/json' }; - -function createTestApp() { - return createServer(workosPlugin, { port: 0, baseUrl: 'http://localhost:0', apiKeys }); -} - -describe('Authorization resource routes', () => { - let app: ReturnType['app']; - - beforeEach(() => { - app = createTestApp().app; - }); - - const req = (path: string, init?: RequestInit) => app.request(path, { headers, ...init }); - const json = (res: Response) => res.json() as Promise; - - async function createOrg(name: string) { - const res = await req('/organizations', { - method: 'POST', - body: JSON.stringify({ name }), - }); - return json(res); - } - - it('creates a resource', async () => { - const org = await createOrg('Res Org'); - const res = await req('/authorization/resources', { - method: 'POST', - body: JSON.stringify({ - resource_type_slug: 'document', - external_id: 'doc-123', - organization_id: org.id, - }), - }); - expect(res.status).toBe(201); - const resource = await json(res); - expect(resource.object).toBe('authorization_resource'); - expect(resource.resource_type_slug).toBe('document'); - expect(resource.external_id).toBe('doc-123'); - expect(resource.organization_id).toBe(org.id); - expect(resource.id).toMatch(/^auth_res_/); - }); - - it('rejects missing required fields', async () => { - const res = await req('/authorization/resources', { - method: 'POST', - body: JSON.stringify({ resource_type_slug: 'document' }), - }); - expect(res.status).toBe(422); - }); - - it('lists resources', async () => { - const org = await createOrg('List Org'); - await req('/authorization/resources', { - method: 'POST', - body: JSON.stringify({ resource_type_slug: 'doc', external_id: '1', organization_id: org.id }), - }); - await req('/authorization/resources', { - method: 'POST', - body: JSON.stringify({ resource_type_slug: 'doc', external_id: '2', organization_id: org.id }), - }); - - const res = await req('/authorization/resources'); - const body = await json(res); - expect(body.object).toBe('list'); - expect(body.data.length).toBe(2); - }); - - it('filters resources by organization_id', async () => { - const org1 = await createOrg('Filter Org1'); - const org2 = await createOrg('Filter Org2'); - await req('/authorization/resources', { - method: 'POST', - body: JSON.stringify({ resource_type_slug: 'doc', external_id: '1', organization_id: org1.id }), - }); - await req('/authorization/resources', { - method: 'POST', - body: JSON.stringify({ resource_type_slug: 'doc', external_id: '2', organization_id: org2.id }), - }); - - const res = await req(`/authorization/resources?organization_id=${org1.id}`); - const body = await json(res); - expect(body.data.length).toBe(1); - expect(body.data[0].organization_id).toBe(org1.id); - }); - - it('gets a resource by id', async () => { - const org = await createOrg('Get Org'); - const createRes = await req('/authorization/resources', { - method: 'POST', - body: JSON.stringify({ resource_type_slug: 'doc', external_id: 'get1', organization_id: org.id }), - }); - const resource = await json(createRes); - - const res = await req(`/authorization/resources/${resource.id}`); - expect(res.status).toBe(200); - const fetched = await json(res); - expect(fetched.id).toBe(resource.id); - }); - - it('updates a resource', async () => { - const org = await createOrg('Upd Org'); - const createRes = await req('/authorization/resources', { - method: 'POST', - body: JSON.stringify({ resource_type_slug: 'doc', external_id: 'upd1', organization_id: org.id }), - }); - const resource = await json(createRes); - - const res = await req(`/authorization/resources/${resource.id}`, { - method: 'PUT', - body: JSON.stringify({ metadata: { key: 'value' } }), - }); - expect(res.status).toBe(200); - const updated = await json(res); - expect(updated.metadata).toEqual({ key: 'value' }); - }); - - it('deletes a resource', async () => { - const org = await createOrg('Del Org'); - const createRes = await req('/authorization/resources', { - method: 'POST', - body: JSON.stringify({ resource_type_slug: 'doc', external_id: 'del1', organization_id: org.id }), - }); - const resource = await json(createRes); - - const res = await req(`/authorization/resources/${resource.id}`, { method: 'DELETE' }); - expect(res.status).toBe(204); - - const getRes = await req(`/authorization/resources/${resource.id}`); - expect(getRes.status).toBe(404); - }); - - it('gets resource by type + external_id within org', async () => { - const org = await createOrg('TypeExt Org'); - await req('/authorization/resources', { - method: 'POST', - body: JSON.stringify({ resource_type_slug: 'project', external_id: 'proj-42', organization_id: org.id }), - }); - - const res = await req(`/authorization/organizations/${org.id}/resources/project/proj-42`); - expect(res.status).toBe(200); - const resource = await json(res); - expect(resource.resource_type_slug).toBe('project'); - expect(resource.external_id).toBe('proj-42'); - }); - - it('lists memberships for a resource', async () => { - const org = await createOrg('Mem Org'); - // Create a user and membership - const userRes = await req('/user_management/users', { - method: 'POST', - body: JSON.stringify({ email: 'member@test.com' }), - }); - const user = await json(userRes); - await req('/user_management/organization_memberships', { - method: 'POST', - body: JSON.stringify({ organization_id: org.id, user_id: user.id }), - }); - - // Create resource - const resCreate = await req('/authorization/resources', { - method: 'POST', - body: JSON.stringify({ resource_type_slug: 'doc', external_id: 'mem1', organization_id: org.id }), - }); - const resource = await json(resCreate); - - const res = await req(`/authorization/resources/${resource.id}/organization_memberships`); - expect(res.status).toBe(200); - const body = await json(res); - expect(body.data.length).toBe(1); - expect(body.data[0].user_id).toBe(user.id); - }); -}); diff --git a/src/emulate/workos/routes/authorization-resources.ts b/src/emulate/workos/routes/authorization-resources.ts deleted file mode 100644 index d78430da..00000000 --- a/src/emulate/workos/routes/authorization-resources.ts +++ /dev/null @@ -1,129 +0,0 @@ -import { type RouteContext, notFound, validationError, parseJsonBody, parseListParams } from '../../core/index.js'; -import { getWorkOSStore } from '../store.js'; -import { formatAuthorizationResource, formatMembership, formatListResponse } from '../helpers.js'; - -export function authorizationResourceRoutes(ctx: RouteContext): void { - const { app, store } = ctx; - const ws = getWorkOSStore(store); - - app.post('/authorization/resources', async (c) => { - const body = await parseJsonBody(c); - - const resourceTypeSlug = body.resource_type_slug as string; - const externalId = body.external_id as string; - const organizationId = body.organization_id as string; - - if (!resourceTypeSlug) { - throw validationError('resource_type_slug is required', [{ field: 'resource_type_slug', code: 'required' }]); - } - if (!externalId) { - throw validationError('external_id is required', [{ field: 'external_id', code: 'required' }]); - } - if (!organizationId) { - throw validationError('organization_id is required', [{ field: 'organization_id', code: 'required' }]); - } - - const resource = ws.authorizationResources.insert({ - object: 'authorization_resource', - resource_type_slug: resourceTypeSlug, - external_id: externalId, - organization_id: organizationId, - metadata: (body.metadata as Record) ?? {}, - }); - - return c.json(formatAuthorizationResource(resource), 201); - }); - - app.get('/authorization/resources', (c) => { - const url = new URL(c.req.url); - const params = parseListParams(url); - const organizationId = url.searchParams.get('organization_id') ?? undefined; - const resourceTypeSlug = url.searchParams.get('resource_type_slug') ?? undefined; - - const result = ws.authorizationResources.list({ - ...params, - filter: (r) => { - if (organizationId && r.organization_id !== organizationId) return false; - if (resourceTypeSlug && r.resource_type_slug !== resourceTypeSlug) return false; - return true; - }, - }); - - return c.json(formatListResponse(result, formatAuthorizationResource)); - }); - - app.get('/authorization/resources/:resource_id', (c) => { - const resourceId = c.req.param('resource_id'); - const resource = ws.authorizationResources.get(resourceId); - if (!resource) throw notFound('AuthorizationResource'); - return c.json(formatAuthorizationResource(resource)); - }); - - app.put('/authorization/resources/:resource_id', async (c) => { - const resourceId = c.req.param('resource_id'); - const resource = ws.authorizationResources.get(resourceId); - if (!resource) throw notFound('AuthorizationResource'); - - const body = await parseJsonBody(c); - const updates: Record = {}; - if ('metadata' in body) updates.metadata = body.metadata; - - const updated = ws.authorizationResources.update(resourceId, updates); - return c.json(formatAuthorizationResource(updated!)); - }); - - app.delete('/authorization/resources/:resource_id', (c) => { - const resourceId = c.req.param('resource_id'); - const resource = ws.authorizationResources.get(resourceId); - if (!resource) throw notFound('AuthorizationResource'); - - ws.authorizationResources.delete(resourceId); - return c.body(null, 204); - }); - - // Memberships with access to a resource (by resource ID) - app.get('/authorization/resources/:resource_id/organization_memberships', (c) => { - const resourceId = c.req.param('resource_id'); - const resource = ws.authorizationResources.get(resourceId); - if (!resource) throw notFound('AuthorizationResource'); - - const memberships = ws.organizationMemberships.findBy('organization_id', resource.organization_id); - return c.json({ - object: 'list', - data: memberships.map(formatMembership), - list_metadata: { before: null, after: null }, - }); - }); - - // Get resource by type + external ID within an org - app.get('/authorization/organizations/:orgId/resources/:type_slug/:external_id', (c) => { - const orgId = c.req.param('orgId'); - const typeSlug = c.req.param('type_slug'); - const externalId = c.req.param('external_id'); - - const resource = ws.authorizationResources - .findBy('organization_id', orgId) - .find((r) => r.resource_type_slug === typeSlug && r.external_id === externalId); - if (!resource) throw notFound('AuthorizationResource'); - return c.json(formatAuthorizationResource(resource)); - }); - - // Memberships for resource by type + external ID within an org - app.get('/authorization/organizations/:orgId/resources/:type_slug/:external_id/organization_memberships', (c) => { - const orgId = c.req.param('orgId'); - const typeSlug = c.req.param('type_slug'); - const externalId = c.req.param('external_id'); - - const resource = ws.authorizationResources - .findBy('organization_id', orgId) - .find((r) => r.resource_type_slug === typeSlug && r.external_id === externalId); - if (!resource) throw notFound('AuthorizationResource'); - - const memberships = ws.organizationMemberships.findBy('organization_id', resource.organization_id); - return c.json({ - object: 'list', - data: memberships.map(formatMembership), - list_metadata: { before: null, after: null }, - }); - }); -} diff --git a/src/emulate/workos/routes/authorization-roles.spec.ts b/src/emulate/workos/routes/authorization-roles.spec.ts deleted file mode 100644 index 486844f3..00000000 --- a/src/emulate/workos/routes/authorization-roles.spec.ts +++ /dev/null @@ -1,175 +0,0 @@ -import { describe, it, expect, beforeEach } from 'vitest'; -import { createServer, type ApiKeyMap } from '../../core/index.js'; -import { workosPlugin } from '../index.js'; - -const apiKeys: ApiKeyMap = { sk_test_role: { environment: 'test' } }; -const headers = { Authorization: 'Bearer sk_test_role', 'Content-Type': 'application/json' }; - -function createTestApp() { - return createServer(workosPlugin, { port: 0, baseUrl: 'http://localhost:0', apiKeys }); -} - -describe('Authorization environment role routes', () => { - let app: ReturnType['app']; - - beforeEach(() => { - app = createTestApp().app; - }); - - const req = (path: string, init?: RequestInit) => app.request(path, { headers, ...init }); - const json = (res: Response) => res.json() as Promise; - - it('creates an environment role', async () => { - const res = await req('/authorization/roles', { - method: 'POST', - body: JSON.stringify({ slug: 'admin', name: 'Admin' }), - }); - expect(res.status).toBe(201); - const role = await json(res); - expect(role.object).toBe('role'); - expect(role.slug).toBe('admin'); - expect(role.type).toBe('EnvironmentRole'); - expect(role.organization_id).toBeNull(); - expect(role.id).toMatch(/^role_/); - }); - - it('rejects duplicate slug', async () => { - await req('/authorization/roles', { - method: 'POST', - body: JSON.stringify({ slug: 'dup', name: 'Dup' }), - }); - const res = await req('/authorization/roles', { - method: 'POST', - body: JSON.stringify({ slug: 'dup', name: 'Dup 2' }), - }); - expect(res.status).toBe(422); - }); - - it('lists environment roles', async () => { - await req('/authorization/roles', { - method: 'POST', - body: JSON.stringify({ slug: 'r1', name: 'R1' }), - }); - await req('/authorization/roles', { - method: 'POST', - body: JSON.stringify({ slug: 'r2', name: 'R2' }), - }); - const res = await req('/authorization/roles'); - const body = await json(res); - expect(body.object).toBe('list'); - expect(body.data.length).toBe(2); - }); - - it('gets a role by slug', async () => { - await req('/authorization/roles', { - method: 'POST', - body: JSON.stringify({ slug: 'viewer', name: 'Viewer' }), - }); - const res = await req('/authorization/roles/viewer'); - expect(res.status).toBe(200); - const role = await json(res); - expect(role.slug).toBe('viewer'); - }); - - it('updates a role', async () => { - await req('/authorization/roles', { - method: 'POST', - body: JSON.stringify({ slug: 'upd', name: 'Original' }), - }); - const res = await req('/authorization/roles/upd', { - method: 'PUT', - body: JSON.stringify({ name: 'Updated', description: 'new desc' }), - }); - expect(res.status).toBe(200); - const role = await json(res); - expect(role.name).toBe('Updated'); - expect(role.description).toBe('new desc'); - }); - - it('deletes a role', async () => { - await req('/authorization/roles', { - method: 'POST', - body: JSON.stringify({ slug: 'del', name: 'Del' }), - }); - const res = await req('/authorization/roles/del', { method: 'DELETE' }); - expect(res.status).toBe(204); - - const getRes = await req('/authorization/roles/del'); - expect(getRes.status).toBe(404); - }); - - it('sets and gets role permissions', async () => { - // Create permissions - await req('/authorization/permissions', { - method: 'POST', - body: JSON.stringify({ slug: 'read', name: 'Read' }), - }); - await req('/authorization/permissions', { - method: 'POST', - body: JSON.stringify({ slug: 'write', name: 'Write' }), - }); - - // Create role - await req('/authorization/roles', { - method: 'POST', - body: JSON.stringify({ slug: 'editor', name: 'Editor' }), - }); - - // Set permissions - const setRes = await req('/authorization/roles/editor/permissions', { - method: 'POST', - body: JSON.stringify({ permissions: ['read', 'write'] }), - }); - expect(setRes.status).toBe(200); - const setBody = await json(setRes); - expect(setBody.data.length).toBe(2); - - // Get permissions - const getRes = await req('/authorization/roles/editor/permissions'); - const getBody = await json(getRes); - expect(getBody.data.length).toBe(2); - const slugs = getBody.data.map((p: any) => p.slug).sort(); - expect(slugs).toEqual(['read', 'write']); - }); - - it('replaces permissions on repeated set', async () => { - await req('/authorization/permissions', { - method: 'POST', - body: JSON.stringify({ slug: 'p1', name: 'P1' }), - }); - await req('/authorization/permissions', { - method: 'POST', - body: JSON.stringify({ slug: 'p2', name: 'P2' }), - }); - await req('/authorization/roles', { - method: 'POST', - body: JSON.stringify({ slug: 'rep', name: 'Rep' }), - }); - - // Set to p1 - await req('/authorization/roles/rep/permissions', { - method: 'POST', - body: JSON.stringify({ permissions: ['p1'] }), - }); - - // Replace with p2 - await req('/authorization/roles/rep/permissions', { - method: 'POST', - body: JSON.stringify({ permissions: ['p2'] }), - }); - - const res = await req('/authorization/roles/rep/permissions'); - const body = await json(res); - expect(body.data.length).toBe(1); - expect(body.data[0].slug).toBe('p2'); - }); - - it('creates role with default flag', async () => { - const res = await req('/authorization/roles', { - method: 'POST', - body: JSON.stringify({ slug: 'default-role', name: 'Default', is_default_role: true }), - }); - const role = await json(res); - expect(role.is_default_role).toBe(true); - }); -}); diff --git a/src/emulate/workos/routes/authorization-roles.ts b/src/emulate/workos/routes/authorization-roles.ts deleted file mode 100644 index 9feecd41..00000000 --- a/src/emulate/workos/routes/authorization-roles.ts +++ /dev/null @@ -1,14 +0,0 @@ -import type { RouteContext } from '../../core/index.js'; -import { findEnvRole, requireEnvRole, registerRoleRoutes } from '../role-helpers.js'; - -export function authorizationRoleRoutes(ctx: RouteContext): void { - registerRoleRoutes(ctx, { - pathPrefix: '/authorization/roles', - roleType: 'EnvironmentRole', - requireRole: (ws, c) => requireEnvRole(ws, c.req.param('slug')!), - findRole: (ws, _c, slug) => findEnvRole(ws, slug), - listFilter: () => (r) => r.type === 'EnvironmentRole', - insertDefaults: () => ({ organization_id: null }), - duplicateMessage: 'Role with this slug already exists', - }); -} diff --git a/src/emulate/workos/routes/config.spec.ts b/src/emulate/workos/routes/config.spec.ts deleted file mode 100644 index 0ea7fa82..00000000 --- a/src/emulate/workos/routes/config.spec.ts +++ /dev/null @@ -1,99 +0,0 @@ -import { describe, it, expect, beforeEach } from 'vitest'; -import { createServer, type ApiKeyMap } from '../../core/index.js'; -import { workosPlugin } from '../index.js'; - -const apiKeys: ApiKeyMap = { sk_test_config: { environment: 'test' } }; -const headers = { Authorization: 'Bearer sk_test_config', 'Content-Type': 'application/json' }; - -function createTestApp() { - return createServer(workosPlugin, { port: 0, baseUrl: 'http://localhost:0', apiKeys }); -} - -describe('Config routes', () => { - let app: ReturnType['app']; - - beforeEach(() => { - app = createTestApp().app; - }); - - const req = (path: string, init?: RequestInit) => app.request(path, { headers, ...init }); - const json = (res: Response) => res.json() as Promise; - - describe('Redirect URIs', () => { - it('creates a redirect URI', async () => { - const res = await req('/user_management/redirect_uris', { - method: 'POST', - body: JSON.stringify({ uri: 'http://localhost:3000/callback' }), - }); - expect(res.status).toBe(201); - const data = await json(res); - expect(data.object).toBe('redirect_uri'); - expect(data.uri).toBe('http://localhost:3000/callback'); - expect(data.id).toMatch(/^redir_/); - }); - - it('rejects duplicate redirect URI', async () => { - await req('/user_management/redirect_uris', { - method: 'POST', - body: JSON.stringify({ uri: 'http://localhost:3000/dup' }), - }); - const res = await req('/user_management/redirect_uris', { - method: 'POST', - body: JSON.stringify({ uri: 'http://localhost:3000/dup' }), - }); - expect(res.status).toBe(422); - expect((await json(res)).code).toBe('redirect_uri_already_exists'); - }); - }); - - describe('CORS Origins', () => { - it('creates a CORS origin', async () => { - const res = await req('/user_management/cors_origins', { - method: 'POST', - body: JSON.stringify({ origin: 'http://localhost:3000' }), - }); - expect(res.status).toBe(201); - const data = await json(res); - expect(data.object).toBe('cors_origin'); - expect(data.origin).toBe('http://localhost:3000'); - expect(data.id).toMatch(/^cors_/); - }); - - it('rejects duplicate CORS origin', async () => { - await req('/user_management/cors_origins', { - method: 'POST', - body: JSON.stringify({ origin: 'http://localhost:4000' }), - }); - const res = await req('/user_management/cors_origins', { - method: 'POST', - body: JSON.stringify({ origin: 'http://localhost:4000' }), - }); - expect(res.status).toBe(422); - expect((await json(res)).code).toBe('cors_origin_already_exists'); - }); - }); - - describe('JWT Template', () => { - it('gets default JWT template', async () => { - const res = await req('/user_management/jwt_template'); - expect(res.status).toBe(200); - const data = await json(res); - expect(data.object).toBe('jwt_template'); - expect(data.custom_claims).toEqual({}); - }); - - it('updates JWT template', async () => { - const res = await req('/user_management/jwt_template', { - method: 'PUT', - body: JSON.stringify({ custom_claims: { role: '{{user.role}}' } }), - }); - expect(res.status).toBe(200); - const data = await json(res); - expect(data.custom_claims).toEqual({ role: '{{user.role}}' }); - - // Verify persistence - const getRes = await req('/user_management/jwt_template'); - expect((await json(getRes)).custom_claims).toEqual({ role: '{{user.role}}' }); - }); - }); -}); diff --git a/src/emulate/workos/routes/config.ts b/src/emulate/workos/routes/config.ts deleted file mode 100644 index 4487d92e..00000000 --- a/src/emulate/workos/routes/config.ts +++ /dev/null @@ -1,67 +0,0 @@ -import { type RouteContext, parseJsonBody, WorkOSApiError, validationError } from '../../core/index.js'; -import { getWorkOSStore } from '../store.js'; -import { formatRedirectUri, formatCorsOrigin } from '../helpers.js'; -import { STORE_KEYS } from '../constants.js'; - -export function configRoutes(ctx: RouteContext): void { - const { app, store } = ctx; - const ws = getWorkOSStore(store); - - app.post('/user_management/redirect_uris', async (c) => { - const body = await parseJsonBody(c); - const uri = body.uri as string | undefined; - if (!uri) { - throw validationError('uri is required', [{ field: 'uri', code: 'required' }]); - } - - const existing = ws.redirectUris.findOneBy('uri', uri); - if (existing) { - throw new WorkOSApiError(422, 'Redirect URI already exists', 'redirect_uri_already_exists'); - } - - const redirectUri = ws.redirectUris.insert({ - object: 'redirect_uri', - uri, - }); - - return c.json(formatRedirectUri(redirectUri), 201); - }); - - app.post('/user_management/cors_origins', async (c) => { - const body = await parseJsonBody(c); - const origin = body.origin as string | undefined; - if (!origin) { - throw validationError('origin is required', [{ field: 'origin', code: 'required' }]); - } - - const existing = ws.corsOrigins.findOneBy('origin', origin); - if (existing) { - throw new WorkOSApiError(422, 'CORS origin already exists', 'cors_origin_already_exists'); - } - - const corsOrigin = ws.corsOrigins.insert({ - object: 'cors_origin', - origin, - }); - - return c.json(formatCorsOrigin(corsOrigin), 201); - }); - - app.get('/user_management/jwt_template', (c) => { - const template = store.getData>(STORE_KEYS.jwtTemplate) ?? { - object: 'jwt_template', - custom_claims: {}, - }; - return c.json(template); - }); - - app.put('/user_management/jwt_template', async (c) => { - const body = await parseJsonBody(c); - const template = { - object: 'jwt_template', - custom_claims: (body.custom_claims as Record) ?? {}, - }; - store.setData(STORE_KEYS.jwtTemplate, template); - return c.json(template); - }); -} diff --git a/src/emulate/workos/routes/connect.spec.ts b/src/emulate/workos/routes/connect.spec.ts deleted file mode 100644 index 26987a99..00000000 --- a/src/emulate/workos/routes/connect.spec.ts +++ /dev/null @@ -1,96 +0,0 @@ -import { describe, it, expect, beforeEach } from 'vitest'; -import { createServer, type ApiKeyMap } from '../../core/index.js'; -import { workosPlugin } from '../index.js'; - -const apiKeys: ApiKeyMap = { sk_test_org: { environment: 'test' } }; -const headers = { Authorization: 'Bearer sk_test_org', 'Content-Type': 'application/json' }; - -function createTestApp() { - return createServer(workosPlugin, { port: 0, baseUrl: 'http://localhost:0', apiKeys }); -} - -describe('Connect routes', () => { - let app: ReturnType['app']; - - beforeEach(() => { - app = createTestApp().app; - }); - - const req = (path: string, init?: RequestInit) => app.request(path, { headers, ...init }); - const json = (res: Response) => res.json() as Promise; - - it('creates an application', async () => { - const res = await req('/connect/applications', { - method: 'POST', - body: JSON.stringify({ name: 'My App', redirect_uris: ['http://localhost:3000/callback'] }), - }); - expect(res.status).toBe(201); - const app = await json(res); - expect(app.object).toBe('connect_application'); - expect(app.name).toBe('My App'); - expect(app.client_id).toBeDefined(); - expect(app.id).toMatch(/^connect_app_/); - }); - - it('rejects empty name', async () => { - const res = await req('/connect/applications', { - method: 'POST', - body: JSON.stringify({ name: '' }), - }); - expect(res.status).toBe(422); - }); - - it('gets an application by id', async () => { - const createRes = await req('/connect/applications', { - method: 'POST', - body: JSON.stringify({ name: 'Get Test' }), - }); - const created = await json(createRes); - - const res = await req(`/connect/applications/${created.id}`); - expect(res.status).toBe(200); - expect((await json(res)).name).toBe('Get Test'); - }); - - it('returns 404 for nonexistent application', async () => { - const res = await req('/connect/applications/connect_app_nonexistent'); - expect(res.status).toBe(404); - }); - - it('lists applications', async () => { - await req('/connect/applications', { - method: 'POST', - body: JSON.stringify({ name: 'App 1' }), - }); - await req('/connect/applications', { - method: 'POST', - body: JSON.stringify({ name: 'App 2' }), - }); - - const res = await req('/connect/applications'); - expect(res.status).toBe(200); - const list = await json(res); - expect(list.object).toBe('list'); - expect(list.data).toHaveLength(2); - }); - - it('creates and revokes a client secret', async () => { - const appRes = await req('/connect/applications', { - method: 'POST', - body: JSON.stringify({ name: 'Secret Test' }), - }); - const application = await json(appRes); - - const secretRes = await req(`/connect/applications/${application.id}/client_secrets`, { - method: 'POST', - }); - expect(secretRes.status).toBe(201); - const secret = await json(secretRes); - expect(secret.object).toBe('client_secret'); - expect(secret.value).toBeDefined(); - expect(secret.last_four).toBe(secret.value.slice(-4)); - - const delRes = await req(`/connect/client_secrets/${secret.id}`, { method: 'DELETE' }); - expect(delRes.status).toBe(204); - }); -}); diff --git a/src/emulate/workos/routes/connect.ts b/src/emulate/workos/routes/connect.ts deleted file mode 100644 index b11f59ad..00000000 --- a/src/emulate/workos/routes/connect.ts +++ /dev/null @@ -1,79 +0,0 @@ -import { type RouteContext, notFound, parseJsonBody, validationError, parseListParams } from '../../core/index.js'; -import { generateId } from '../../core/index.js'; -import { getWorkOSStore } from '../store.js'; -import { - formatConnectApplication, - formatClientSecret, - generateVerificationToken, - formatListResponse, -} from '../helpers.js'; - -export function connectRoutes(ctx: RouteContext): void { - const { app, store } = ctx; - const ws = getWorkOSStore(store); - - // List applications - app.get('/connect/applications', (c) => { - const url = new URL(c.req.url); - const params = parseListParams(url); - const result = ws.connectApplications.list({ ...params }); - return c.json(formatListResponse(result, formatConnectApplication)); - }); - - // Create application - app.post('/connect/applications', async (c) => { - const body = await parseJsonBody(c); - const name = body.name as string | undefined; - if (!name || typeof name !== 'string' || name.trim().length === 0) { - throw validationError('name is required', [{ field: 'name', code: 'required' }]); - } - - const application = ws.connectApplications.insert({ - object: 'connect_application', - name: name.trim(), - redirect_uris: (body.redirect_uris as string[]) ?? [], - client_id: `client_${generateId('connect')}`, - logo_url: (body.logo_url as string) ?? null, - }); - - return c.json(formatConnectApplication(application), 201); - }); - - // Get application - app.get('/connect/applications/:id', (c) => { - const application = ws.connectApplications.get(c.req.param('id')); - if (!application) throw notFound('ConnectApplication'); - return c.json(formatConnectApplication(application)); - }); - - // Create client secret - app.post('/connect/applications/:id/client_secrets', (c) => { - const application = ws.connectApplications.get(c.req.param('id')); - if (!application) throw notFound('ConnectApplication'); - - const value = `secret_${generateVerificationToken()}`; - const secret = ws.clientSecrets.insert({ - object: 'client_secret', - application_id: application.id, - value, - last_four: value.slice(-4), - }); - - // Return full value only on creation - return c.json( - { - ...formatClientSecret(secret), - value: secret.value, - }, - 201, - ); - }); - - // Revoke client secret - app.delete('/connect/client_secrets/:id', (c) => { - const secret = ws.clientSecrets.get(c.req.param('id')); - if (!secret) throw notFound('ClientSecret'); - ws.clientSecrets.delete(secret.id); - return c.body(null, 204); - }); -} diff --git a/src/emulate/workos/routes/connections.spec.ts b/src/emulate/workos/routes/connections.spec.ts deleted file mode 100644 index 2d1c9306..00000000 --- a/src/emulate/workos/routes/connections.spec.ts +++ /dev/null @@ -1,96 +0,0 @@ -import { describe, it, expect, beforeEach } from 'vitest'; -import { createServer, type ApiKeyMap } from '../../core/index.js'; -import { workosPlugin } from '../index.js'; - -const apiKeys: ApiKeyMap = { sk_test_conn: { environment: 'test' } }; -const headers = { Authorization: 'Bearer sk_test_conn', 'Content-Type': 'application/json' }; - -function createTestApp() { - return createServer(workosPlugin, { port: 0, baseUrl: 'http://localhost:0', apiKeys }); -} - -describe('Connection routes', () => { - let app: ReturnType['app']; - - beforeEach(() => { - app = createTestApp().app; - }); - - const req = (path: string, init?: RequestInit) => app.request(path, { headers, ...init }); - const json = (res: Response) => res.json() as Promise; - - async function createOrg(name: string) { - return json( - await req('/organizations', { - method: 'POST', - body: JSON.stringify({ name }), - }), - ); - } - - it('creates a connection', async () => { - const org = await createOrg('SSO Org'); - const res = await req('/connections', { - method: 'POST', - body: JSON.stringify({ - name: 'Test SSO', - organization_id: org.id, - connection_type: 'GenericSAML', - domains: ['sso.example.com'], - }), - }); - expect(res.status).toBe(201); - const conn = await json(res); - expect(conn.object).toBe('connection'); - expect(conn.organization_id).toBe(org.id); - expect(conn.domains).toHaveLength(1); - }); - - it('lists connections filtered by org', async () => { - const org1 = await createOrg('Org 1'); - const org2 = await createOrg('Org 2'); - - await req('/connections', { - method: 'POST', - body: JSON.stringify({ name: 'C1', organization_id: org1.id }), - }); - await req('/connections', { - method: 'POST', - body: JSON.stringify({ name: 'C2', organization_id: org2.id }), - }); - - const list = await json(await req(`/connections?organization_id=${org1.id}`)); - expect(list.data).toHaveLength(1); - expect(list.data[0].name).toBe('C1'); - }); - - it('gets a connection by id', async () => { - const org = await createOrg('Conn Org'); - const created = await json( - await req('/connections', { - method: 'POST', - body: JSON.stringify({ name: 'Get Me', organization_id: org.id }), - }), - ); - - const res = await req(`/connections/${created.id}`); - expect(res.status).toBe(200); - expect((await json(res)).name).toBe('Get Me'); - }); - - it('deletes a connection', async () => { - const org = await createOrg('Del Org'); - const conn = await json( - await req('/connections', { - method: 'POST', - body: JSON.stringify({ name: 'Del Conn', organization_id: org.id }), - }), - ); - - const delRes = await req(`/connections/${conn.id}`, { method: 'DELETE' }); - expect(delRes.status).toBe(204); - - const getRes = await req(`/connections/${conn.id}`); - expect(getRes.status).toBe(404); - }); -}); diff --git a/src/emulate/workos/routes/connections.ts b/src/emulate/workos/routes/connections.ts deleted file mode 100644 index 5080ed2b..00000000 --- a/src/emulate/workos/routes/connections.ts +++ /dev/null @@ -1,80 +0,0 @@ -import { type RouteContext, notFound, parseJsonBody, generateId, parseListParams } from '../../core/index.js'; -import { getWorkOSStore } from '../store.js'; -import { formatConnection, formatListResponse } from '../helpers.js'; -import type { WorkOSConnectionType } from '../entities.js'; - -export function connectionRoutes(ctx: RouteContext): void { - const { app, store } = ctx; - const ws = getWorkOSStore(store); - - app.post('/connections', async (c) => { - const body = await parseJsonBody(c); - const name = body.name as string; - const organizationId = body.organization_id as string; - const connectionType = (body.connection_type as WorkOSConnectionType) ?? 'GenericSAML'; - const domainsList = (body.domains as string[]) ?? []; - - if (!organizationId) { - throw notFound('Organization'); - } - const org = ws.organizations.get(organizationId); - if (!org) throw notFound('Organization'); - - const domains = domainsList.map((d) => ({ - object: 'connection_domain' as const, - id: generateId('conn_domain'), - domain: d, - })); - - const conn = ws.connections.insert({ - object: 'connection', - organization_id: organizationId, - connection_type: connectionType, - name: name ?? `${org.name} SSO`, - state: 'active', - domains, - }); - - return c.json(formatConnection(conn), 201); - }); - - app.get('/connections', (c) => { - const url = new URL(c.req.url); - const params = parseListParams(url); - const orgFilter = url.searchParams.get('organization_id') ?? undefined; - const typeFilter = url.searchParams.get('connection_type') ?? undefined; - const domainFilter = url.searchParams.get('domain') ?? undefined; - - const result = ws.connections.list({ - ...params, - filter: (conn) => { - if (orgFilter && conn.organization_id !== orgFilter) return false; - if (typeFilter && conn.connection_type !== typeFilter) return false; - if (domainFilter && !conn.domains.some((d) => d.domain === domainFilter)) return false; - return true; - }, - }); - - return c.json(formatListResponse(result, formatConnection)); - }); - - app.get('/connections/:id', (c) => { - const conn = ws.connections.get(c.req.param('id')); - if (!conn) throw notFound('Connection'); - return c.json(formatConnection(conn)); - }); - - app.delete('/connections/:id', (c) => { - const conn = ws.connections.get(c.req.param('id')); - if (!conn) throw notFound('Connection'); - - for (const auth of ws.ssoAuthorizations.all()) { - if (auth.connection_id === conn.id) { - ws.ssoAuthorizations.delete(auth.id); - } - } - - ws.connections.delete(conn.id); - return c.body(null, 204); - }); -} diff --git a/src/emulate/workos/routes/data-integrations.spec.ts b/src/emulate/workos/routes/data-integrations.spec.ts deleted file mode 100644 index 17aa325b..00000000 --- a/src/emulate/workos/routes/data-integrations.spec.ts +++ /dev/null @@ -1,91 +0,0 @@ -import { describe, it, expect, beforeEach } from 'vitest'; -import { createServer, type ApiKeyMap } from '../../core/index.js'; -import { workosPlugin } from '../index.js'; - -const apiKeys: ApiKeyMap = { sk_test_org: { environment: 'test' } }; -const headers = { Authorization: 'Bearer sk_test_org', 'Content-Type': 'application/json' }; - -function createTestApp() { - return createServer(workosPlugin, { port: 0, baseUrl: 'http://localhost:0', apiKeys }); -} - -describe('Data Integrations routes', () => { - let app: ReturnType['app']; - - beforeEach(() => { - app = createTestApp().app; - }); - - const req = (path: string, init?: RequestInit) => app.request(path, { headers, ...init }); - const json = (res: Response) => res.json() as Promise; - - it('authorize redirects with code', async () => { - const res = await app.request( - '/data-integrations/salesforce/authorize?redirect_uri=http://localhost:3000/callback&state=xyz', - { redirect: 'manual' }, - ); - expect(res.status).toBe(302); - const location = res.headers.get('Location')!; - expect(location).toContain('code='); - expect(location).toContain('state=xyz'); - }); - - it('authorize rejects missing redirect_uri', async () => { - const res = await app.request('/data-integrations/salesforce/authorize'); - expect(res.status).toBe(400); - }); - - it('authorize rejects non-localhost redirect_uri', async () => { - const res = await app.request('/data-integrations/salesforce/authorize?redirect_uri=https://evil.com/callback'); - expect(res.status).toBe(400); - }); - - it('exchanges code for token', async () => { - // First authorize to get a code - const authRes = await app.request( - '/data-integrations/salesforce/authorize?redirect_uri=http://localhost:3000/callback', - { redirect: 'manual' }, - ); - const location = authRes.headers.get('Location')!; - const code = new URL(location).searchParams.get('code')!; - - // Exchange code - const tokenRes = await req('/data-integrations/salesforce/token', { - method: 'POST', - body: JSON.stringify({ code }), - }); - expect(tokenRes.status).toBe(200); - const data = await json(tokenRes); - expect(data.access_token).toBeDefined(); - expect(data.token_type).toBe('bearer'); - }); - - it('rejects invalid code', async () => { - const res = await req('/data-integrations/salesforce/token', { - method: 'POST', - body: JSON.stringify({ code: 'invalid_code' }), - }); - expect(res.status).toBe(400); - }); - - it('rejects code reuse', async () => { - const authRes = await app.request( - '/data-integrations/github/authorize?redirect_uri=http://localhost:3000/callback', - { redirect: 'manual' }, - ); - const code = new URL(authRes.headers.get('Location')!).searchParams.get('code')!; - - // First use succeeds - await req('/data-integrations/github/token', { - method: 'POST', - body: JSON.stringify({ code }), - }); - - // Second use fails - const res = await req('/data-integrations/github/token', { - method: 'POST', - body: JSON.stringify({ code }), - }); - expect(res.status).toBe(400); - }); -}); diff --git a/src/emulate/workos/routes/data-integrations.ts b/src/emulate/workos/routes/data-integrations.ts deleted file mode 100644 index d10d735c..00000000 --- a/src/emulate/workos/routes/data-integrations.ts +++ /dev/null @@ -1,65 +0,0 @@ -import { type RouteContext, parseJsonBody, WorkOSApiError } from '../../core/index.js'; -import { getWorkOSStore } from '../store.js'; -import { assertLocalRedirectUri, generateVerificationToken, expiresIn, isExpired } from '../helpers.js'; - -export function dataIntegrationRoutes(ctx: RouteContext): void { - const { app, store } = ctx; - const ws = getWorkOSStore(store); - - // Authorize (public endpoint — no auth required) - app.get('/data-integrations/:slug/authorize', (c) => { - const slug = c.req.param('slug'); - const url = new URL(c.req.url); - const redirectUri = url.searchParams.get('redirect_uri'); - const state = url.searchParams.get('state') ?? null; - - if (!redirectUri) { - throw new WorkOSApiError(400, 'redirect_uri is required', 'invalid_request'); - } - assertLocalRedirectUri(redirectUri); - - const code = generateVerificationToken(); - ws.dataIntegrationAuths.insert({ - slug, - code, - redirect_uri: redirectUri, - state, - expires_at: expiresIn(10), - }); - - const redirect = new URL(redirectUri); - redirect.searchParams.set('code', code); - if (state) redirect.searchParams.set('state', state); - - return c.redirect(redirect.toString(), 302); - }); - - // Exchange code for token - app.post('/data-integrations/:slug/token', async (c) => { - const slug = c.req.param('slug'); - const body = await parseJsonBody(c); - const code = body.code as string | undefined; - - if (!code) { - throw new WorkOSApiError(400, 'code is required', 'invalid_request'); - } - - const auth = ws.dataIntegrationAuths.findOneBy('code', code); - if (!auth || auth.slug !== slug) { - throw new WorkOSApiError(400, 'Invalid authorization code', 'invalid_grant'); - } - - if (isExpired(auth.expires_at)) { - ws.dataIntegrationAuths.delete(auth.id); - throw new WorkOSApiError(400, 'Authorization code has expired', 'invalid_grant'); - } - - ws.dataIntegrationAuths.delete(auth.id); - - return c.json({ - access_token: `di_mock_${slug}_${generateVerificationToken().slice(0, 8)}`, - token_type: 'bearer', - expires_in: 3600, - }); - }); -} diff --git a/src/emulate/workos/routes/directories.spec.ts b/src/emulate/workos/routes/directories.spec.ts deleted file mode 100644 index 616a0181..00000000 --- a/src/emulate/workos/routes/directories.spec.ts +++ /dev/null @@ -1,149 +0,0 @@ -import { describe, it, expect, beforeEach } from 'vitest'; -import { createServer, type ApiKeyMap, type Store } from '../../core/index.js'; -import { workosPlugin } from '../index.js'; -import { getWorkOSStore } from '../store.js'; - -const apiKeys: ApiKeyMap = { sk_test_org: { environment: 'test' } }; -const headers = { Authorization: 'Bearer sk_test_org', 'Content-Type': 'application/json' }; - -function createTestApp() { - return createServer(workosPlugin, { port: 0, baseUrl: 'http://localhost:0', apiKeys }); -} - -describe('Directory Sync routes', () => { - let app: ReturnType['app']; - let store: Store; - - beforeEach(() => { - const server = createTestApp(); - app = server.app; - store = server.store; - }); - - const req = (path: string, init?: RequestInit) => app.request(path, { headers, ...init }); - const json = (res: Response) => res.json() as Promise; - - function seedDirectory() { - const ws = getWorkOSStore(store); - const dir = ws.directories.insert({ - object: 'directory', - name: 'Okta Directory', - organization_id: 'org_123', - domain: 'acme.com', - type: 'okta scim v2.0', - state: 'linked', - external_key: 'ext_1', - }); - - const group = ws.directoryGroups.insert({ - object: 'directory_group', - directory_id: dir.id, - organization_id: 'org_123', - idp_id: 'idp_grp_1', - name: 'Engineering', - raw_attributes: {}, - }); - - const user = ws.directoryUsers.insert({ - object: 'directory_user', - directory_id: dir.id, - organization_id: 'org_123', - idp_id: 'idp_usr_1', - first_name: 'Jane', - last_name: 'Doe', - email: 'jane@acme.com', - username: 'jdoe', - state: 'active', - role: null, - custom_attributes: {}, - raw_attributes: {}, - groups: [{ object: 'directory_group', id: group.id, name: 'Engineering' }], - }); - - return { dir, group, user }; - } - - it('lists directories', async () => { - seedDirectory(); - const res = await req('/directories'); - expect(res.status).toBe(200); - const list = await json(res); - expect(list.data).toHaveLength(1); - expect(list.data[0].object).toBe('directory'); - }); - - it('filters directories by organization_id', async () => { - seedDirectory(); - const res = await req('/directories?organization_id=org_other'); - const list = await json(res); - expect(list.data).toHaveLength(0); - }); - - it('filters directories by search', async () => { - seedDirectory(); - const res = await req('/directories?search=okta'); - const list = await json(res); - expect(list.data).toHaveLength(1); - }); - - it('gets a directory by id', async () => { - const { dir } = seedDirectory(); - const res = await req(`/directories/${dir.id}`); - expect(res.status).toBe(200); - expect((await json(res)).name).toBe('Okta Directory'); - }); - - it('returns 404 for nonexistent directory', async () => { - const res = await req('/directories/directory_nonexistent'); - expect(res.status).toBe(404); - }); - - it('deletes a directory and cascades', async () => { - const { dir, user, group } = seedDirectory(); - const delRes = await req(`/directories/${dir.id}`, { method: 'DELETE' }); - expect(delRes.status).toBe(204); - - expect(await (await req(`/directories/${dir.id}`)).status).toBe(404); - expect(await (await req(`/directory_users/${user.id}`)).status).toBe(404); - expect(await (await req(`/directory_groups/${group.id}`)).status).toBe(404); - }); - - it('lists directory users with directory_id filter', async () => { - const { dir } = seedDirectory(); - const res = await req(`/directory_users?directory_id=${dir.id}`); - expect(res.status).toBe(200); - const list = await json(res); - expect(list.data).toHaveLength(1); - expect(list.data[0].email).toBe('jane@acme.com'); - }); - - it('lists directory users with group_id filter', async () => { - const { group } = seedDirectory(); - const res = await req(`/directory_users?group_id=${group.id}`); - const list = await json(res); - expect(list.data).toHaveLength(1); - }); - - it('gets a directory user by id', async () => { - const { user } = seedDirectory(); - const res = await req(`/directory_users/${user.id}`); - expect(res.status).toBe(200); - expect((await json(res)).first_name).toBe('Jane'); - }); - - it('lists directory groups', async () => { - const { dir } = seedDirectory(); - const res = await req(`/directory_groups?directory_id=${dir.id}`); - expect(res.status).toBe(200); - const list = await json(res); - expect(list.data).toHaveLength(1); - expect(list.data[0].name).toBe('Engineering'); - }); - - it('gets a directory group by id', async () => { - const { group } = seedDirectory(); - const res = await req(`/directory_groups/${group.id}`); - expect(res.status).toBe(200); - expect((await json(res)).name).toBe('Engineering'); - }); -}); diff --git a/src/emulate/workos/routes/directories.ts b/src/emulate/workos/routes/directories.ts deleted file mode 100644 index ad6257ae..00000000 --- a/src/emulate/workos/routes/directories.ts +++ /dev/null @@ -1,96 +0,0 @@ -import { type RouteContext, notFound, parseListParams } from '../../core/index.js'; -import { getWorkOSStore } from '../store.js'; -import { formatDirectory, formatDirectoryUser, formatDirectoryGroup, formatListResponse } from '../helpers.js'; - -export function directoryRoutes(ctx: RouteContext): void { - const { app, store } = ctx; - const ws = getWorkOSStore(store); - - // List directories - app.get('/directories', (c) => { - const url = new URL(c.req.url); - const params = parseListParams(url); - const orgFilter = url.searchParams.get('organization_id') ?? undefined; - const search = url.searchParams.get('search') ?? undefined; - - const result = ws.directories.list({ - ...params, - filter: (d) => { - if (orgFilter && d.organization_id !== orgFilter) return false; - if (search && !d.name.toLowerCase().includes(search.toLowerCase())) return false; - return true; - }, - }); - - return c.json(formatListResponse(result, formatDirectory)); - }); - - // Get directory - app.get('/directories/:id', (c) => { - const dir = ws.directories.get(c.req.param('id')); - if (!dir) throw notFound('Directory'); - return c.json(formatDirectory(dir)); - }); - - // Delete directory (cascade users + groups) - app.delete('/directories/:id', (c) => { - const dir = ws.directories.get(c.req.param('id')); - if (!dir) throw notFound('Directory'); - - ws.directoryUsers.deleteBy('directory_id', dir.id); - ws.directoryGroups.deleteBy('directory_id', dir.id); - - ws.directories.delete(dir.id); - return c.body(null, 204); - }); - - // List directory users - app.get('/directory_users', (c) => { - const url = new URL(c.req.url); - const params = parseListParams(url); - const directoryId = url.searchParams.get('directory_id') ?? undefined; - const groupId = url.searchParams.get('group_id') ?? undefined; - - const result = ws.directoryUsers.list({ - ...params, - filter: (u) => { - if (directoryId && u.directory_id !== directoryId) return false; - if (groupId && !u.groups.some((g) => g.id === groupId)) return false; - return true; - }, - }); - - return c.json(formatListResponse(result, formatDirectoryUser)); - }); - - // Get directory user - app.get('/directory_users/:id', (c) => { - const user = ws.directoryUsers.get(c.req.param('id')); - if (!user) throw notFound('DirectoryUser'); - return c.json(formatDirectoryUser(user)); - }); - - // List directory groups - app.get('/directory_groups', (c) => { - const url = new URL(c.req.url); - const params = parseListParams(url); - const directoryId = url.searchParams.get('directory_id') ?? undefined; - - const result = ws.directoryGroups.list({ - ...params, - filter: (g) => { - if (directoryId && g.directory_id !== directoryId) return false; - return true; - }, - }); - - return c.json(formatListResponse(result, formatDirectoryGroup)); - }); - - // Get directory group - app.get('/directory_groups/:id', (c) => { - const group = ws.directoryGroups.get(c.req.param('id')); - if (!group) throw notFound('DirectoryGroup'); - return c.json(formatDirectoryGroup(group)); - }); -} diff --git a/src/emulate/workos/routes/email-verification.ts b/src/emulate/workos/routes/email-verification.ts deleted file mode 100644 index 6791ddd9..00000000 --- a/src/emulate/workos/routes/email-verification.ts +++ /dev/null @@ -1,56 +0,0 @@ -import { type RouteContext, notFound, parseJsonBody, WorkOSApiError } from '../../core/index.js'; -import { getWorkOSStore } from '../store.js'; -import { formatEmailVerification, formatUser, generateCode, expiresIn, isExpired } from '../helpers.js'; - -export function emailVerificationRoutes(ctx: RouteContext): void { - const { app, store } = ctx; - const ws = getWorkOSStore(store); - - app.get('/user_management/email_verification/:id', (c) => { - const ev = ws.emailVerifications.get(c.req.param('id')); - if (!ev) throw notFound('Email Verification'); - return c.json(formatEmailVerification(ev)); - }); - - app.post('/user_management/users/:id/email_verification/send', (c) => { - const user = ws.users.get(c.req.param('id')); - if (!user) throw notFound('User'); - - const ev = ws.emailVerifications.insert({ - object: 'email_verification', - user_id: user.id, - email: user.email, - code: generateCode(), - expires_at: expiresIn(10), - }); - - return c.json(formatEmailVerification(ev), 201); - }); - - app.post('/user_management/users/:id/email_verification/confirm', async (c) => { - const user = ws.users.get(c.req.param('id')); - if (!user) throw notFound('User'); - - const body = await parseJsonBody(c); - const code = body.code as string | undefined; - if (!code) { - throw new WorkOSApiError(400, 'code is required', 'invalid_request'); - } - - const verifications = ws.emailVerifications.findBy('user_id', user.id); - const ev = verifications.find((v) => v.code === code); - - if (!ev) { - throw new WorkOSApiError(400, 'Invalid code', 'invalid_code'); - } - if (isExpired(ev.expires_at)) { - throw new WorkOSApiError(400, 'Code has expired', 'expired_code'); - } - - ws.users.update(user.id, { email_verified: true }); - ws.emailVerifications.delete(ev.id); - - const updated = ws.users.get(user.id)!; - return c.json(formatUser(updated)); - }); -} diff --git a/src/emulate/workos/routes/events.spec.ts b/src/emulate/workos/routes/events.spec.ts deleted file mode 100644 index ace3475b..00000000 --- a/src/emulate/workos/routes/events.spec.ts +++ /dev/null @@ -1,68 +0,0 @@ -import { describe, it, expect, beforeEach } from 'vitest'; -import { createServer, type ApiKeyMap } from '../../core/index.js'; -import { workosPlugin, getWorkOSStore } from '../index.js'; - -const apiKeys: ApiKeyMap = { sk_test_ev: { environment: 'test' } }; -const headers = { Authorization: 'Bearer sk_test_ev', 'Content-Type': 'application/json' }; - -function createTestApp() { - return createServer(workosPlugin, { port: 0, baseUrl: 'http://localhost:0', apiKeys }); -} - -describe('Events routes', () => { - let app: ReturnType['app']; - let store: ReturnType['store']; - - beforeEach(() => { - const server = createTestApp(); - app = server.app; - store = server.store; - }); - - const req = (path: string, init?: RequestInit) => app.request(path, { headers, ...init }); - const json = (res: Response) => res.json() as Promise; - - it('lists events', async () => { - const ws = getWorkOSStore(store); - ws.events.insert({ object: 'event', event: 'user.created', data: { id: 'user_1' }, environment_id: null }); - ws.events.insert({ object: 'event', event: 'organization.created', data: { id: 'org_1' }, environment_id: null }); - - const res = await req('/events'); - expect(res.status).toBe(200); - const list = await json(res); - expect(list.object).toBe('list'); - expect(list.data).toHaveLength(2); - expect(list.data[0].object).toBe('event'); - }); - - it('filters events by type', async () => { - const ws = getWorkOSStore(store); - ws.events.insert({ object: 'event', event: 'user.created', data: {}, environment_id: null }); - ws.events.insert({ object: 'event', event: 'user.updated', data: {}, environment_id: null }); - ws.events.insert({ object: 'event', event: 'organization.created', data: {}, environment_id: null }); - - const res = await req('/events?events[]=user.created&events[]=user.updated'); - const list = await json(res); - expect(list.data).toHaveLength(2); - expect(list.data.every((e: any) => e.event.startsWith('user.'))).toBe(true); - }); - - it('returns empty list when no events', async () => { - const res = await req('/events'); - const list = await json(res); - expect(list.data).toHaveLength(0); - }); - - it('event from user creation appears in events list', async () => { - // Create a user which should trigger an event via collection hooks - await req('/user_management/users', { - method: 'POST', - body: JSON.stringify({ email: 'test@example.com', password: 'password123' }), - }); - - const res = await req('/events'); - const list = await json(res); - const userEvents = list.data.filter((e: any) => e.event === 'user.created'); - expect(userEvents.length).toBeGreaterThanOrEqual(1); - }); -}); diff --git a/src/emulate/workos/routes/events.ts b/src/emulate/workos/routes/events.ts deleted file mode 100644 index fdfad777..00000000 --- a/src/emulate/workos/routes/events.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { type RouteContext, parseListParams } from '../../core/index.js'; -import { getWorkOSStore } from '../store.js'; -import { formatEvent, formatListResponse } from '../helpers.js'; - -export function eventRoutes(ctx: RouteContext): void { - const { app, store } = ctx; - const ws = getWorkOSStore(store); - - app.get('/events', (c) => { - const url = new URL(c.req.url); - const params = parseListParams(url); - const eventTypes = url.searchParams.getAll('events[]'); - - const result = ws.events.list({ - ...params, - filter: eventTypes.length > 0 ? (e) => eventTypes.includes(e.event) : undefined, - }); - - return c.json(formatListResponse(result, formatEvent)); - }); -} diff --git a/src/emulate/workos/routes/feature-flags.spec.ts b/src/emulate/workos/routes/feature-flags.spec.ts deleted file mode 100644 index 783b8408..00000000 --- a/src/emulate/workos/routes/feature-flags.spec.ts +++ /dev/null @@ -1,139 +0,0 @@ -import { describe, it, expect, beforeEach } from 'vitest'; -import { createServer, type ApiKeyMap, type Store } from '../../core/index.js'; -import { workosPlugin } from '../index.js'; -import { getWorkOSStore } from '../store.js'; - -const apiKeys: ApiKeyMap = { sk_test_org: { environment: 'test' } }; -const headers = { Authorization: 'Bearer sk_test_org', 'Content-Type': 'application/json' }; - -function createTestApp() { - return createServer(workosPlugin, { port: 0, baseUrl: 'http://localhost:0', apiKeys }); -} - -describe('Feature Flags routes', () => { - let app: ReturnType['app']; - let store: Store; - - beforeEach(() => { - const server = createTestApp(); - app = server.app; - store = server.store; - }); - - const req = (path: string, init?: RequestInit) => app.request(path, { headers, ...init }); - const json = (res: Response) => res.json() as Promise; - - function seedFlag(slug = 'dark-mode', enabled = true) { - const ws = getWorkOSStore(store); - return ws.featureFlags.insert({ - object: 'feature_flag', - slug, - name: 'Dark Mode', - description: 'Enable dark mode', - type: 'boolean', - default_value: true, - enabled, - }); - } - - it('lists feature flags', async () => { - seedFlag(); - const res = await req('/feature-flags'); - expect(res.status).toBe(200); - const list = await json(res); - expect(list.object).toBe('list'); - expect(list.data).toHaveLength(1); - expect(list.data[0].slug).toBe('dark-mode'); - }); - - it('gets a flag by slug', async () => { - seedFlag(); - const res = await req('/feature-flags/dark-mode'); - expect(res.status).toBe(200); - const flag = await json(res); - expect(flag.slug).toBe('dark-mode'); - expect(flag.enabled).toBe(true); - }); - - it('returns 404 for nonexistent flag', async () => { - const res = await req('/feature-flags/nonexistent'); - expect(res.status).toBe(404); - }); - - it('enables a flag', async () => { - seedFlag('test-flag', false); - const res = await req('/feature-flags/test-flag/enable', { method: 'POST' }); - expect(res.status).toBe(200); - expect((await json(res)).enabled).toBe(true); - }); - - it('disables a flag', async () => { - seedFlag('test-flag', true); - const res = await req('/feature-flags/test-flag/disable', { method: 'POST' }); - expect(res.status).toBe(200); - expect((await json(res)).enabled).toBe(false); - }); - - it('adds and removes a target', async () => { - seedFlag(); - - // Add target - const addRes = await req('/feature-flags/dark-mode/targets/user_123', { - method: 'PUT', - body: JSON.stringify({ value: false, resource_type: 'user' }), - }); - expect(addRes.status).toBe(201); - const target = await json(addRes); - expect(target.resource_id).toBe('user_123'); - expect(target.value).toBe(false); - - // Update target - const updateRes = await req('/feature-flags/dark-mode/targets/user_123', { - method: 'PUT', - body: JSON.stringify({ value: true }), - }); - expect(updateRes.status).toBe(200); - expect((await json(updateRes)).value).toBe(true); - - // Remove target - const delRes = await req('/feature-flags/dark-mode/targets/user_123', { method: 'DELETE' }); - expect(delRes.status).toBe(204); - }); - - it('evaluates flags for organization', async () => { - seedFlag(); - const ws = getWorkOSStore(store); - ws.flagTargets.insert({ - object: 'flag_target', - flag_slug: 'dark-mode', - resource_id: 'org_abc', - resource_type: 'organization', - value: false, - }); - - const res = await req('/organizations/org_abc/feature-flags'); - expect(res.status).toBe(200); - const list = await json(res); - expect(list.data).toHaveLength(1); - expect(list.data[0].value).toBe(false); - }); - - it('evaluates flags for user', async () => { - seedFlag(); - - const res = await req('/user_management/users/user_123/feature-flags'); - expect(res.status).toBe(200); - const list = await json(res); - expect(list.data).toHaveLength(1); - // No target for this user, should get default value since enabled - expect(list.data[0].value).toBe(true); - }); - - it('returns null value for disabled flag without target', async () => { - seedFlag('disabled-flag', false); - - const res = await req('/user_management/users/user_123/feature-flags'); - const list = await json(res); - expect(list.data[0].value).toBe(null); - }); -}); diff --git a/src/emulate/workos/routes/feature-flags.ts b/src/emulate/workos/routes/feature-flags.ts deleted file mode 100644 index 36c9fb6a..00000000 --- a/src/emulate/workos/routes/feature-flags.ts +++ /dev/null @@ -1,113 +0,0 @@ -import { type RouteContext, notFound, parseJsonBody, parseListParams } from '../../core/index.js'; -import { getWorkOSStore, type WorkOSStore } from '../store.js'; -import { formatFeatureFlag, formatFlagTarget, formatListResponse } from '../helpers.js'; - -function evaluateFlags(ws: WorkOSStore, resourceId: string) { - const flags = ws.featureFlags.all(); - return flags.map((flag) => { - const target = ws.flagTargets.findBy('flag_slug', flag.slug).find((t) => t.resource_id === resourceId); - return { - slug: flag.slug, - type: flag.type, - value: target ? target.value : flag.enabled ? flag.default_value : null, - enabled: flag.enabled, - }; - }); -} - -export function featureFlagRoutes(ctx: RouteContext): void { - const { app, store } = ctx; - const ws = getWorkOSStore(store); - - // List all flags - app.get('/feature-flags', (c) => { - const url = new URL(c.req.url); - const params = parseListParams(url); - const result = ws.featureFlags.list({ ...params }); - return c.json(formatListResponse(result, formatFeatureFlag)); - }); - - // Get flag by slug - app.get('/feature-flags/:slug', (c) => { - const flag = ws.featureFlags.findOneBy('slug', c.req.param('slug')); - if (!flag) throw notFound('FeatureFlag'); - return c.json(formatFeatureFlag(flag)); - }); - - // Enable flag - app.post('/feature-flags/:slug/enable', (c) => { - const flag = ws.featureFlags.findOneBy('slug', c.req.param('slug')); - if (!flag) throw notFound('FeatureFlag'); - const updated = ws.featureFlags.update(flag.id, { enabled: true }); - return c.json(formatFeatureFlag(updated!)); - }); - - // Disable flag - app.post('/feature-flags/:slug/disable', (c) => { - const flag = ws.featureFlags.findOneBy('slug', c.req.param('slug')); - if (!flag) throw notFound('FeatureFlag'); - const updated = ws.featureFlags.update(flag.id, { enabled: false }); - return c.json(formatFeatureFlag(updated!)); - }); - - // Add/update target - app.put('/feature-flags/:slug/targets/:resourceId', async (c) => { - const flag = ws.featureFlags.findOneBy('slug', c.req.param('slug')); - if (!flag) throw notFound('FeatureFlag'); - - const resourceId = c.req.param('resourceId'); - const body = await parseJsonBody(c); - - // Upsert: find existing target or create - const existing = ws.flagTargets.findBy('flag_slug', flag.slug).find((t) => t.resource_id === resourceId); - - if (existing) { - const updated = ws.flagTargets.update(existing.id, { - value: body.value, - resource_type: (body.resource_type as string) ?? existing.resource_type, - }); - return c.json(formatFlagTarget(updated!)); - } - - const target = ws.flagTargets.insert({ - object: 'flag_target', - flag_slug: flag.slug, - resource_id: resourceId, - resource_type: (body.resource_type as string) ?? 'user', - value: body.value, - }); - - return c.json(formatFlagTarget(target), 201); - }); - - // Remove target - app.delete('/feature-flags/:slug/targets/:resourceId', (c) => { - const flag = ws.featureFlags.findOneBy('slug', c.req.param('slug')); - if (!flag) throw notFound('FeatureFlag'); - - const resourceId = c.req.param('resourceId'); - const target = ws.flagTargets.findBy('flag_slug', flag.slug).find((t) => t.resource_id === resourceId); - if (!target) throw notFound('FlagTarget'); - - ws.flagTargets.delete(target.id); - return c.body(null, 204); - }); - - // Evaluate flags for organization - app.get('/organizations/:orgId/feature-flags', (c) => { - return c.json({ - object: 'list', - data: evaluateFlags(ws, c.req.param('orgId')), - list_metadata: { before: null, after: null }, - }); - }); - - // Evaluate flags for user - app.get('/user_management/users/:userId/feature-flags', (c) => { - return c.json({ - object: 'list', - data: evaluateFlags(ws, c.req.param('userId')), - list_metadata: { before: null, after: null }, - }); - }); -} diff --git a/src/emulate/workos/routes/invitations.spec.ts b/src/emulate/workos/routes/invitations.spec.ts deleted file mode 100644 index 2e7b3cd2..00000000 --- a/src/emulate/workos/routes/invitations.spec.ts +++ /dev/null @@ -1,193 +0,0 @@ -import { describe, it, expect, beforeEach } from 'vitest'; -import { createServer, type ApiKeyMap } from '../../core/index.js'; -import { workosPlugin } from '../index.js'; - -const apiKeys: ApiKeyMap = { sk_test_inv: { environment: 'test' } }; -const headers = { Authorization: 'Bearer sk_test_inv', 'Content-Type': 'application/json' }; - -function createTestApp() { - return createServer(workosPlugin, { port: 0, baseUrl: 'http://localhost:0', apiKeys }); -} - -describe('Invitation routes', () => { - let app: ReturnType['app']; - - beforeEach(() => { - app = createTestApp().app; - }); - - const req = (path: string, init?: RequestInit) => app.request(path, { headers, ...init }); - const json = (res: Response) => res.json() as Promise; - - it('creates an invitation', async () => { - const res = await req('/user_management/invitations', { - method: 'POST', - body: JSON.stringify({ email: 'invite@test.com' }), - }); - expect(res.status).toBe(201); - const inv = await json(res); - expect(inv.object).toBe('invitation'); - expect(inv.email).toBe('invite@test.com'); - expect(inv.state).toBe('pending'); - expect(inv.token).toBeDefined(); - expect(inv.accept_invitation_url).toContain(inv.token); - expect(inv.id).toMatch(/^inv_/); - }); - - it('lists invitations with email filter', async () => { - await req('/user_management/invitations', { - method: 'POST', - body: JSON.stringify({ email: 'a@test.com' }), - }); - await req('/user_management/invitations', { - method: 'POST', - body: JSON.stringify({ email: 'b@test.com' }), - }); - - const list = await json(await req('/user_management/invitations?email=a@test.com')); - expect(list.data).toHaveLength(1); - expect(list.data[0].email).toBe('a@test.com'); - }); - - it('lists invitations with organization_id filter', async () => { - await req('/user_management/invitations', { - method: 'POST', - body: JSON.stringify({ email: 'org@test.com', organization_id: 'org_123' }), - }); - await req('/user_management/invitations', { - method: 'POST', - body: JSON.stringify({ email: 'no-org@test.com' }), - }); - - const list = await json(await req('/user_management/invitations?organization_id=org_123')); - expect(list.data).toHaveLength(1); - expect(list.data[0].email).toBe('org@test.com'); - }); - - it('gets invitation by id', async () => { - const created = await json( - await req('/user_management/invitations', { - method: 'POST', - body: JSON.stringify({ email: 'get@test.com' }), - }), - ); - - const res = await req(`/user_management/invitations/${created.id}`); - expect(res.status).toBe(200); - expect((await json(res)).email).toBe('get@test.com'); - }); - - it('gets invitation by token', async () => { - const created = await json( - await req('/user_management/invitations', { - method: 'POST', - body: JSON.stringify({ email: 'token@test.com' }), - }), - ); - - const res = await req(`/user_management/invitations/by_token/${created.token}`); - expect(res.status).toBe(200); - expect((await json(res)).email).toBe('token@test.com'); - }); - - it('accepts an invitation', async () => { - const created = await json( - await req('/user_management/invitations', { - method: 'POST', - body: JSON.stringify({ email: 'accept@test.com' }), - }), - ); - - const res = await req(`/user_management/invitations/${created.id}/accept`, { method: 'POST' }); - expect(res.status).toBe(200); - const accepted = await json(res); - expect(accepted.state).toBe('accepted'); - }); - - it('accepts invitation with org creates membership', async () => { - // Create a user and org first - await req('/user_management/users', { - method: 'POST', - body: JSON.stringify({ email: 'member@test.com' }), - }); - const org = await json( - await req('/organizations', { - method: 'POST', - body: JSON.stringify({ name: 'Test Org' }), - }), - ); - - const inv = await json( - await req('/user_management/invitations', { - method: 'POST', - body: JSON.stringify({ email: 'member@test.com', organization_id: org.id }), - }), - ); - - await req(`/user_management/invitations/${inv.id}/accept`, { method: 'POST' }); - - // Check membership was created - const memberships = await json(await req(`/user_management/organization_memberships?organization_id=${org.id}`)); - expect(memberships.data).toHaveLength(1); - expect(memberships.data[0].organization_id).toBe(org.id); - }); - - it('revokes an invitation', async () => { - const created = await json( - await req('/user_management/invitations', { - method: 'POST', - body: JSON.stringify({ email: 'revoke@test.com' }), - }), - ); - - const res = await req(`/user_management/invitations/${created.id}/revoke`, { method: 'POST' }); - expect(res.status).toBe(200); - expect((await json(res)).state).toBe('revoked'); - }); - - it('rejects accept on non-pending invitation', async () => { - const created = await json( - await req('/user_management/invitations', { - method: 'POST', - body: JSON.stringify({ email: 'twice@test.com' }), - }), - ); - - await req(`/user_management/invitations/${created.id}/revoke`, { method: 'POST' }); - - const res = await req(`/user_management/invitations/${created.id}/accept`, { method: 'POST' }); - expect(res.status).toBe(400); - }); - - it('resends an invitation with new token', async () => { - const created = await json( - await req('/user_management/invitations', { - method: 'POST', - body: JSON.stringify({ email: 'resend@test.com' }), - }), - ); - const originalToken = created.token; - - const res = await req(`/user_management/invitations/${created.id}/resend`, { method: 'POST' }); - expect(res.status).toBe(200); - const resent = await json(res); - expect(resent.token).not.toBe(originalToken); - expect(resent.state).toBe('pending'); - expect(resent.accept_invitation_url).toContain(resent.token); - }); - - it('deletes an invitation', async () => { - const created = await json( - await req('/user_management/invitations', { - method: 'POST', - body: JSON.stringify({ email: 'delete@test.com' }), - }), - ); - - const delRes = await req(`/user_management/invitations/${created.id}`, { method: 'DELETE' }); - expect(delRes.status).toBe(204); - - const getRes = await req(`/user_management/invitations/${created.id}`); - expect(getRes.status).toBe(404); - }); -}); diff --git a/src/emulate/workos/routes/invitations.ts b/src/emulate/workos/routes/invitations.ts deleted file mode 100644 index 8c9c1607..00000000 --- a/src/emulate/workos/routes/invitations.ts +++ /dev/null @@ -1,142 +0,0 @@ -import { - type RouteContext, - notFound, - validationError, - parseJsonBody, - WorkOSApiError, - parseListParams, -} from '../../core/index.js'; -import { getWorkOSStore } from '../store.js'; -import { formatInvitation, generateVerificationToken, expiresIn, formatListResponse } from '../helpers.js'; -import type { EventBus } from '../event-bus.js'; -import { STORE_KEYS, EVENTS } from '../constants.js'; - -export function invitationRoutes(ctx: RouteContext): void { - const { app, store, baseUrl } = ctx; - const ws = getWorkOSStore(store); - - app.post('/user_management/invitations', async (c) => { - const body = await parseJsonBody(c); - const email = body.email as string | undefined; - if (!email) { - throw validationError('email is required', [{ field: 'email', code: 'required' }]); - } - - const token = generateVerificationToken(); - const inv = ws.invitations.insert({ - object: 'invitation', - email, - state: 'pending', - token, - accept_invitation_url: `${baseUrl}/user_management/invitations/accept?token=${token}`, - organization_id: (body.organization_id as string) ?? null, - inviter_user_id: (body.inviter_user_id as string) ?? null, - role_slug: (body.role_slug as string) ?? null, - expires_at: expiresIn(72 * 60), // 72 hours - }); - - return c.json(formatInvitation(inv), 201); - }); - - app.get('/user_management/invitations', (c) => { - const url = new URL(c.req.url); - const params = parseListParams(url); - const emailFilter = url.searchParams.get('email') ?? undefined; - const orgFilter = url.searchParams.get('organization_id') ?? undefined; - - const result = ws.invitations.list({ - ...params, - filter: (inv) => { - if (emailFilter && inv.email !== emailFilter) return false; - if (orgFilter && inv.organization_id !== orgFilter) return false; - return true; - }, - }); - - return c.json(formatListResponse(result, formatInvitation)); - }); - - app.get('/user_management/invitations/by_token/:token', (c) => { - const inv = ws.invitations.findOneBy('token', c.req.param('token')); - if (!inv) throw notFound('Invitation'); - return c.json(formatInvitation(inv)); - }); - - app.get('/user_management/invitations/:id', (c) => { - const inv = ws.invitations.get(c.req.param('id')); - if (!inv) throw notFound('Invitation'); - return c.json(formatInvitation(inv)); - }); - - app.post('/user_management/invitations/:id/accept', (c) => { - const inv = ws.invitations.get(c.req.param('id')); - if (!inv) throw notFound('Invitation'); - - if (inv.state !== 'pending') { - throw new WorkOSApiError(400, `Invitation is ${inv.state}`, 'invalid_invitation_state'); - } - - ws.invitations.update(inv.id, { state: 'accepted' }); - const eventBus = store.getData(STORE_KEYS.eventBus); - eventBus?.emit({ event: EVENTS.invitationAccepted, data: formatInvitation(ws.invitations.get(inv.id)!) }); - - // Create org membership if invitation has an organization - if (inv.organization_id) { - const user = ws.users.findOneBy('email', inv.email); - if (user) { - ws.organizationMemberships.insert({ - object: 'organization_membership', - organization_id: inv.organization_id, - user_id: user.id, - role: { slug: inv.role_slug ?? 'member' }, - status: 'active', - external_id: null, - metadata: {}, - }); - } - } - - const updated = ws.invitations.get(inv.id)!; - return c.json(formatInvitation(updated)); - }); - - app.post('/user_management/invitations/:id/revoke', (c) => { - const inv = ws.invitations.get(c.req.param('id')); - if (!inv) throw notFound('Invitation'); - - if (inv.state !== 'pending') { - throw new WorkOSApiError(400, `Invitation is ${inv.state}`, 'invalid_invitation_state'); - } - - ws.invitations.update(inv.id, { state: 'revoked' }); - const eventBus = store.getData(STORE_KEYS.eventBus); - eventBus?.emit({ event: EVENTS.invitationRevoked, data: formatInvitation(ws.invitations.get(inv.id)!) }); - const updated = ws.invitations.get(inv.id)!; - return c.json(formatInvitation(updated)); - }); - - app.post('/user_management/invitations/:id/resend', (c) => { - const inv = ws.invitations.get(c.req.param('id')); - if (!inv) throw notFound('Invitation'); - - const newToken = generateVerificationToken(); - ws.invitations.update(inv.id, { - token: newToken, - accept_invitation_url: `${baseUrl}/user_management/invitations/accept?token=${newToken}`, - expires_at: expiresIn(72 * 60), - state: 'pending', - }); - - const eventBus = store.getData(STORE_KEYS.eventBus); - eventBus?.emit({ event: EVENTS.invitationResent, data: formatInvitation(ws.invitations.get(inv.id)!) }); - const updated = ws.invitations.get(inv.id)!; - return c.json(formatInvitation(updated)); - }); - - app.delete('/user_management/invitations/:id', (c) => { - const inv = ws.invitations.get(c.req.param('id')); - if (!inv) throw notFound('Invitation'); - ws.invitations.delete(inv.id); - return c.body(null, 204); - }); -} diff --git a/src/emulate/workos/routes/legacy-mfa.spec.ts b/src/emulate/workos/routes/legacy-mfa.spec.ts deleted file mode 100644 index e61969a7..00000000 --- a/src/emulate/workos/routes/legacy-mfa.spec.ts +++ /dev/null @@ -1,96 +0,0 @@ -import { describe, it, expect, beforeEach } from 'vitest'; -import { createServer, type ApiKeyMap } from '../../core/index.js'; -import { workosPlugin } from '../index.js'; - -const apiKeys: ApiKeyMap = { sk_test_org: { environment: 'test' } }; -const headers = { Authorization: 'Bearer sk_test_org', 'Content-Type': 'application/json' }; - -function createTestApp() { - return createServer(workosPlugin, { port: 0, baseUrl: 'http://localhost:0', apiKeys }); -} - -describe('Legacy MFA routes', () => { - let app: ReturnType['app']; - - beforeEach(() => { - app = createTestApp().app; - }); - - const req = (path: string, init?: RequestInit) => app.request(path, { headers, ...init }); - const json = (res: Response) => res.json() as Promise; - - it('enrolls a TOTP factor', async () => { - const res = await req('/auth/factors/enroll', { - method: 'POST', - body: JSON.stringify({ type: 'totp', totp_issuer: 'TestApp', totp_user: 'user@test.com' }), - }); - expect(res.status).toBe(201); - const factor = await json(res); - expect(factor.object).toBe('authentication_factor'); - expect(factor.type).toBe('totp'); - expect(factor.id).toMatch(/^auth_factor_/); - }); - - it('gets a factor by id', async () => { - const createRes = await req('/auth/factors/enroll', { - method: 'POST', - body: JSON.stringify({ type: 'totp' }), - }); - const factor = await json(createRes); - - const res = await req(`/auth/factors/${factor.id}`); - expect(res.status).toBe(200); - expect((await json(res)).id).toBe(factor.id); - }); - - it('returns 404 for nonexistent factor', async () => { - const res = await req('/auth/factors/auth_factor_nonexistent'); - expect(res.status).toBe(404); - }); - - it('deletes a factor', async () => { - const createRes = await req('/auth/factors/enroll', { - method: 'POST', - body: JSON.stringify({ type: 'totp' }), - }); - const factor = await json(createRes); - - const delRes = await req(`/auth/factors/${factor.id}`, { method: 'DELETE' }); - expect(delRes.status).toBe(204); - - const getRes = await req(`/auth/factors/${factor.id}`); - expect(getRes.status).toBe(404); - }); - - it('creates and verifies a challenge', async () => { - const factorRes = await req('/auth/factors/enroll', { - method: 'POST', - body: JSON.stringify({ type: 'totp' }), - }); - const factor = await json(factorRes); - - const challengeRes = await req(`/auth/factors/${factor.id}/challenge`, { method: 'POST' }); - expect(challengeRes.status).toBe(201); - const challenge = await json(challengeRes); - expect(challenge.object).toBe('authentication_challenge'); - - // In the emulator we need to know the code — use a 6-digit code - // The emulator stores the code; for test we need to peek at it or accept any code - // Since the challenge object doesn't expose the code, we verify with the stored code - // For testing, we'll create a new challenge and verify with a matching code - const verifyRes = await req(`/auth/challenges/${challenge.id}/verify`, { - method: 'POST', - body: JSON.stringify({ code: '000000' }), - }); - // Code won't match the generated one, so this should fail - expect(verifyRes.status).toBe(400); - }); - - it('returns 404 for nonexistent challenge', async () => { - const res = await req('/auth/challenges/auth_challenge_nonexistent/verify', { - method: 'POST', - body: JSON.stringify({ code: '123456' }), - }); - expect(res.status).toBe(404); - }); -}); diff --git a/src/emulate/workos/routes/legacy-mfa.ts b/src/emulate/workos/routes/legacy-mfa.ts deleted file mode 100644 index c9ac435f..00000000 --- a/src/emulate/workos/routes/legacy-mfa.ts +++ /dev/null @@ -1,83 +0,0 @@ -import { type RouteContext, notFound, parseJsonBody, WorkOSApiError } from '../../core/index.js'; -import { getWorkOSStore } from '../store.js'; -import { formatAuthFactor, formatAuthChallenge, expiresIn, isExpired, generateCode } from '../helpers.js'; -import { randomBytes } from 'node:crypto'; - -export function legacyMfaRoutes(ctx: RouteContext): void { - const { app, store } = ctx; - const ws = getWorkOSStore(store); - - // Enroll factor (legacy path — not tied to user management users) - app.post('/auth/factors/enroll', async (c) => { - const body = await parseJsonBody(c); - const type = (body.type as string) ?? 'totp'; - const issuer = (body.totp_issuer as string) ?? 'WorkOS Emulator'; - const totpUser = (body.totp_user as string) ?? 'legacy@emulator'; - const secret = randomBytes(20).toString('hex').slice(0, 32).toUpperCase(); - const uri = `otpauth://totp/${encodeURIComponent(issuer)}:${encodeURIComponent(totpUser)}?secret=${secret}&issuer=${encodeURIComponent(issuer)}`; - - const factor = ws.authFactors.insert({ - object: 'authentication_factor', - user_id: 'legacy', - type: type as 'totp', - totp: { issuer, user: totpUser, uri }, - }); - - return c.json(formatAuthFactor(factor), 201); - }); - - // Get factor - app.get('/auth/factors/:id', (c) => { - const factor = ws.authFactors.get(c.req.param('id')); - if (!factor) throw notFound('AuthenticationFactor'); - return c.json(formatAuthFactor(factor)); - }); - - // Delete factor - app.delete('/auth/factors/:id', (c) => { - const factor = ws.authFactors.get(c.req.param('id')); - if (!factor) throw notFound('AuthenticationFactor'); - ws.authFactors.delete(factor.id); - return c.body(null, 204); - }); - - // Create challenge - app.post('/auth/factors/:id/challenge', async (c) => { - const factor = ws.authFactors.get(c.req.param('id')); - if (!factor) throw notFound('AuthenticationFactor'); - - const code = generateCode(); - const challenge = ws.authChallenges.insert({ - object: 'authentication_challenge', - user_id: factor.user_id, - factor_id: factor.id, - expires_at: expiresIn(10), - code, - }); - - return c.json(formatAuthChallenge(challenge), 201); - }); - - // Verify challenge - app.post('/auth/challenges/:id/verify', async (c) => { - const challenge = ws.authChallenges.get(c.req.param('id')); - if (!challenge) throw notFound('AuthenticationChallenge'); - - if (isExpired(challenge.expires_at)) { - ws.authChallenges.delete(challenge.id); - throw new WorkOSApiError(400, 'Challenge has expired', 'expired_challenge'); - } - - const body = await parseJsonBody(c); - const code = body.code as string; - if (!code) { - throw new WorkOSApiError(400, 'code is required', 'invalid_request'); - } - if (challenge.code && code !== challenge.code) { - throw new WorkOSApiError(400, 'Invalid one-time code', 'invalid_one_time_code'); - } - - ws.authChallenges.delete(challenge.id); - return c.json({ challenge: formatAuthChallenge(challenge), valid: true }); - }); -} diff --git a/src/emulate/workos/routes/magic-auth.ts b/src/emulate/workos/routes/magic-auth.ts deleted file mode 100644 index e6686514..00000000 --- a/src/emulate/workos/routes/magic-auth.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { type RouteContext, notFound, parseJsonBody, WorkOSApiError } from '../../core/index.js'; -import { getWorkOSStore } from '../store.js'; -import { formatMagicAuth, generateCode, expiresIn } from '../helpers.js'; - -export function magicAuthRoutes(ctx: RouteContext): void { - const { app, store } = ctx; - const ws = getWorkOSStore(store); - - app.get('/user_management/magic_auth/:id', (c) => { - const ma = ws.magicAuths.get(c.req.param('id')); - if (!ma) throw notFound('Magic Auth'); - return c.json(formatMagicAuth(ma)); - }); - - app.post('/user_management/magic_auth', async (c) => { - const body = await parseJsonBody(c); - const email = body.email as string | undefined; - if (!email) { - throw new WorkOSApiError(400, 'email is required', 'invalid_request'); - } - - const user = ws.users.findOneBy('email', email); - if (!user) throw notFound('User'); - - const ma = ws.magicAuths.insert({ - object: 'magic_auth', - user_id: user.id, - email: user.email, - code: generateCode(), - expires_at: expiresIn(10), - }); - - return c.json(formatMagicAuth(ma), 201); - }); -} diff --git a/src/emulate/workos/routes/memberships.spec.ts b/src/emulate/workos/routes/memberships.spec.ts deleted file mode 100644 index 75a02f4a..00000000 --- a/src/emulate/workos/routes/memberships.spec.ts +++ /dev/null @@ -1,114 +0,0 @@ -import { describe, it, expect, beforeEach } from 'vitest'; -import { createServer, type ApiKeyMap } from '../../core/index.js'; -import { workosPlugin } from '../index.js'; - -const apiKeys: ApiKeyMap = { sk_test_mem: { environment: 'test' } }; -const headers = { Authorization: 'Bearer sk_test_mem', 'Content-Type': 'application/json' }; - -function createTestApp() { - return createServer(workosPlugin, { port: 0, baseUrl: 'http://localhost:0', apiKeys }); -} - -describe('Membership routes', () => { - let app: ReturnType['app']; - - beforeEach(() => { - app = createTestApp().app; - }); - - const req = (path: string, init?: RequestInit) => app.request(path, { headers, ...init }); - const json = (res: Response) => res.json() as Promise; - - async function createOrg(name: string) { - return json( - await req('/organizations', { - method: 'POST', - body: JSON.stringify({ name }), - }), - ); - } - - async function createUser(email: string) { - return json( - await req('/user_management/users', { - method: 'POST', - body: JSON.stringify({ email }), - }), - ); - } - - it('creates a membership', async () => { - const org = await createOrg('Mem Org'); - const user = await createUser('member@test.com'); - - const res = await req('/user_management/organization_memberships', { - method: 'POST', - body: JSON.stringify({ - organization_id: org.id, - user_id: user.id, - role_slug: 'admin', - }), - }); - expect(res.status).toBe(201); - const m = await json(res); - expect(m.object).toBe('organization_membership'); - expect(m.role.slug).toBe('admin'); - expect(m.status).toBe('active'); - }); - - it('rejects duplicate active membership', async () => { - const org = await createOrg('Dup Org'); - const user = await createUser('dup@test.com'); - - await req('/user_management/organization_memberships', { - method: 'POST', - body: JSON.stringify({ organization_id: org.id, user_id: user.id }), - }); - - const res = await req('/user_management/organization_memberships', { - method: 'POST', - body: JSON.stringify({ organization_id: org.id, user_id: user.id }), - }); - expect(res.status).toBe(409); - }); - - it('lists memberships filtered by org', async () => { - const org = await createOrg('List Org'); - const u1 = await createUser('m1@test.com'); - const u2 = await createUser('m2@test.com'); - - await req('/user_management/organization_memberships', { - method: 'POST', - body: JSON.stringify({ organization_id: org.id, user_id: u1.id }), - }); - await req('/user_management/organization_memberships', { - method: 'POST', - body: JSON.stringify({ organization_id: org.id, user_id: u2.id }), - }); - - const list = await json(await req(`/user_management/organization_memberships?organization_id=${org.id}`)); - expect(list.data).toHaveLength(2); - }); - - it('deactivates and reactivates a membership', async () => { - const org = await createOrg('Toggle Org'); - const user = await createUser('toggle@test.com'); - - const m = await json( - await req('/user_management/organization_memberships', { - method: 'POST', - body: JSON.stringify({ organization_id: org.id, user_id: user.id }), - }), - ); - - const deactivated = await json( - await req(`/user_management/organization_memberships/${m.id}/deactivate`, { method: 'PUT' }), - ); - expect(deactivated.status).toBe('inactive'); - - const reactivated = await json( - await req(`/user_management/organization_memberships/${m.id}/reactivate`, { method: 'PUT' }), - ); - expect(reactivated.status).toBe('active'); - }); -}); diff --git a/src/emulate/workos/routes/memberships.ts b/src/emulate/workos/routes/memberships.ts deleted file mode 100644 index 611a0524..00000000 --- a/src/emulate/workos/routes/memberships.ts +++ /dev/null @@ -1,130 +0,0 @@ -import { - type RouteContext, - notFound, - validationError, - parseJsonBody, - WorkOSApiError, - parseListParams, -} from '../../core/index.js'; -import { getWorkOSStore } from '../store.js'; -import { formatMembership, formatListResponse } from '../helpers.js'; - -export function membershipRoutes(ctx: RouteContext): void { - const { app, store } = ctx; - const ws = getWorkOSStore(store); - - app.post('/user_management/organization_memberships', async (c) => { - const body = await parseJsonBody(c); - const organizationId = body.organization_id as string | undefined; - const userId = body.user_id as string | undefined; - - if (!organizationId) { - throw validationError('organization_id is required', [{ field: 'organization_id', code: 'required' }]); - } - if (!userId) { - throw validationError('user_id is required', [{ field: 'user_id', code: 'required' }]); - } - - const org = ws.organizations.get(organizationId); - if (!org) throw notFound('Organization'); - - const existing = ws.organizationMemberships - .findBy('organization_id', organizationId) - .find((m) => m.user_id === userId && m.status !== 'inactive'); - if (existing) { - throw new WorkOSApiError(409, 'Membership already exists', 'conflict'); - } - - const roleSlug = (body.role_slug as string) ?? 'member'; - - const membership = ws.organizationMemberships.insert({ - object: 'organization_membership', - organization_id: organizationId, - user_id: userId, - role: { slug: roleSlug }, - status: 'active', - external_id: (body.external_id as string) ?? null, - metadata: (body.metadata as Record) ?? {}, - }); - - return c.json(formatMembership(membership), 201); - }); - - app.get('/user_management/organization_memberships', (c) => { - const url = new URL(c.req.url); - const params = parseListParams(url); - const orgFilter = url.searchParams.get('organization_id') ?? undefined; - const userFilter = url.searchParams.get('user_id') ?? undefined; - const statusesParam = url.searchParams.getAll('statuses[]'); - - const result = ws.organizationMemberships.list({ - ...params, - filter: (m) => { - if (orgFilter && m.organization_id !== orgFilter) return false; - if (userFilter && m.user_id !== userFilter) return false; - if (statusesParam.length > 0 && !statusesParam.includes(m.status)) return false; - return true; - }, - }); - - return c.json(formatListResponse(result, formatMembership)); - }); - - app.get('/user_management/organization_memberships/:id', (c) => { - const m = ws.organizationMemberships.get(c.req.param('id')); - if (!m) throw notFound('Organization Membership'); - return c.json(formatMembership(m)); - }); - - app.put('/user_management/organization_memberships/:id', async (c) => { - const m = ws.organizationMemberships.get(c.req.param('id')); - if (!m) throw notFound('Organization Membership'); - - const body = await parseJsonBody(c); - const updates: Record = {}; - - if ('role_slug' in body) { - updates.role = { slug: body.role_slug as string }; - } - if ('external_id' in body) { - updates.external_id = body.external_id ?? null; - } - if ('metadata' in body) { - updates.metadata = body.metadata ?? {}; - } - - const updated = ws.organizationMemberships.update(m.id, updates); - return c.json(formatMembership(updated!)); - }); - - app.delete('/user_management/organization_memberships/:id', (c) => { - const m = ws.organizationMemberships.get(c.req.param('id')); - if (!m) throw notFound('Organization Membership'); - ws.organizationMemberships.delete(m.id); - return c.body(null, 204); - }); - - app.put('/user_management/organization_memberships/:id/deactivate', (c) => { - const m = ws.organizationMemberships.get(c.req.param('id')); - if (!m) throw notFound('Organization Membership'); - if (m.status === 'inactive') { - throw validationError('Membership is already inactive'); - } - const updated = ws.organizationMemberships.update(m.id, { - status: 'inactive', - }); - return c.json(formatMembership(updated!)); - }); - - app.put('/user_management/organization_memberships/:id/reactivate', (c) => { - const m = ws.organizationMemberships.get(c.req.param('id')); - if (!m) throw notFound('Organization Membership'); - if (m.status === 'active') { - throw validationError('Membership is already active'); - } - const updated = ws.organizationMemberships.update(m.id, { - status: 'active', - }); - return c.json(formatMembership(updated!)); - }); -} diff --git a/src/emulate/workos/routes/organization-domains.ts b/src/emulate/workos/routes/organization-domains.ts deleted file mode 100644 index 7efd7ff2..00000000 --- a/src/emulate/workos/routes/organization-domains.ts +++ /dev/null @@ -1,64 +0,0 @@ -import { type RouteContext, notFound, validationError, parseJsonBody, WorkOSApiError } from '../../core/index.js'; -import { getWorkOSStore } from '../store.js'; -import { formatDomain, generateVerificationToken } from '../helpers.js'; - -export function organizationDomainRoutes(ctx: RouteContext): void { - const { app, store } = ctx; - const ws = getWorkOSStore(store); - - app.post('/organization_domains', async (c) => { - const body = await parseJsonBody(c); - const organizationId = body.organization_id as string | undefined; - const domain = body.domain as string | undefined; - - if (!organizationId) { - throw validationError('organization_id is required', [{ field: 'organization_id', code: 'required' }]); - } - if (!domain) { - throw validationError('domain is required', [{ field: 'domain', code: 'required' }]); - } - - const org = ws.organizations.get(organizationId); - if (!org) throw notFound('Organization'); - - const existing = ws.organizationDomains.findBy('organization_id', organizationId).find((d) => d.domain === domain); - if (existing) { - throw new WorkOSApiError(409, 'Domain already exists for this organization', 'conflict'); - } - - const domainEntity = ws.organizationDomains.insert({ - object: 'organization_domain', - organization_id: organizationId, - domain, - state: 'pending', - verification_strategy: (body.verification_strategy as 'manual' | 'dns') ?? 'manual', - verification_token: generateVerificationToken(), - verification_prefix: 'workos-verify', - }); - - return c.json(formatDomain(domainEntity), 201); - }); - - app.get('/organization_domains/:id', (c) => { - const domain = ws.organizationDomains.get(c.req.param('id')); - if (!domain) throw notFound('Organization Domain'); - return c.json(formatDomain(domain)); - }); - - app.delete('/organization_domains/:id', (c) => { - const domain = ws.organizationDomains.get(c.req.param('id')); - if (!domain) throw notFound('Organization Domain'); - ws.organizationDomains.delete(domain.id); - return c.body(null, 204); - }); - - app.post('/organization_domains/:id/verify', (c) => { - const domain = ws.organizationDomains.get(c.req.param('id')); - if (!domain) throw notFound('Organization Domain'); - - const updated = ws.organizationDomains.update(domain.id, { - state: 'verified', - }); - return c.json(formatDomain(updated!)); - }); -} diff --git a/src/emulate/workos/routes/organizations.spec.ts b/src/emulate/workos/routes/organizations.spec.ts deleted file mode 100644 index 09dd9829..00000000 --- a/src/emulate/workos/routes/organizations.spec.ts +++ /dev/null @@ -1,146 +0,0 @@ -import { describe, it, expect, beforeEach } from 'vitest'; -import { createServer, type ApiKeyMap } from '../../core/index.js'; -import { workosPlugin } from '../index.js'; - -const apiKeys: ApiKeyMap = { sk_test_org: { environment: 'test' } }; -const headers = { Authorization: 'Bearer sk_test_org', 'Content-Type': 'application/json' }; - -function createTestApp() { - return createServer(workosPlugin, { port: 0, baseUrl: 'http://localhost:0', apiKeys }); -} - -describe('Organization routes', () => { - let app: ReturnType['app']; - - beforeEach(() => { - app = createTestApp().app; - }); - - const req = (path: string, init?: RequestInit) => app.request(path, { headers, ...init }); - const json = (res: Response) => res.json() as Promise; - - it('creates an organization', async () => { - const res = await req('/organizations', { - method: 'POST', - body: JSON.stringify({ name: 'Acme Corp', external_id: 'acme' }), - }); - expect(res.status).toBe(201); - const org = await json(res); - expect(org.object).toBe('organization'); - expect(org.name).toBe('Acme Corp'); - expect(org.external_id).toBe('acme'); - expect(org.id).toMatch(/^org_/); - }); - - it('creates an org with domain_data', async () => { - const res = await req('/organizations', { - method: 'POST', - body: JSON.stringify({ - name: 'Acme Corp', - domain_data: [{ domain: 'acme.com', state: 'verified' }], - }), - }); - const org = await json(res); - expect(org.domains).toHaveLength(1); - expect(org.domains[0].domain).toBe('acme.com'); - expect(org.domains[0].state).toBe('verified'); - }); - - it('rejects empty name', async () => { - const res = await req('/organizations', { - method: 'POST', - body: JSON.stringify({ name: '' }), - }); - expect(res.status).toBe(422); - const body = await json(res); - expect(body.code).toBe('unprocessable_entity'); - }); - - it('gets an organization by id', async () => { - const createRes = await req('/organizations', { - method: 'POST', - body: JSON.stringify({ name: 'Get Test' }), - }); - const created = await json(createRes); - - const res = await req(`/organizations/${created.id}`); - expect(res.status).toBe(200); - expect((await json(res)).name).toBe('Get Test'); - }); - - it('gets org by external_id', async () => { - await req('/organizations', { - method: 'POST', - body: JSON.stringify({ name: 'Ext Test', external_id: 'ext_123' }), - }); - - const res = await req('/organizations/external_id/ext_123'); - expect(res.status).toBe(200); - expect((await json(res)).name).toBe('Ext Test'); - }); - - it('returns 404 for nonexistent org', async () => { - const res = await req('/organizations/org_nonexistent'); - expect(res.status).toBe(404); - }); - - it('updates an organization', async () => { - const createRes = await req('/organizations', { - method: 'POST', - body: JSON.stringify({ name: 'Old Name' }), - }); - const created = await json(createRes); - - const res = await req(`/organizations/${created.id}`, { - method: 'PUT', - body: JSON.stringify({ name: 'New Name' }), - }); - expect(res.status).toBe(200); - expect((await json(res)).name).toBe('New Name'); - }); - - it('deletes an org and cascades', async () => { - const createRes = await req('/organizations', { - method: 'POST', - body: JSON.stringify({ - name: 'Delete Test', - domain_data: [{ domain: 'delete.com' }], - }), - }); - const org = await json(createRes); - - const delRes = await req(`/organizations/${org.id}`, { method: 'DELETE' }); - expect(delRes.status).toBe(204); - - const getRes = await req(`/organizations/${org.id}`); - expect(getRes.status).toBe(404); - }); - - it('lists with cursor pagination', async () => { - for (let i = 1; i <= 5; i++) { - await req('/organizations', { - method: 'POST', - body: JSON.stringify({ name: `Org ${i}` }), - }); - } - - const res = await req('/organizations?limit=2&order=asc'); - const list = await json(res); - expect(list.object).toBe('list'); - expect(list.data).toHaveLength(2); - expect(list.list_metadata.after).toBeDefined(); - - const res2 = await req(`/organizations?limit=2&order=asc&after=${list.list_metadata.after}`); - const list2 = await json(res2); - expect(list2.data).toHaveLength(2); - - const ids1 = list.data.map((d: any) => d.id); - const ids2 = list2.data.map((d: any) => d.id); - expect(ids1.filter((id: string) => ids2.includes(id))).toHaveLength(0); - }); - - it('rejects unauthenticated request', async () => { - const res = await app.request('/organizations', { method: 'GET' }); - expect(res.status).toBe(401); - }); -}); diff --git a/src/emulate/workos/routes/organizations.ts b/src/emulate/workos/routes/organizations.ts deleted file mode 100644 index 1f1bc7fa..00000000 --- a/src/emulate/workos/routes/organizations.ts +++ /dev/null @@ -1,148 +0,0 @@ -import { type RouteContext, notFound, validationError, parseJsonBody, parseListParams } from '../../core/index.js'; -import { getWorkOSStore } from '../store.js'; -import { formatOrganization, generateVerificationToken, formatListResponse } from '../helpers.js'; -import type { WorkOSOrganizationDomain } from '../entities.js'; - -export function organizationRoutes(ctx: RouteContext): void { - const { app, store } = ctx; - const ws = getWorkOSStore(store); - - app.post('/organizations', async (c) => { - const body = await parseJsonBody(c); - const name = body.name as string | undefined; - if (!name || typeof name !== 'string' || name.trim().length === 0) { - throw validationError('Name is required', [{ field: 'name', code: 'required' }]); - } - - const org = ws.organizations.insert({ - object: 'organization', - name: name.trim(), - external_id: (body.external_id as string) ?? null, - metadata: (body.metadata as Record) ?? {}, - stripe_customer_id: null, - }); - - const domainData = body.domain_data as Array<{ domain: string; state?: string }> | undefined; - if (domainData && Array.isArray(domainData)) { - for (const dd of domainData) { - ws.organizationDomains.insert({ - object: 'organization_domain', - organization_id: org.id, - domain: dd.domain, - state: dd.state === 'verified' ? 'verified' : 'pending', - verification_strategy: 'manual', - verification_token: generateVerificationToken(), - verification_prefix: 'workos-verify', - }); - } - } - - return c.json(formatOrganization(org, ws), 201); - }); - - app.get('/organizations', (c) => { - const url = new URL(c.req.url); - const params = parseListParams(url); - const nameFilter = url.searchParams.get('name') ?? undefined; - const domainsFilter = url.searchParams.get('domains') ?? undefined; - - const result = ws.organizations.list({ - ...params, - filter: (org) => { - if (nameFilter && !org.name.toLowerCase().includes(nameFilter.toLowerCase())) { - return false; - } - if (domainsFilter) { - const orgDomains = ws.organizationDomains.findBy('organization_id', org.id); - if (!orgDomains.some((d) => d.domain === domainsFilter)) { - return false; - } - } - return true; - }, - }); - - // Pre-fetch all domains once to avoid N+1 lookups per org - const allDomains = ws.organizationDomains.all(); - const domainsByOrg = new Map(); - for (const d of allDomains) { - const list = domainsByOrg.get(d.organization_id) ?? []; - list.push(d); - domainsByOrg.set(d.organization_id, list); - } - - return c.json( - formatListResponse(result, (org) => formatOrganization(org, ws, { domains: domainsByOrg.get(org.id) ?? [] })), - ); - }); - - app.get('/organizations/:id', (c) => { - const org = ws.organizations.get(c.req.param('id')); - if (!org) throw notFound('Organization'); - return c.json(formatOrganization(org, ws)); - }); - - app.get('/organizations/external_id/:external_id', (c) => { - const org = ws.organizations.findOneBy('external_id', c.req.param('external_id')); - if (!org) throw notFound('Organization'); - return c.json(formatOrganization(org, ws)); - }); - - app.put('/organizations/:id', async (c) => { - const org = ws.organizations.get(c.req.param('id')); - if (!org) throw notFound('Organization'); - - const body = await parseJsonBody(c); - const updates: Record = {}; - - if ('name' in body) { - if (!body.name || typeof body.name !== 'string' || (body.name as string).trim().length === 0) { - throw validationError('Name is required', [{ field: 'name', code: 'required' }]); - } - updates.name = (body.name as string).trim(); - } - if ('external_id' in body) updates.external_id = body.external_id ?? null; - if ('metadata' in body) updates.metadata = body.metadata ?? {}; - - if ('domain_data' in body && Array.isArray(body.domain_data)) { - const existing = ws.organizationDomains.findBy('organization_id', org.id); - const incoming = body.domain_data as Array<{ domain: string; state?: string }>; - const incomingDomains = new Set(incoming.map((d) => d.domain)); - - for (const d of existing) { - if (!incomingDomains.has(d.domain)) { - ws.organizationDomains.delete(d.id); - } - } - - const existingDomains = new Set(existing.map((d) => d.domain)); - for (const dd of incoming) { - if (!existingDomains.has(dd.domain)) { - ws.organizationDomains.insert({ - object: 'organization_domain', - organization_id: org.id, - domain: dd.domain, - state: dd.state === 'verified' ? 'verified' : 'pending', - verification_strategy: 'manual', - verification_token: generateVerificationToken(), - verification_prefix: 'workos-verify', - }); - } - } - } - - const updated = ws.organizations.update(org.id, updates); - return c.json(formatOrganization(updated!, ws)); - }); - - app.delete('/organizations/:id', (c) => { - const org = ws.organizations.get(c.req.param('id')); - if (!org) throw notFound('Organization'); - - ws.organizationDomains.deleteBy('organization_id', org.id); - ws.organizationMemberships.deleteBy('organization_id', org.id); - - ws.organizations.delete(org.id); - return c.body(null, 204); - }); -} diff --git a/src/emulate/workos/routes/password-reset.ts b/src/emulate/workos/routes/password-reset.ts deleted file mode 100644 index 4295b032..00000000 --- a/src/emulate/workos/routes/password-reset.ts +++ /dev/null @@ -1,70 +0,0 @@ -import { type RouteContext, notFound, parseJsonBody, WorkOSApiError } from '../../core/index.js'; -import { getWorkOSStore } from '../store.js'; -import { formatPasswordReset, generateVerificationToken, hashPassword, expiresIn, isExpired } from '../helpers.js'; - -export function passwordResetRoutes(ctx: RouteContext): void { - const { app, store } = ctx; - const ws = getWorkOSStore(store); - - app.get('/user_management/password_reset/:id', (c) => { - const pr = ws.passwordResets.get(c.req.param('id')); - if (!pr) throw notFound('Password Reset'); - return c.json(formatPasswordReset(pr)); - }); - - app.post('/user_management/password_reset', async (c) => { - const body = await parseJsonBody(c); - const email = body.email as string | undefined; - if (!email) { - throw new WorkOSApiError(400, 'email is required', 'invalid_request'); - } - - const user = ws.users.findOneBy('email', email); - if (!user) throw notFound('User'); - - const pr = ws.passwordResets.insert({ - object: 'password_reset', - user_id: user.id, - email: user.email, - token: generateVerificationToken(), - expires_at: expiresIn(60), - }); - - return c.json(formatPasswordReset(pr), 201); - }); - - app.post('/user_management/password_reset/confirm', async (c) => { - const body = await parseJsonBody(c); - const token = body.token as string | undefined; - const newPassword = body.new_password as string | undefined; - - if (!token) { - throw new WorkOSApiError(400, 'token is required', 'invalid_request'); - } - if (!newPassword) { - throw new WorkOSApiError(400, 'new_password is required', 'invalid_request'); - } - - const resets = ws.passwordResets.all(); - const pr = resets.find((r) => r.token === token); - if (!pr) { - throw new WorkOSApiError(400, 'Invalid token', 'invalid_token'); - } - if (isExpired(pr.expires_at)) { - throw new WorkOSApiError(400, 'Token has expired', 'expired_token'); - } - - const user = ws.users.get(pr.user_id); - if (!user) { - ws.passwordResets.delete(pr.id); - throw notFound('User'); - } - - ws.users.update(pr.user_id, { - password_hash: hashPassword(newPassword), - }); - ws.passwordResets.delete(pr.id); - - return c.json({ user: { object: 'user', id: user.id, email: user.email } }); - }); -} diff --git a/src/emulate/workos/routes/pipes.spec.ts b/src/emulate/workos/routes/pipes.spec.ts deleted file mode 100644 index d712b626..00000000 --- a/src/emulate/workos/routes/pipes.spec.ts +++ /dev/null @@ -1,261 +0,0 @@ -import { describe, it, expect, beforeEach } from 'vitest'; -import { createServer, type ApiKeyMap } from '../../core/index.js'; -import { workosPlugin, seedFromConfig } from '../index.js'; - -const apiKeys: ApiKeyMap = { sk_test_pipes: { environment: 'test' } }; -const headers = { Authorization: 'Bearer sk_test_pipes', 'Content-Type': 'application/json' }; - -function createTestApp() { - return createServer(workosPlugin, { port: 0, baseUrl: 'http://localhost:0', apiKeys }); -} - -describe('Pipe connection routes', () => { - let app: ReturnType['app']; - - beforeEach(() => { - app = createTestApp().app; - }); - - const req = (path: string, init?: RequestInit) => app.request(path, { headers, ...init }); - const json = (res: Response) => res.json() as Promise; - - async function createPipeConnection(overrides: Record = {}) { - return json( - await req('/pipes/connections', { - method: 'POST', - body: JSON.stringify({ - user_id: 'user_01ABC', - provider: 'github', - scopes: ['repo', 'user'], - ...overrides, - }), - }), - ); - } - - it('creates a pipe connection', async () => { - const res = await req('/pipes/connections', { - method: 'POST', - body: JSON.stringify({ - user_id: 'user_01ABC', - provider: 'github', - scopes: ['repo', 'user'], - }), - }); - expect(res.status).toBe(201); - const conn = await json(res); - expect(conn.object).toBe('pipe_connection'); - expect(conn.id).toMatch(/^pipe_conn_/); - expect(conn.user_id).toBe('user_01ABC'); - expect(conn.provider).toBe('github'); - expect(conn.scopes).toEqual(['repo', 'user']); - expect(conn.status).toBe('connected'); - expect(conn.external_account_id).toBeNull(); - expect(conn.created_at).toBeDefined(); - expect(conn.updated_at).toBeDefined(); - }); - - it('rejects missing user_id', async () => { - const res = await req('/pipes/connections', { - method: 'POST', - body: JSON.stringify({ provider: 'github', scopes: ['repo'] }), - }); - expect(res.status).toBe(422); - }); - - it('rejects missing provider', async () => { - const res = await req('/pipes/connections', { - method: 'POST', - body: JSON.stringify({ user_id: 'user_01ABC', scopes: ['repo'] }), - }); - expect(res.status).toBe(422); - }); - - it('rejects invalid provider', async () => { - const res = await req('/pipes/connections', { - method: 'POST', - body: JSON.stringify({ user_id: 'user_01ABC', provider: 'invalid', scopes: [] }), - }); - expect(res.status).toBe(422); - }); - - it('lists pipe connections', async () => { - await createPipeConnection({ provider: 'github' }); - await createPipeConnection({ provider: 'slack', scopes: ['chat:write'] }); - - const list = await json(await req('/pipes/connections')); - expect(list.object).toBe('list'); - expect(list.data).toHaveLength(2); - expect(list.list_metadata).toBeDefined(); - }); - - it('lists connections filtered by user_id', async () => { - await createPipeConnection({ user_id: 'user_01AAA', provider: 'github' }); - await createPipeConnection({ user_id: 'user_01BBB', provider: 'slack' }); - - const list = await json(await req('/pipes/connections?user_id=user_01AAA')); - expect(list.data).toHaveLength(1); - expect(list.data[0].user_id).toBe('user_01AAA'); - }); - - it('lists connections filtered by provider', async () => { - await createPipeConnection({ provider: 'github' }); - await createPipeConnection({ provider: 'slack', scopes: ['chat:write'] }); - - const list = await json(await req('/pipes/connections?provider=slack')); - expect(list.data).toHaveLength(1); - expect(list.data[0].provider).toBe('slack'); - }); - - it('gets a pipe connection by id', async () => { - const created = await createPipeConnection(); - const res = await req(`/pipes/connections/${created.id}`); - expect(res.status).toBe(200); - const conn = await json(res); - expect(conn.id).toBe(created.id); - expect(conn.provider).toBe('github'); - }); - - it('returns 404 for nonexistent pipe connection', async () => { - const res = await req('/pipes/connections/pipe_conn_nonexistent'); - expect(res.status).toBe(404); - }); - - it('deletes a pipe connection', async () => { - const created = await createPipeConnection(); - - const delRes = await req(`/pipes/connections/${created.id}`, { method: 'DELETE' }); - expect(delRes.status).toBe(204); - - const getRes = await req(`/pipes/connections/${created.id}`); - expect(getRes.status).toBe(404); - }); - - it('returns 404 when deleting nonexistent connection', async () => { - const res = await req('/pipes/connections/pipe_conn_nonexistent', { method: 'DELETE' }); - expect(res.status).toBe(404); - }); - - it('gets access token for connected pipe', async () => { - const created = await createPipeConnection({ - user_id: 'user_01XYZ', - provider: 'github', - scopes: ['repo', 'user'], - }); - - const res = await req(`/pipes/connections/${created.id}/access_token`, { method: 'POST' }); - expect(res.status).toBe(200); - const token = await json(res); - expect(token.access_token).toBe('pipes_mock_github_user_01XYZ'); - expect(token.token_type).toBe('bearer'); - expect(token.scopes).toEqual(['repo', 'user']); - expect(token.expires_in).toBe(3600); - }); - - it('returns 400 for access token on disconnected connection', async () => { - const { app: seededApp, store } = createTestApp(); - seedFromConfig(store, 'http://localhost:0', { - pipeConnections: [{ user_id: 'user_01ABC', provider: 'github', scopes: ['repo'], status: 'disconnected' }], - }); - - const list = (await (await seededApp.request('/pipes/connections', { headers })).json()) as any; - const connId = list.data[0].id; - - const res = await seededApp.request(`/pipes/connections/${connId}/access_token`, { - method: 'POST', - headers, - }); - expect(res.status).toBe(400); - const body = (await res.json()) as any; - expect(body.error).toBe('connection_inactive'); - expect(body.message).toBe('Connection is disconnected'); - }); - - it('returns 400 for access token on requires_reauth connection', async () => { - const { app: seededApp, store } = createTestApp(); - seedFromConfig(store, 'http://localhost:0', { - pipeConnections: [ - { user_id: 'user_01ABC', provider: 'slack', scopes: ['chat:write'], status: 'requires_reauth' }, - ], - }); - - const list = (await (await seededApp.request('/pipes/connections', { headers })).json()) as any; - const connId = list.data[0].id; - - const res = await seededApp.request(`/pipes/connections/${connId}/access_token`, { - method: 'POST', - headers, - }); - expect(res.status).toBe(400); - const body = (await res.json()) as any; - expect(body.error).toBe('connection_inactive'); - expect(body.message).toBe('Connection is requires_reauth'); - }); - - it('returns 404 for access token on nonexistent connection', async () => { - const res = await req('/pipes/connections/pipe_conn_nonexistent/access_token', { method: 'POST' }); - expect(res.status).toBe(404); - }); - - it('rejects unauthenticated request', async () => { - const res = await app.request('/pipes/connections', { method: 'GET' }); - expect(res.status).toBe(401); - }); -}); - -describe('Pipe connection seed config', () => { - it('seeds pipe connections from config', async () => { - const { app, store } = createTestApp(); - - seedFromConfig(store, 'http://localhost:0', { - pipeConnections: [ - { - user_id: 'user_01ABC', - provider: 'github', - scopes: ['repo', 'user'], - status: 'connected', - }, - { - user_id: 'user_01ABC', - provider: 'slack', - scopes: ['chat:write', 'channels:read'], - }, - ], - }); - - const res = await app.request('/pipes/connections', { headers }); - const list = (await res.json()) as any; - expect(list.data).toHaveLength(2); - - const github = list.data.find((c: any) => c.provider === 'github'); - expect(github).toBeDefined(); - expect(github.user_id).toBe('user_01ABC'); - expect(github.scopes).toEqual(['repo', 'user']); - expect(github.status).toBe('connected'); - - const slack = list.data.find((c: any) => c.provider === 'slack'); - expect(slack).toBeDefined(); - expect(slack.scopes).toEqual(['chat:write', 'channels:read']); - expect(slack.status).toBe('connected'); - }); - - it('seeds pipe connections with custom status', async () => { - const { app, store } = createTestApp(); - - seedFromConfig(store, 'http://localhost:0', { - pipeConnections: [ - { - user_id: 'user_01ABC', - provider: 'google', - scopes: ['email'], - status: 'disconnected', - }, - ], - }); - - const res = await app.request('/pipes/connections', { headers }); - const list = (await res.json()) as any; - expect(list.data).toHaveLength(1); - expect(list.data[0].status).toBe('disconnected'); - }); -}); diff --git a/src/emulate/workos/routes/pipes.ts b/src/emulate/workos/routes/pipes.ts deleted file mode 100644 index 59d7a865..00000000 --- a/src/emulate/workos/routes/pipes.ts +++ /dev/null @@ -1,93 +0,0 @@ -import { type RouteContext, notFound, validationError, parseJsonBody, parseListParams } from '../../core/index.js'; -import { getWorkOSStore } from '../store.js'; -import { formatPipeConnection, formatListResponse } from '../helpers.js'; -import type { PipeProvider } from '../entities.js'; - -const VALID_PROVIDERS: PipeProvider[] = ['github', 'slack', 'google', 'salesforce']; - -export function pipeRoutes(ctx: RouteContext): void { - const { app, store } = ctx; - const ws = getWorkOSStore(store); - - app.post('/pipes/connections', async (c) => { - const body = await parseJsonBody(c); - const userId = body.user_id as string | undefined; - const provider = body.provider as PipeProvider | undefined; - const scopes = (body.scopes as string[]) ?? []; - - if (!userId) { - throw validationError('user_id is required', [{ field: 'user_id', code: 'required' }]); - } - if (!provider) { - throw validationError('provider is required', [{ field: 'provider', code: 'required' }]); - } - if (!VALID_PROVIDERS.includes(provider)) { - throw validationError(`provider must be one of: ${VALID_PROVIDERS.join(', ')}`, [ - { field: 'provider', code: 'invalid' }, - ]); - } - - const conn = ws.pipeConnections.insert({ - object: 'pipe_connection', - user_id: userId, - provider, - scopes, - status: 'connected', - external_account_id: (body.external_account_id as string) ?? null, - }); - - return c.json(formatPipeConnection(conn), 201); - }); - - app.get('/pipes/connections', (c) => { - const url = new URL(c.req.url); - const params = parseListParams(url); - const userIdFilter = url.searchParams.get('user_id') ?? undefined; - const providerFilter = url.searchParams.get('provider') ?? undefined; - - const result = ws.pipeConnections.list({ - ...params, - filter: (pc) => { - if (userIdFilter && pc.user_id !== userIdFilter) return false; - if (providerFilter && pc.provider !== providerFilter) return false; - return true; - }, - }); - - return c.json(formatListResponse(result, formatPipeConnection)); - }); - - app.get('/pipes/connections/:id', (c) => { - const conn = ws.pipeConnections.get(c.req.param('id')); - if (!conn) throw notFound('Pipe connection'); - return c.json(formatPipeConnection(conn)); - }); - - app.delete('/pipes/connections/:id', (c) => { - const conn = ws.pipeConnections.get(c.req.param('id')); - if (!conn) throw notFound('Pipe connection'); - ws.pipeConnections.delete(conn.id); - return c.body(null, 204); - }); - - app.post('/pipes/connections/:id/access_token', (c) => { - const conn = ws.pipeConnections.get(c.req.param('id')); - if (!conn) throw notFound('Pipe connection'); - if (conn.status !== 'connected') { - return c.json( - { - error: 'connection_inactive', - message: `Connection is ${conn.status}`, - }, - 400, - ); - } - - return c.json({ - access_token: `pipes_mock_${conn.provider}_${conn.user_id}`, - token_type: 'bearer', - scopes: conn.scopes, - expires_in: 3600, - }); - }); -} diff --git a/src/emulate/workos/routes/portal.spec.ts b/src/emulate/workos/routes/portal.spec.ts deleted file mode 100644 index 70d110cd..00000000 --- a/src/emulate/workos/routes/portal.spec.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { describe, it, expect, beforeEach } from 'vitest'; -import { createServer, type ApiKeyMap } from '../../core/index.js'; -import { workosPlugin } from '../index.js'; - -const apiKeys: ApiKeyMap = { sk_test_org: { environment: 'test' } }; -const headers = { Authorization: 'Bearer sk_test_org', 'Content-Type': 'application/json' }; - -function createTestApp() { - return createServer(workosPlugin, { port: 0, baseUrl: 'http://localhost:0', apiKeys }); -} - -describe('Portal routes', () => { - let app: ReturnType['app']; - - beforeEach(() => { - app = createTestApp().app; - }); - - const req = (path: string, init?: RequestInit) => app.request(path, { headers, ...init }); - const json = (res: Response) => res.json() as Promise; - - it('generates a portal link', async () => { - const res = await req('/portal/generate_link', { - method: 'POST', - body: JSON.stringify({ intent: 'sso', organization: 'org_123' }), - }); - expect(res.status).toBe(200); - const data = await json(res); - expect(data.link).toContain('/portal/sso/org_123'); - }); - - it('rejects missing intent', async () => { - const res = await req('/portal/generate_link', { - method: 'POST', - body: JSON.stringify({ organization: 'org_123' }), - }); - expect(res.status).toBe(422); - }); - - it('rejects missing organization', async () => { - const res = await req('/portal/generate_link', { - method: 'POST', - body: JSON.stringify({ intent: 'sso' }), - }); - expect(res.status).toBe(422); - }); -}); diff --git a/src/emulate/workos/routes/portal.ts b/src/emulate/workos/routes/portal.ts deleted file mode 100644 index 1ff91663..00000000 --- a/src/emulate/workos/routes/portal.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { type RouteContext, parseJsonBody, validationError } from '../../core/index.js'; - -export function portalRoutes(ctx: RouteContext): void { - const { app } = ctx; - - app.post('/portal/generate_link', async (c) => { - const body = await parseJsonBody(c); - const intent = body.intent as string | undefined; - const organization = body.organization as string | undefined; - - if (!intent) { - throw validationError('intent is required', [{ field: 'intent', code: 'required' }]); - } - if (!organization) { - throw validationError('organization is required', [{ field: 'organization', code: 'required' }]); - } - - const baseUrl = new URL(c.req.url).origin; - return c.json({ link: `${baseUrl}/portal/${intent}/${organization}` }); - }); -} diff --git a/src/emulate/workos/routes/radar.spec.ts b/src/emulate/workos/routes/radar.spec.ts deleted file mode 100644 index 523f09ee..00000000 --- a/src/emulate/workos/routes/radar.spec.ts +++ /dev/null @@ -1,86 +0,0 @@ -import { describe, it, expect, beforeEach } from 'vitest'; -import { createServer, type ApiKeyMap, type Store } from '../../core/index.js'; -import { workosPlugin } from '../index.js'; -import { getWorkOSStore } from '../store.js'; - -const apiKeys: ApiKeyMap = { sk_test_org: { environment: 'test' } }; -const headers = { Authorization: 'Bearer sk_test_org', 'Content-Type': 'application/json' }; - -function createTestApp() { - return createServer(workosPlugin, { port: 0, baseUrl: 'http://localhost:0', apiKeys }); -} - -describe('Radar routes', () => { - let app: ReturnType['app']; - let store: Store; - - beforeEach(() => { - const server = createTestApp(); - app = server.app; - store = server.store; - }); - - const req = (path: string, init?: RequestInit) => app.request(path, { headers, ...init }); - const json = (res: Response) => res.json() as Promise; - - it('lists radar attempts', async () => { - const ws = getWorkOSStore(store); - ws.radarAttempts.insert({ - object: 'radar_attempt', - user_id: null, - ip_address: '1.2.3.4', - user_agent: 'test-agent', - verdict: 'allow', - signals: [], - }); - - const res = await req('/radar/attempts'); - expect(res.status).toBe(200); - const list = await json(res); - expect(list.object).toBe('list'); - expect(list.data).toHaveLength(1); - expect(list.data[0].ip_address).toBe('1.2.3.4'); - }); - - it('gets an attempt by id', async () => { - const ws = getWorkOSStore(store); - const attempt = ws.radarAttempts.insert({ - object: 'radar_attempt', - user_id: null, - ip_address: '5.6.7.8', - user_agent: null, - verdict: 'allow', - signals: [{ type: 'geo', confidence: 0.9 }], - }); - - const res = await req(`/radar/attempts/${attempt.id}`); - expect(res.status).toBe(200); - const data = await json(res); - expect(data.ip_address).toBe('5.6.7.8'); - expect(data.signals).toHaveLength(1); - }); - - it('returns 404 for nonexistent attempt', async () => { - const res = await req('/radar/attempts/radar_attempt_nonexistent'); - expect(res.status).toBe(404); - }); - - it('adds and removes entries from allow list', async () => { - const addRes = await req('/radar/lists/ip/add', { - method: 'POST', - body: JSON.stringify({ entries: ['1.2.3.4', '5.6.7.8'] }), - }); - expect(addRes.status).toBe(200); - expect((await json(addRes)).success).toBe(true); - - const removeRes = await req('/radar/lists/ip/remove', { - method: 'POST', - body: JSON.stringify({ entries: ['1.2.3.4'] }), - }); - expect(removeRes.status).toBe(200); - - const list = store.getData>('radar_ip_list'); - expect(list?.has('5.6.7.8')).toBe(true); - expect(list?.has('1.2.3.4')).toBe(false); - }); -}); diff --git a/src/emulate/workos/routes/radar.ts b/src/emulate/workos/routes/radar.ts deleted file mode 100644 index 133b7084..00000000 --- a/src/emulate/workos/routes/radar.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { type RouteContext, notFound, parseJsonBody, parseListParams } from '../../core/index.js'; -import { getWorkOSStore } from '../store.js'; -import { formatRadarAttempt, formatListResponse } from '../helpers.js'; - -export function radarRoutes(ctx: RouteContext): void { - const { app, store } = ctx; - const ws = getWorkOSStore(store); - - // List attempts - app.get('/radar/attempts', (c) => { - const url = new URL(c.req.url); - const params = parseListParams(url); - const result = ws.radarAttempts.list({ ...params }); - return c.json(formatListResponse(result, formatRadarAttempt)); - }); - - // Get attempt - app.get('/radar/attempts/:id', (c) => { - const attempt = ws.radarAttempts.get(c.req.param('id')); - if (!attempt) throw notFound('RadarAttempt'); - return c.json(formatRadarAttempt(attempt)); - }); - - // Manage allow/deny lists - app.post('/radar/lists/:type/:action', async (c) => { - const listType = c.req.param('type'); - const action = c.req.param('action'); - const body = await parseJsonBody(c); - const entries = (body.entries as string[]) ?? []; - - const key = `radar_${listType}_list`; - const existing = store.getData>(key) ?? new Set(); - - if (action === 'add') { - for (const entry of entries) existing.add(entry); - } else if (action === 'remove') { - for (const entry of entries) existing.delete(entry); - } - - store.setData(key, existing); - return c.json({ success: true }); - }); -} diff --git a/src/emulate/workos/routes/sessions.spec.ts b/src/emulate/workos/routes/sessions.spec.ts deleted file mode 100644 index 13710b5f..00000000 --- a/src/emulate/workos/routes/sessions.spec.ts +++ /dev/null @@ -1,106 +0,0 @@ -import { describe, it, expect, beforeEach } from 'vitest'; -import { createServer, type ApiKeyMap } from '../../core/index.js'; -import { workosPlugin } from '../index.js'; -import { getWorkOSStore } from '../store.js'; -import type { Store } from '../../core/index.js'; - -const apiKeys: ApiKeyMap = { sk_test_session: { environment: 'test' } }; - -function createTestApp() { - return createServer(workosPlugin, { port: 0, baseUrl: 'http://localhost:0', apiKeys }); -} - -describe('Session routes', () => { - let app: ReturnType['app']; - let store: Store; - - beforeEach(() => { - const server = createTestApp(); - app = server.app; - store = server.store; - }); - - const json = (res: Response) => res.json() as Promise; - - it('logout redirects to return_to when provided', async () => { - const ws = getWorkOSStore(store); - const user = ws.users.insert({ - object: 'user', - email: 'logout@test.com', - first_name: null, - last_name: null, - email_verified: false, - profile_picture_url: null, - last_sign_in_at: null, - external_id: null, - metadata: {}, - locale: null, - password_hash: null, - impersonator: null, - }); - const session = ws.sessions.insert({ - object: 'session', - user_id: user.id, - organization_id: null, - ip_address: null, - user_agent: null, - }); - - const res = await app.request( - `/user_management/sessions/logout?session_id=${session.id}&return_to=http://localhost:3000/logged-out`, - ); - expect(res.status).toBe(302); - expect(res.headers.get('location')).toBe('http://localhost:3000/logged-out'); - - // Session should be deleted - expect(ws.sessions.get(session.id)).toBeUndefined(); - }); - - it('logout returns JSON when no return_to', async () => { - const ws = getWorkOSStore(store); - const user = ws.users.insert({ - object: 'user', - email: 'logout2@test.com', - first_name: null, - last_name: null, - email_verified: false, - profile_picture_url: null, - last_sign_in_at: null, - external_id: null, - metadata: {}, - locale: null, - password_hash: null, - impersonator: null, - }); - const session = ws.sessions.insert({ - object: 'session', - user_id: user.id, - organization_id: null, - ip_address: null, - user_agent: null, - }); - - const res = await app.request(`/user_management/sessions/logout?session_id=${session.id}`); - expect(res.status).toBe(200); - const body = await json(res); - expect(body.success).toBe(true); - }); - - it('logout returns 422 when session_id missing', async () => { - const res = await app.request('/user_management/sessions/logout'); - expect(res.status).toBe(422); - }); - - it('logout succeeds even if session does not exist', async () => { - const res = await app.request('/user_management/sessions/logout?session_id=session_nonexistent'); - expect(res.status).toBe(200); - }); - - it('jwks endpoint returns keys', async () => { - const res = await app.request('/user_management/sessions/jwks/test_client'); - expect(res.status).toBe(200); - const body = await json(res); - expect(body.keys).toHaveLength(1); - expect(body.keys[0].alg).toBe('RS256'); - }); -}); diff --git a/src/emulate/workos/routes/sessions.ts b/src/emulate/workos/routes/sessions.ts deleted file mode 100644 index bacd1ae4..00000000 --- a/src/emulate/workos/routes/sessions.ts +++ /dev/null @@ -1,58 +0,0 @@ -import { type RouteContext, notFound, parseJsonBody, WorkOSApiError } from '../../core/index.js'; -import { getWorkOSStore } from '../store.js'; -import { formatSession, assertLocalRedirectUri } from '../helpers.js'; - -export function sessionRoutes(ctx: RouteContext): void { - const { app, store, jwt } = ctx; - const ws = getWorkOSStore(store); - - app.get('/user_management/users/:id/sessions', (c) => { - const user = ws.users.get(c.req.param('id')); - if (!user) throw notFound('User'); - - const sessions = ws.sessions.findBy('user_id', user.id); - return c.json({ - object: 'list', - data: sessions.map(formatSession), - list_metadata: { before: null, after: null }, - }); - }); - - app.post('/user_management/sessions/revoke', async (c) => { - const body = await parseJsonBody(c); - const sessionId = body.session_id as string | undefined; - if (!sessionId) { - throw new WorkOSApiError(400, 'session_id is required', 'invalid_request'); - } - - const session = ws.sessions.get(sessionId); - if (!session) throw notFound('Session'); - - ws.sessions.delete(session.id); - return c.json({ success: true }); - }); - - // Public endpoint — no auth required (security: []) - app.get('/user_management/sessions/logout', (c) => { - const url = new URL(c.req.url); - const sessionId = url.searchParams.get('session_id'); - const returnTo = url.searchParams.get('return_to'); - - if (!sessionId) { - throw new WorkOSApiError(422, 'session_id is required', 'invalid_request'); - } - - const session = ws.sessions.get(sessionId); - if (session) ws.sessions.delete(session.id); - - if (returnTo) { - assertLocalRedirectUri(returnTo); - return c.redirect(returnTo); - } - return c.json({ success: true }); - }); - - app.get('/user_management/sessions/jwks/:clientId', (c) => { - return c.json(jwt.getJWKS()); - }); -} diff --git a/src/emulate/workos/routes/sso.spec.ts b/src/emulate/workos/routes/sso.spec.ts deleted file mode 100644 index 0043b9c7..00000000 --- a/src/emulate/workos/routes/sso.spec.ts +++ /dev/null @@ -1,107 +0,0 @@ -import { describe, it, expect, beforeEach } from 'vitest'; -import { createServer, type ApiKeyMap } from '../../core/index.js'; -import { workosPlugin } from '../index.js'; - -const apiKeys: ApiKeyMap = { sk_test_sso: { environment: 'test' } }; -const headers = { Authorization: 'Bearer sk_test_sso', 'Content-Type': 'application/json' }; - -function createTestApp() { - return createServer(workosPlugin, { port: 0, baseUrl: 'http://localhost:0', apiKeys }); -} - -describe('SSO routes', () => { - let app: ReturnType['app']; - - beforeEach(() => { - app = createTestApp().app; - }); - - const req = (path: string, init?: RequestInit) => app.request(path, { headers, ...init }); - const json = (res: Response) => res.json() as Promise; - - async function createOrgWithConnection() { - const org = await json( - await req('/organizations', { - method: 'POST', - body: JSON.stringify({ name: 'SSO Org' }), - }), - ); - const conn = await json( - await req('/connections', { - method: 'POST', - body: JSON.stringify({ - name: 'Test SSO', - organization_id: org.id, - connection_type: 'GenericSAML', - domains: ['sso.example.com'], - }), - }), - ); - return { org, conn }; - } - - it('sso authorize flow with connection', async () => { - const { conn } = await createOrgWithConnection(); - - const res = await app.request( - `/sso/authorize?connection=${conn.id}&redirect_uri=http://localhost:3000/callback&state=abc`, - ); - expect(res.status).toBe(302); - const location = res.headers.get('location')!; - const url = new URL(location); - expect(url.searchParams.get('code')).toBeTruthy(); - expect(url.searchParams.get('state')).toBe('abc'); - }); - - it('sso token exchange returns profile and access_token', async () => { - const { conn } = await createOrgWithConnection(); - - // Get code - const authRes = await app.request( - `/sso/authorize?connection=${conn.id}&redirect_uri=http://localhost:3000/callback`, - ); - const location = authRes.headers.get('location')!; - const code = new URL(location).searchParams.get('code')!; - - // Exchange - const tokenRes = await app.request('/sso/token', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - grant_type: 'authorization_code', - code, - }), - }); - expect(tokenRes.status).toBe(200); - const body = await json(tokenRes); - expect(body.profile).toBeDefined(); - expect(body.profile.object).toBe('profile'); - expect(body.access_token).toBeDefined(); - }); - - it('returns 404 when no active connection found', async () => { - const res = await app.request( - '/sso/authorize?connection=conn_nonexistent&redirect_uri=http://localhost:3000/callback', - ); - expect(res.status).toBe(404); - }); - - it('jwks endpoint returns keys', async () => { - const res = await app.request('/sso/jwks'); - expect(res.status).toBe(200); - const body = await json(res); - expect(body.keys).toHaveLength(1); - expect(body.keys[0].alg).toBe('RS256'); - }); - - it('sso authorize rejects non-localhost redirect_uri', async () => { - const { conn } = await createOrgWithConnection(); - - const res = await app.request( - `/sso/authorize?connection=${conn.id}&redirect_uri=https://evil.example.com/callback`, - ); - expect(res.status).toBe(400); - const body = await json(res); - expect(body.code).toBe('invalid_redirect_uri'); - }); -}); diff --git a/src/emulate/workos/routes/sso.ts b/src/emulate/workos/routes/sso.ts deleted file mode 100644 index b912775a..00000000 --- a/src/emulate/workos/routes/sso.ts +++ /dev/null @@ -1,186 +0,0 @@ -import { type RouteContext, parseJsonBody, WorkOSApiError, generateId } from '../../core/index.js'; -import { getWorkOSStore } from '../store.js'; -import { formatSSOProfile, expiresIn, isExpired, assertLocalRedirectUri } from '../helpers.js'; -import type { WorkOSConnection } from '../entities.js'; -import { STORE_KEY_PREFIXES } from '../constants.js'; - -export function ssoRoutes(ctx: RouteContext): void { - const { app, store, jwt } = ctx; - const ws = getWorkOSStore(store); - - app.get('/sso/authorize', (c) => { - const url = new URL(c.req.url); - const redirectUri = url.searchParams.get('redirect_uri'); - const state = url.searchParams.get('state'); - const connectionId = url.searchParams.get('connection'); - const organizationId = url.searchParams.get('organization'); - const domainHint = url.searchParams.get('domain_hint'); - const loginHint = url.searchParams.get('login_hint'); - - if (!redirectUri) { - throw new WorkOSApiError(400, 'Missing required parameter: redirect_uri', 'invalid_request'); - } - assertLocalRedirectUri(redirectUri); - - let connection: WorkOSConnection | undefined; - - if (connectionId) { - connection = ws.connections.get(connectionId); - } else if (organizationId) { - connection = ws.connections.findBy('organization_id', organizationId).find((c) => c.state === 'active'); - } else if (domainHint) { - connection = ws.connections - .all() - .find((c) => c.state === 'active' && c.domains.some((d) => d.domain === domainHint)); - } - - if (!connection || connection.state !== 'active') { - throw new WorkOSApiError(404, 'No active connection found', 'connection_not_found'); - } - - const email = loginHint ?? `user@${connection.domains[0]?.domain ?? 'example.com'}`; - let profile = ws.ssoProfiles.findOneBy('email', email); - if (!profile || profile.connection_id !== connection.id) { - profile = ws.ssoProfiles.insert({ - object: 'profile', - connection_id: connection.id, - connection_type: connection.connection_type, - organization_id: connection.organization_id, - idp_id: `idp_${generateId('usr')}`, - email, - first_name: email.split('@')[0], - last_name: null, - groups: [], - raw_attributes: { email }, - }); - } - - const authCode = ws.ssoAuthorizations.insert({ - code: generateId('sso_code'), - connection_id: connection.id, - organization_id: connection.organization_id, - profile_id: profile.id, - redirect_uri: redirectUri, - state, - expires_at: expiresIn(10), - }); - - const redirect = new URL(redirectUri); - redirect.searchParams.set('code', authCode.code); - if (state) redirect.searchParams.set('state', state); - return c.redirect(redirect.toString()); - }); - - app.post('/sso/token', async (c) => { - const body = await parseJsonBody(c); - const grantType = body.grant_type as string; - const code = body.code as string; - - if (grantType !== 'authorization_code') { - throw new WorkOSApiError(400, 'Unsupported grant_type', 'invalid_request'); - } - if (!code) { - throw new WorkOSApiError(400, 'code is required', 'invalid_request'); - } - - const auth = ws.ssoAuthorizations.findOneBy('code', code); - if (!auth) { - throw new WorkOSApiError(400, 'Invalid authorization code', 'invalid_code'); - } - if (isExpired(auth.expires_at)) { - ws.ssoAuthorizations.delete(auth.id); - throw new WorkOSApiError(400, 'Authorization code has expired', 'expired_code'); - } - - const profile = ws.ssoProfiles.get(auth.profile_id); - if (!profile) { - throw new WorkOSApiError(500, 'Profile not found', 'server_error'); - } - - ws.ssoAuthorizations.delete(auth.id); - - const accessToken = jwt.sign({ - sub: profile.id, - aud: (body.client_id as string) ?? 'workos-emulate', - org_id: auth.organization_id, - }); - - store.setData(`${STORE_KEY_PREFIXES.ssoToken}${accessToken}`, profile.id); - - return c.json({ - profile: formatSSOProfile(profile), - access_token: accessToken, - }); - }); - - app.get('/sso/profile', (c) => { - const authHeader = c.req.header('Authorization'); - if (!authHeader) { - throw new WorkOSApiError(401, 'Unauthorized', 'unauthorized'); - } - const token = authHeader.replace(/^Bearer\s+/i, '').trim(); - - const profileId = store.getData(`${STORE_KEY_PREFIXES.ssoToken}${token}`); - if (!profileId) { - try { - const payload = jwt.verify(token); - const profile = ws.ssoProfiles.get(payload.sub); - if (profile) return c.json(formatSSOProfile(profile)); - } catch { - // fall through - } - throw new WorkOSApiError(401, 'Invalid access token', 'unauthorized'); - } - - const profile = ws.ssoProfiles.get(profileId); - if (!profile) { - throw new WorkOSApiError(404, 'Profile not found', 'not_found'); - } - - return c.json(formatSSOProfile(profile)); - }); - - app.get('/sso/jwks', (c) => { - return c.json(jwt.getJWKS()); - }); - - // SSO Single Logout — generate logout token - app.post('/sso/logout/authorize', async (c) => { - const body = await parseJsonBody(c); - const profileId = body.profile_id as string; - if (!profileId) { - throw new WorkOSApiError(400, 'profile_id is required', 'invalid_request'); - } - - const profile = ws.ssoProfiles.get(profileId); - if (!profile) { - throw new WorkOSApiError(404, 'Profile not found', 'not_found'); - } - - const logoutToken = generateId('sso_logout'); - store.setData(`${STORE_KEY_PREFIXES.ssoLogout}${logoutToken}`, profile.id); - - return c.json({ - logout_token: logoutToken, - logout_url: `${ctx.baseUrl}/sso/logout?logout_token=${logoutToken}`, - }); - }); - - // SSO Single Logout — redirect (public, no auth) - app.get('/sso/logout', (c) => { - const url = new URL(c.req.url); - const logoutToken = url.searchParams.get('logout_token'); - - if (!logoutToken) { - throw new WorkOSApiError(400, 'logout_token is required', 'invalid_request'); - } - - const profileId = store.getData(`${STORE_KEY_PREFIXES.ssoLogout}${logoutToken}`); - if (!profileId) { - throw new WorkOSApiError(400, 'Invalid logout token', 'invalid_logout_token'); - } - - store.setData(`${STORE_KEY_PREFIXES.ssoLogout}${logoutToken}`, undefined); - return c.json({ success: true }); - }); -} diff --git a/src/emulate/workos/routes/user-features.spec.ts b/src/emulate/workos/routes/user-features.spec.ts deleted file mode 100644 index 7d810b8b..00000000 --- a/src/emulate/workos/routes/user-features.spec.ts +++ /dev/null @@ -1,133 +0,0 @@ -import { describe, it, expect, beforeEach } from 'vitest'; -import { createServer, type ApiKeyMap } from '../../core/index.js'; -import { workosPlugin } from '../index.js'; -import { getWorkOSStore } from '../store.js'; - -const apiKeys: ApiKeyMap = { sk_test_uf: { environment: 'test' } }; -const headers = { Authorization: 'Bearer sk_test_uf', 'Content-Type': 'application/json' }; - -function createTestApp() { - return createServer(workosPlugin, { port: 0, baseUrl: 'http://localhost:0', apiKeys }); -} - -describe('User feature routes', () => { - let app: ReturnType['app']; - let store: ReturnType['store']; - - beforeEach(() => { - const result = createTestApp(); - app = result.app; - store = result.store; - }); - - const req = (path: string, init?: RequestInit) => app.request(path, { headers, ...init }); - const json = (res: Response) => res.json() as Promise; - - async function createUser(email: string) { - return json( - await req('/user_management/users', { - method: 'POST', - body: JSON.stringify({ email }), - }), - ); - } - - describe('Authorized Applications', () => { - it('lists authorized applications for user', async () => { - const user = await createUser('apps@test.com'); - const ws = getWorkOSStore(store); - ws.authorizedApplications.insert({ - object: 'authorized_application', - user_id: user.id, - name: 'Test App', - redirect_uri: 'http://localhost:3000/callback', - }); - - const res = await req(`/user_management/users/${user.id}/authorized_applications`); - expect(res.status).toBe(200); - const list = await json(res); - expect(list.data).toHaveLength(1); - expect(list.data[0].name).toBe('Test App'); - }); - - it('deletes an authorized application', async () => { - const user = await createUser('revoke-app@test.com'); - const ws = getWorkOSStore(store); - const appItem = ws.authorizedApplications.insert({ - object: 'authorized_application', - user_id: user.id, - name: 'Revoke App', - redirect_uri: 'http://localhost:3000/callback', - }); - - const delRes = await req(`/user_management/users/${user.id}/authorized_applications/${appItem.id}`, { - method: 'DELETE', - }); - expect(delRes.status).toBe(204); - - const listRes = await json(await req(`/user_management/users/${user.id}/authorized_applications`)); - expect(listRes.data).toHaveLength(0); - }); - - it('returns 404 for non-existent user', async () => { - const res = await req('/user_management/users/user_nonexistent/authorized_applications'); - expect(res.status).toBe(404); - }); - }); - - describe('Connected Accounts', () => { - it('gets connected account by provider slug', async () => { - const user = await createUser('connected@test.com'); - const ws = getWorkOSStore(store); - ws.connectedAccounts.insert({ - object: 'connected_account', - user_id: user.id, - provider: 'github', - provider_id: 'gh_123', - }); - - const res = await req(`/user_management/users/${user.id}/connected_accounts/github`); - expect(res.status).toBe(200); - const data = await json(res); - expect(data.provider).toBe('github'); - expect(data.provider_id).toBe('gh_123'); - }); - - it('returns 404 for unknown provider', async () => { - const user = await createUser('no-provider@test.com'); - const res = await req(`/user_management/users/${user.id}/connected_accounts/unknown`); - expect(res.status).toBe(404); - }); - }); - - describe('Data Providers', () => { - it('lists data providers from pipe connections', async () => { - const user = await createUser('pipes@test.com'); - const ws = getWorkOSStore(store); - ws.pipeConnections.insert({ - object: 'pipe_connection', - user_id: user.id, - provider: 'github', - scopes: ['read'], - status: 'connected', - external_account_id: null, - }); - - const res = await req(`/user_management/users/${user.id}/data_providers`); - expect(res.status).toBe(200); - const list = await json(res); - expect(list.data).toHaveLength(1); - expect(list.data[0].provider).toBe('github'); - }); - }); - - describe('Feature Flags', () => { - it('returns empty list when no flags exist', async () => { - const user = await createUser('flags@test.com'); - const res = await req(`/user_management/users/${user.id}/feature-flags`); - expect(res.status).toBe(200); - const list = await json(res); - expect(list.data).toEqual([]); - }); - }); -}); diff --git a/src/emulate/workos/routes/user-features.ts b/src/emulate/workos/routes/user-features.ts deleted file mode 100644 index e1cd1db7..00000000 --- a/src/emulate/workos/routes/user-features.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { type RouteContext, notFound } from '../../core/index.js'; -import { getWorkOSStore } from '../store.js'; -import { formatAuthorizedApplication, formatConnectedAccount, formatPipeConnection } from '../helpers.js'; - -export function userFeatureRoutes(ctx: RouteContext): void { - const { app, store } = ctx; - const ws = getWorkOSStore(store); - - app.get('/user_management/users/:user_id/authorized_applications', (c) => { - const user = ws.users.get(c.req.param('user_id')); - if (!user) throw notFound('User'); - - const apps = ws.authorizedApplications.findBy('user_id', user.id); - return c.json({ - object: 'list', - data: apps.map(formatAuthorizedApplication), - list_metadata: { before: null, after: null }, - }); - }); - - app.delete('/user_management/users/:user_id/authorized_applications/:application_id', (c) => { - const user = ws.users.get(c.req.param('user_id')); - if (!user) throw notFound('User'); - - const appItem = ws.authorizedApplications.get(c.req.param('application_id')); - if (!appItem || appItem.user_id !== user.id) throw notFound('Authorized Application'); - - ws.authorizedApplications.delete(appItem.id); - return c.body(null, 204); - }); - - app.get('/user_management/users/:user_id/connected_accounts/:slug', (c) => { - const user = ws.users.get(c.req.param('user_id')); - if (!user) throw notFound('User'); - - const slug = c.req.param('slug'); - const account = ws.connectedAccounts.findBy('user_id', user.id).find((a) => a.provider === slug); - - if (!account) throw notFound('Connected Account'); - return c.json(formatConnectedAccount(account)); - }); - - app.get('/user_management/users/:user_id/data_providers', (c) => { - const user = ws.users.get(c.req.param('user_id')); - if (!user) throw notFound('User'); - - const pipes = ws.pipeConnections.findBy('user_id', user.id); - return c.json({ - object: 'list', - data: pipes.map(formatPipeConnection), - list_metadata: { before: null, after: null }, - }); - }); -} diff --git a/src/emulate/workos/routes/users.spec.ts b/src/emulate/workos/routes/users.spec.ts deleted file mode 100644 index 658f0745..00000000 --- a/src/emulate/workos/routes/users.spec.ts +++ /dev/null @@ -1,225 +0,0 @@ -import { describe, it, expect, beforeEach } from 'vitest'; -import { createServer, type ApiKeyMap } from '../../core/index.js'; -import { workosPlugin } from '../index.js'; - -const apiKeys: ApiKeyMap = { sk_test_users: { environment: 'test' } }; -const headers = { Authorization: 'Bearer sk_test_users', 'Content-Type': 'application/json' }; - -function createTestApp() { - return createServer(workosPlugin, { port: 0, baseUrl: 'http://localhost:0', apiKeys }); -} - -describe('User routes', () => { - let app: ReturnType['app']; - - beforeEach(() => { - const result = createTestApp(); - app = result.app; - }); - - const req = (path: string, init?: RequestInit) => app.request(path, { headers, ...init }); - const json = (res: Response) => res.json() as Promise; - - it('creates a user', async () => { - const res = await req('/user_management/users', { - method: 'POST', - body: JSON.stringify({ email: 'alice@test.com', first_name: 'Alice', password: 'pass123' }), - }); - expect(res.status).toBe(201); - const user = await json(res); - expect(user.object).toBe('user'); - expect(user.email).toBe('alice@test.com'); - expect(user.id).toMatch(/^user_/); - expect(user.password_hash).toBeUndefined(); - }); - - it('rejects duplicate email', async () => { - await req('/user_management/users', { - method: 'POST', - body: JSON.stringify({ email: 'dup@test.com' }), - }); - const res = await req('/user_management/users', { - method: 'POST', - body: JSON.stringify({ email: 'dup@test.com' }), - }); - expect(res.status).toBe(409); - expect((await json(res)).code).toBe('user_already_exists'); - }); - - it('gets user by id', async () => { - const created = await json( - await req('/user_management/users', { - method: 'POST', - body: JSON.stringify({ email: 'get@test.com' }), - }), - ); - - const res = await req(`/user_management/users/${created.id}`); - expect(res.status).toBe(200); - expect((await json(res)).email).toBe('get@test.com'); - }); - - it('lists users filtered by email', async () => { - await req('/user_management/users', { - method: 'POST', - body: JSON.stringify({ email: 'a@test.com' }), - }); - await req('/user_management/users', { - method: 'POST', - body: JSON.stringify({ email: 'b@test.com' }), - }); - - const list = await json(await req('/user_management/users?email=a@test.com')); - expect(list.data).toHaveLength(1); - expect(list.data[0].email).toBe('a@test.com'); - }); - - it('updates a user', async () => { - const created = await json( - await req('/user_management/users', { - method: 'POST', - body: JSON.stringify({ email: 'update@test.com' }), - }), - ); - - const res = await req(`/user_management/users/${created.id}`, { - method: 'PUT', - body: JSON.stringify({ first_name: 'Updated' }), - }); - expect(res.status).toBe(200); - expect((await json(res)).first_name).toBe('Updated'); - }); - - it('deletes a user', async () => { - const user = await json( - await req('/user_management/users', { - method: 'POST', - body: JSON.stringify({ email: 'delete@test.com' }), - }), - ); - - const delRes = await req(`/user_management/users/${user.id}`, { method: 'DELETE' }); - expect(delRes.status).toBe(204); - - const getRes = await req(`/user_management/users/${user.id}`); - expect(getRes.status).toBe(404); - }); -}); - -describe('Email Verification', () => { - let app: ReturnType['app']; - - beforeEach(() => { - app = createTestApp().app; - }); - - const req = (path: string, init?: RequestInit) => app.request(path, { headers, ...init }); - const json = (res: Response) => res.json() as Promise; - - it('send → confirm flow', async () => { - const user = await json( - await req('/user_management/users', { - method: 'POST', - body: JSON.stringify({ email: 'verify@test.com' }), - }), - ); - expect(user.email_verified).toBe(false); - - const ev = await json(await req(`/user_management/users/${user.id}/email_verification/send`, { method: 'POST' })); - expect(ev.code).toMatch(/^\d{6}$/); - - const confirmed = await json( - await req(`/user_management/users/${user.id}/email_verification/confirm`, { - method: 'POST', - body: JSON.stringify({ code: ev.code }), - }), - ); - expect(confirmed.email_verified).toBe(true); - }); -}); - -describe('Password Reset', () => { - let app: ReturnType['app']; - - beforeEach(() => { - app = createTestApp().app; - }); - - const req = (path: string, init?: RequestInit) => app.request(path, { headers, ...init }); - const json = (res: Response) => res.json() as Promise; - - it('create → confirm flow', async () => { - await req('/user_management/users', { - method: 'POST', - body: JSON.stringify({ email: 'reset@test.com', password: 'old' }), - }); - - const pr = await json( - await req('/user_management/password_reset', { - method: 'POST', - body: JSON.stringify({ email: 'reset@test.com' }), - }), - ); - expect(pr.token).toBeDefined(); - - const confirmRes = await req('/user_management/password_reset/confirm', { - method: 'POST', - body: JSON.stringify({ token: pr.token, new_password: 'new' }), - }); - expect(confirmRes.status).toBe(200); - }); - - it('returns 404 when confirming reset after user deletion', async () => { - const user = await json( - await req('/user_management/users', { - method: 'POST', - body: JSON.stringify({ email: 'gone@test.com', password: 'old' }), - }), - ); - - const pr = await json( - await req('/user_management/password_reset', { - method: 'POST', - body: JSON.stringify({ email: 'gone@test.com' }), - }), - ); - - // Delete the user while the reset token is still valid - await req(`/user_management/users/${user.id}`, { method: 'DELETE' }); - - // Password-reset artifacts should have been cleaned up by user deletion, - // so the token is now invalid - const confirmRes = await req('/user_management/password_reset/confirm', { - method: 'POST', - body: JSON.stringify({ token: pr.token, new_password: 'new' }), - }); - // Token was cleaned up → 400 invalid token (not a 500) - expect(confirmRes.status).toBeLessThan(500); - }); - - it('deleting a user cleans up password resets, verifications, and magic auths', async () => { - const user = await json( - await req('/user_management/users', { - method: 'POST', - body: JSON.stringify({ email: 'cleanup@test.com', password: 'pw' }), - }), - ); - - // Create a password reset - await req('/user_management/password_reset', { - method: 'POST', - body: JSON.stringify({ email: 'cleanup@test.com' }), - }); - - // Create an email verification - await req(`/user_management/users/${user.id}/email_verification/send`, { method: 'POST' }); - - // Delete the user - const delRes = await req(`/user_management/users/${user.id}`, { method: 'DELETE' }); - expect(delRes.status).toBe(204); - - // Verify the user is gone - const getRes = await req(`/user_management/users/${user.id}`); - expect(getRes.status).toBe(404); - }); -}); diff --git a/src/emulate/workos/routes/users.ts b/src/emulate/workos/routes/users.ts deleted file mode 100644 index 21f8184f..00000000 --- a/src/emulate/workos/routes/users.ts +++ /dev/null @@ -1,143 +0,0 @@ -import { - type RouteContext, - notFound, - validationError, - parseJsonBody, - WorkOSApiError, - parseListParams, -} from '../../core/index.js'; -import { getWorkOSStore } from '../store.js'; -import { formatUser, formatIdentity, hashPassword, formatListResponse } from '../helpers.js'; - -export function userRoutes(ctx: RouteContext): void { - const { app, store } = ctx; - const ws = getWorkOSStore(store); - - app.post('/user_management/users', async (c) => { - const body = await parseJsonBody(c); - const email = body.email as string | undefined; - if (!email) { - throw validationError('email is required', [{ field: 'email', code: 'required' }]); - } - - const existing = ws.users.findOneBy('email', email); - if (existing) { - throw new WorkOSApiError(409, 'A user with this email already exists', 'user_already_exists'); - } - - const password = body.password as string | undefined; - const user = ws.users.insert({ - object: 'user', - email, - first_name: (body.first_name as string) ?? null, - last_name: (body.last_name as string) ?? null, - email_verified: (body.email_verified as boolean) ?? false, - profile_picture_url: null, - last_sign_in_at: null, - external_id: (body.external_id as string) ?? null, - metadata: (body.metadata as Record) ?? {}, - locale: null, - password_hash: password ? hashPassword(password) : null, - impersonator: null, - }); - - return c.json(formatUser(user), 201); - }); - - app.get('/user_management/users', (c) => { - const url = new URL(c.req.url); - const params = parseListParams(url); - const emailFilter = url.searchParams.get('email') ?? undefined; - const orgFilter = url.searchParams.get('organization_id') ?? undefined; - - let orgUserIds: Set | undefined; - if (orgFilter) { - orgUserIds = new Set(ws.organizationMemberships.findBy('organization_id', orgFilter).map((m) => m.user_id)); - } - - const result = ws.users.list({ - ...params, - filter: (user) => { - if (emailFilter && user.email !== emailFilter) return false; - if (orgUserIds && !orgUserIds.has(user.id)) return false; - return true; - }, - }); - - return c.json(formatListResponse(result, formatUser)); - }); - - app.get('/user_management/users/:id', (c) => { - const user = ws.users.get(c.req.param('id')); - if (!user) throw notFound('User'); - return c.json(formatUser(user)); - }); - - app.get('/user_management/users/external_id/:external_id', (c) => { - const user = ws.users.findOneBy('external_id', c.req.param('external_id')); - if (!user) throw notFound('User'); - return c.json(formatUser(user)); - }); - - app.put('/user_management/users/:id', async (c) => { - const user = ws.users.get(c.req.param('id')); - if (!user) throw notFound('User'); - - const body = await parseJsonBody(c); - const updates: Record = {}; - - if ('first_name' in body) updates.first_name = body.first_name ?? null; - if ('last_name' in body) updates.last_name = body.last_name ?? null; - if ('email_verified' in body) updates.email_verified = body.email_verified; - if ('external_id' in body) updates.external_id = body.external_id ?? null; - if ('metadata' in body) updates.metadata = body.metadata ?? {}; - if ('password' in body && body.password) { - updates.password_hash = hashPassword(body.password as string); - } - - const updated = ws.users.update(user.id, updates); - return c.json(formatUser(updated!)); - }); - - app.delete('/user_management/users/:id', (c) => { - const user = ws.users.get(c.req.param('id')); - if (!user) throw notFound('User'); - - for (const s of ws.sessions.findBy('user_id', user.id)) { - ws.sessions.delete(s.id); - } - for (const m of ws.organizationMemberships.findBy('user_id', user.id)) { - ws.organizationMemberships.delete(m.id); - } - for (const f of ws.authFactors.findBy('user_id', user.id)) { - ws.authFactors.delete(f.id); - } - for (const i of ws.identities.findBy('user_id', user.id)) { - ws.identities.delete(i.id); - } - for (const pr of ws.passwordResets.findBy('user_id', user.id)) { - ws.passwordResets.delete(pr.id); - } - for (const ev of ws.emailVerifications.findBy('user_id', user.id)) { - ws.emailVerifications.delete(ev.id); - } - for (const ma of ws.magicAuths.findBy('user_id', user.id)) { - ws.magicAuths.delete(ma.id); - } - - ws.users.delete(user.id); - return c.body(null, 204); - }); - - app.get('/user_management/users/:id/identities', (c) => { - const user = ws.users.get(c.req.param('id')); - if (!user) throw notFound('User'); - - const identities = ws.identities.findBy('user_id', user.id); - return c.json({ - object: 'list', - data: identities.map(formatIdentity), - list_metadata: { before: null, after: null }, - }); - }); -} diff --git a/src/emulate/workos/routes/webhook-endpoints.spec.ts b/src/emulate/workos/routes/webhook-endpoints.spec.ts deleted file mode 100644 index 0cc6f343..00000000 --- a/src/emulate/workos/routes/webhook-endpoints.spec.ts +++ /dev/null @@ -1,145 +0,0 @@ -import { describe, it, expect, beforeEach } from 'vitest'; -import { createServer, type ApiKeyMap } from '../../core/index.js'; -import { workosPlugin } from '../index.js'; - -const apiKeys: ApiKeyMap = { sk_test_wh: { environment: 'test' } }; -const headers = { Authorization: 'Bearer sk_test_wh', 'Content-Type': 'application/json' }; - -function createTestApp() { - return createServer(workosPlugin, { port: 0, baseUrl: 'http://localhost:0', apiKeys }); -} - -describe('Webhook endpoint routes', () => { - let app: ReturnType['app']; - - beforeEach(() => { - app = createTestApp().app; - }); - - const req = (path: string, init?: RequestInit) => app.request(path, { headers, ...init }); - const json = (res: Response) => res.json() as Promise; - - it('creates a webhook endpoint with auto-generated secret', async () => { - const res = await req('/webhook_endpoints', { - method: 'POST', - body: JSON.stringify({ endpoint_url: 'http://localhost:3000/webhooks' }), - }); - expect(res.status).toBe(201); - const ep = await json(res); - expect(ep.object).toBe('webhook_endpoint'); - expect(ep.endpoint_url).toBe('http://localhost:3000/webhooks'); - expect(ep.secret).toHaveLength(64); // full hex secret on create - expect(ep.enabled).toBe(true); - expect(ep.events).toEqual([]); - expect(ep.id).toMatch(/^we_/); - }); - - it('creates with custom secret and event filter', async () => { - const res = await req('/webhook_endpoints', { - method: 'POST', - body: JSON.stringify({ - endpoint_url: 'http://localhost:3000/webhooks', - secret: 'my_custom_secret', - events: ['user.created', 'user.deleted'], - description: 'Test endpoint', - }), - }); - const ep = await json(res); - expect(ep.secret).toBe('my_custom_secret'); - expect(ep.events).toEqual(['user.created', 'user.deleted']); - expect(ep.description).toBe('Test endpoint'); - }); - - it('masks secret on GET', async () => { - const createRes = await req('/webhook_endpoints', { - method: 'POST', - body: JSON.stringify({ endpoint_url: 'http://localhost:3000/webhooks' }), - }); - const created = await json(createRes); - - const getRes = await req(`/webhook_endpoints/${created.id}`); - const ep = await json(getRes); - expect(ep.secret).toContain('****'); - expect(ep.secret).not.toBe(created.secret); - }); - - it('masks secret on list', async () => { - await req('/webhook_endpoints', { - method: 'POST', - body: JSON.stringify({ endpoint_url: 'http://localhost:3000/webhooks' }), - }); - - const listRes = await req('/webhook_endpoints'); - const list = await json(listRes); - expect(list.data).toHaveLength(1); - expect(list.data[0].secret).toContain('****'); - }); - - it('updates a webhook endpoint', async () => { - const createRes = await req('/webhook_endpoints', { - method: 'POST', - body: JSON.stringify({ endpoint_url: 'http://localhost:3000/webhooks' }), - }); - const created = await json(createRes); - - const updateRes = await req(`/webhook_endpoints/${created.id}`, { - method: 'PUT', - body: JSON.stringify({ enabled: false, events: ['user.created'] }), - }); - const updated = await json(updateRes); - expect(updated.enabled).toBe(false); - expect(updated.events).toEqual(['user.created']); - }); - - it('deletes a webhook endpoint', async () => { - const createRes = await req('/webhook_endpoints', { - method: 'POST', - body: JSON.stringify({ endpoint_url: 'http://localhost:3000/webhooks' }), - }); - const created = await json(createRes); - - const delRes = await req(`/webhook_endpoints/${created.id}`, { method: 'DELETE' }); - expect(delRes.status).toBe(204); - - const getRes = await req(`/webhook_endpoints/${created.id}`); - expect(getRes.status).toBe(404); - }); - - it('accepts legacy url on create for backward compatibility', async () => { - const res = await req('/webhook_endpoints', { - method: 'POST', - body: JSON.stringify({ url: 'http://localhost:3000/legacy' }), - }); - expect(res.status).toBe(201); - const ep = await json(res); - expect(ep.endpoint_url).toBe('http://localhost:3000/legacy'); - }); - - it('accepts legacy url on update for backward compatibility', async () => { - const createRes = await req('/webhook_endpoints', { - method: 'POST', - body: JSON.stringify({ endpoint_url: 'http://localhost:3000/webhooks' }), - }); - const created = await json(createRes); - const updateRes = await req(`/webhook_endpoints/${created.id}`, { - method: 'PUT', - body: JSON.stringify({ url: 'http://localhost:3000/updated-legacy' }), - }); - expect(updateRes.status).toBe(200); - const updated = await json(updateRes); - expect(updated.endpoint_url).toBe('http://localhost:3000/updated-legacy'); - }); - - it('returns 422 for missing url', async () => { - const res = await req('/webhook_endpoints', { - method: 'POST', - body: JSON.stringify({}), - }); - expect(res.status).toBe(422); - }); - - it('returns 404 for unknown endpoint', async () => { - const res = await req('/webhook_endpoints/we_nonexistent'); - expect(res.status).toBe(404); - }); -}); diff --git a/src/emulate/workos/routes/webhook-endpoints.ts b/src/emulate/workos/routes/webhook-endpoints.ts deleted file mode 100644 index 7c36d907..00000000 --- a/src/emulate/workos/routes/webhook-endpoints.ts +++ /dev/null @@ -1,73 +0,0 @@ -import { randomBytes } from 'node:crypto'; -import { type RouteContext, notFound, validationError, parseJsonBody, parseListParams } from '../../core/index.js'; -import { getWorkOSStore } from '../store.js'; -import { formatWebhookEndpoint, formatListResponse } from '../helpers.js'; - -export function webhookEndpointRoutes(ctx: RouteContext): void { - const { app, store } = ctx; - const ws = getWorkOSStore(store); - - app.post('/webhook_endpoints', async (c) => { - const body = await parseJsonBody(c); - const endpointUrl = (body.endpoint_url ?? body.url) as string | undefined; - if (!endpointUrl || typeof endpointUrl !== 'string') { - throw validationError('endpoint_url is required', [{ field: 'endpoint_url', code: 'required' }]); - } - - const secret = (body.secret as string) ?? randomBytes(32).toString('hex'); - - const endpoint = ws.webhookEndpoints.insert({ - object: 'webhook_endpoint', - endpoint_url: endpointUrl, - secret, - enabled: body.enabled !== false, - events: Array.isArray(body.events) ? (body.events as string[]) : [], - description: (body.description as string) ?? null, - }); - - return c.json(formatWebhookEndpoint(endpoint, { includeSecret: true }), 201); - }); - - app.get('/webhook_endpoints', (c) => { - const url = new URL(c.req.url); - const params = parseListParams(url); - - const result = ws.webhookEndpoints.list(params); - return c.json(formatListResponse(result, (ep) => formatWebhookEndpoint(ep))); - }); - - app.get('/webhook_endpoints/:id', (c) => { - const ep = ws.webhookEndpoints.get(c.req.param('id')); - if (!ep) throw notFound('WebhookEndpoint'); - return c.json(formatWebhookEndpoint(ep)); - }); - - app.put('/webhook_endpoints/:id', async (c) => { - const ep = ws.webhookEndpoints.get(c.req.param('id')); - if (!ep) throw notFound('WebhookEndpoint'); - - const body = await parseJsonBody(c); - const updates: Record = {}; - - if ('endpoint_url' in body || 'url' in body) { - const newUrl = (body.endpoint_url ?? body.url) as string | undefined; - if (!newUrl || typeof newUrl !== 'string') { - throw validationError('endpoint_url is required', [{ field: 'endpoint_url', code: 'required' }]); - } - updates.endpoint_url = newUrl; - } - if ('enabled' in body) updates.enabled = !!body.enabled; - if ('events' in body) updates.events = Array.isArray(body.events) ? body.events : []; - if ('description' in body) updates.description = body.description ?? null; - - const updated = ws.webhookEndpoints.update(ep.id, updates); - return c.json(formatWebhookEndpoint(updated!)); - }); - - app.delete('/webhook_endpoints/:id', (c) => { - const ep = ws.webhookEndpoints.get(c.req.param('id')); - if (!ep) throw notFound('WebhookEndpoint'); - ws.webhookEndpoints.delete(ep.id); - return c.body(null, 204); - }); -} diff --git a/src/emulate/workos/routes/widgets.spec.ts b/src/emulate/workos/routes/widgets.spec.ts deleted file mode 100644 index ce3df860..00000000 --- a/src/emulate/workos/routes/widgets.spec.ts +++ /dev/null @@ -1,62 +0,0 @@ -import { describe, it, expect, beforeEach } from 'vitest'; -import { createServer, type ApiKeyMap } from '../../core/index.js'; -import { workosPlugin } from '../index.js'; - -const apiKeys: ApiKeyMap = { sk_test_widgets: { environment: 'test' } }; -const headers = { Authorization: 'Bearer sk_test_widgets', 'Content-Type': 'application/json' }; - -function createTestApp() { - return createServer(workosPlugin, { port: 0, baseUrl: 'http://localhost:0', apiKeys }); -} - -describe('Widget routes', () => { - let app: ReturnType['app']; - - beforeEach(() => { - app = createTestApp().app; - }); - - const req = (path: string, init?: RequestInit) => app.request(path, { headers, ...init }); - const json = (res: Response) => res.json() as Promise; - - it('generates a widgets token', async () => { - const res = await req('/widgets/token', { - method: 'POST', - body: JSON.stringify({ - organization_id: 'org_123', - user_id: 'user_456', - scopes: ['widgets:users-table:manage'], - }), - }); - expect(res.status).toBe(200); - const data = await json(res); - expect(data.token).toBeDefined(); - expect(typeof data.token).toBe('string'); - // JWT has 3 dot-separated parts - expect(data.token.split('.')).toHaveLength(3); - }); - - it('requires organization_id', async () => { - const res = await req('/widgets/token', { - method: 'POST', - body: JSON.stringify({ user_id: 'user_456', scopes: ['read'] }), - }); - expect(res.status).toBe(422); - }); - - it('requires user_id', async () => { - const res = await req('/widgets/token', { - method: 'POST', - body: JSON.stringify({ organization_id: 'org_123', scopes: ['read'] }), - }); - expect(res.status).toBe(422); - }); - - it('requires scopes', async () => { - const res = await req('/widgets/token', { - method: 'POST', - body: JSON.stringify({ organization_id: 'org_123', user_id: 'user_456' }), - }); - expect(res.status).toBe(422); - }); -}); diff --git a/src/emulate/workos/routes/widgets.ts b/src/emulate/workos/routes/widgets.ts deleted file mode 100644 index e095c302..00000000 --- a/src/emulate/workos/routes/widgets.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { type RouteContext, parseJsonBody, validationError } from '../../core/index.js'; - -export function widgetRoutes(ctx: RouteContext): void { - const { app, jwt } = ctx; - - app.post('/widgets/token', async (c) => { - const body = await parseJsonBody(c); - const organizationId = body.organization_id as string | undefined; - const userId = body.user_id as string | undefined; - const scopes = body.scopes as string[] | undefined; - - if (!organizationId) { - throw validationError('organization_id is required', [{ field: 'organization_id', code: 'required' }]); - } - if (!userId) { - throw validationError('user_id is required', [{ field: 'user_id', code: 'required' }]); - } - if (!scopes || !Array.isArray(scopes)) { - throw validationError('scopes is required', [{ field: 'scopes', code: 'required' }]); - } - - const token = jwt.sign({ - sub: userId, - org_id: organizationId, - aud: 'widgets', - scopes, - } as any); - - return c.json({ token }); - }); -} diff --git a/src/emulate/workos/store.ts b/src/emulate/workos/store.ts deleted file mode 100644 index 3ff12271..00000000 --- a/src/emulate/workos/store.ts +++ /dev/null @@ -1,246 +0,0 @@ -import { type Store, type Collection, ID_PREFIXES } from '../core/index.js'; -import { STORE_KEYS } from './constants.js'; -import type { - WorkOSOrganization, - WorkOSOrganizationDomain, - WorkOSOrganizationMembership, - WorkOSUser, - WorkOSSession, - WorkOSEmailVerification, - WorkOSPasswordReset, - WorkOSMagicAuth, - WorkOSAuthenticationFactor, - WorkOSAuthorizationCode, - WorkOSIdentity, - WorkOSConnection, - WorkOSSSOProfile, - WorkOSSSOAuthorization, - WorkOSPipeConnection, - WorkOSRefreshToken, - WorkOSAuthenticationChallenge, - WorkOSDeviceAuthorization, - WorkOSInvitation, - WorkOSRedirectUri, - WorkOSCorsOrigin, - WorkOSAuthorizedApplication, - WorkOSConnectedAccount, - WorkOSRole, - WorkOSPermission, - WorkOSRolePermission, - WorkOSAuthorizationResource, - WorkOSRoleAssignment, - WorkOSDirectory, - WorkOSDirectoryUser, - WorkOSDirectoryGroup, - WorkOSAuditLogAction, - WorkOSAuditLogEvent, - WorkOSAuditLogExport, - WorkOSFeatureFlag, - WorkOSFlagTarget, - WorkOSConnectApplication, - WorkOSClientSecret, - WorkOSDataIntegrationAuth, - WorkOSRadarAttempt, - WorkOSApiKey, - WorkOSEvent, - WorkOSWebhookEndpoint, -} from './entities.js'; - -export interface WorkOSStore { - organizations: Collection; - organizationDomains: Collection; - organizationMemberships: Collection; - users: Collection; - sessions: Collection; - emailVerifications: Collection; - passwordResets: Collection; - magicAuths: Collection; - authFactors: Collection; - authCodes: Collection; - identities: Collection; - connections: Collection; - ssoProfiles: Collection; - ssoAuthorizations: Collection; - pipeConnections: Collection; - refreshTokens: Collection; - authChallenges: Collection; - deviceAuthorizations: Collection; - invitations: Collection; - redirectUris: Collection; - corsOrigins: Collection; - authorizedApplications: Collection; - connectedAccounts: Collection; - roles: Collection; - permissions: Collection; - rolePermissions: Collection; - authorizationResources: Collection; - roleAssignments: Collection; - directories: Collection; - directoryUsers: Collection; - directoryGroups: Collection; - auditLogActions: Collection; - auditLogEvents: Collection; - auditLogExports: Collection; - featureFlags: Collection; - flagTargets: Collection; - connectApplications: Collection; - clientSecrets: Collection; - dataIntegrationAuths: Collection; - radarAttempts: Collection; - apiKeyRecords: Collection; - events: Collection; - webhookEndpoints: Collection; -} - -export function getWorkOSStore(store: Store): WorkOSStore { - const cached = store.getData(STORE_KEYS.workosStore); - if (cached) return cached; - - const ws: WorkOSStore = { - organizations: store.collection('workos.organizations', ID_PREFIXES.organization, [ - 'name', - 'external_id', - ]), - organizationDomains: store.collection( - 'workos.organization_domains', - ID_PREFIXES.organization_domain, - ['organization_id', 'domain'], - ), - organizationMemberships: store.collection( - 'workos.organization_memberships', - ID_PREFIXES.organization_membership, - ['organization_id', 'user_id'], - ), - users: store.collection('workos.users', ID_PREFIXES.user, ['email', 'external_id']), - sessions: store.collection('workos.sessions', ID_PREFIXES.session, ['user_id']), - emailVerifications: store.collection( - 'workos.email_verifications', - ID_PREFIXES.email_verification, - ['user_id'], - ), - passwordResets: store.collection('workos.password_resets', ID_PREFIXES.password_reset, [ - 'user_id', - ]), - magicAuths: store.collection('workos.magic_auths', ID_PREFIXES.magic_auth, ['user_id']), - authFactors: store.collection( - 'workos.auth_factors', - ID_PREFIXES.authentication_factor, - ['user_id'], - ), - authCodes: store.collection('workos.auth_codes', ID_PREFIXES.authorization_code, [ - 'user_id', - 'code', - ]), - identities: store.collection('workos.identities', ID_PREFIXES.identity, ['user_id']), - connections: store.collection('workos.connections', ID_PREFIXES.connection, ['organization_id']), - ssoProfiles: store.collection('workos.sso_profiles', ID_PREFIXES.profile, [ - 'connection_id', - 'email', - ]), - ssoAuthorizations: store.collection( - 'workos.sso_authorizations', - ID_PREFIXES.sso_authorization, - ['code'], - ), - pipeConnections: store.collection('workos.pipe_connections', ID_PREFIXES.pipe_connection, [ - 'user_id', - 'provider', - ]), - refreshTokens: store.collection('workos.refresh_tokens', ID_PREFIXES.refresh_token, [ - 'token', - 'user_id', - 'session_id', - ]), - authChallenges: store.collection( - 'workos.auth_challenges', - ID_PREFIXES.authentication_challenge, - ['user_id', 'factor_id'], - ), - deviceAuthorizations: store.collection( - 'workos.device_authorizations', - ID_PREFIXES.device_authorization, - ['device_code', 'user_code'], - ), - invitations: store.collection('workos.invitations', ID_PREFIXES.invitation, [ - 'email', - 'token', - 'organization_id', - ]), - redirectUris: store.collection('workos.redirect_uris', ID_PREFIXES.redirect_uri, ['uri']), - corsOrigins: store.collection('workos.cors_origins', ID_PREFIXES.cors_origin, ['origin']), - authorizedApplications: store.collection( - 'workos.authorized_applications', - ID_PREFIXES.authorized_application, - ['user_id'], - ), - connectedAccounts: store.collection( - 'workos.connected_accounts', - ID_PREFIXES.connected_account, - ['user_id', 'provider'], - ), - roles: store.collection('workos.roles', ID_PREFIXES.role, ['slug', 'organization_id']), - permissions: store.collection('workos.permissions', ID_PREFIXES.permission, ['slug']), - rolePermissions: store.collection('workos.role_permissions', ID_PREFIXES.role_permission, [ - 'role_id', - 'permission_id', - ]), - authorizationResources: store.collection( - 'workos.authorization_resources', - ID_PREFIXES.authorization_resource, - ['organization_id', 'resource_type_slug'], - ), - roleAssignments: store.collection('workos.role_assignments', ID_PREFIXES.role_assignment, [ - 'organization_membership_id', - 'role_id', - ]), - directories: store.collection('workos.directories', ID_PREFIXES.directory, ['organization_id']), - directoryUsers: store.collection('workos.directory_users', ID_PREFIXES.directory_user, [ - 'directory_id', - 'organization_id', - ]), - directoryGroups: store.collection('workos.directory_groups', ID_PREFIXES.directory_group, [ - 'directory_id', - 'organization_id', - ]), - auditLogActions: store.collection('workos.audit_log_actions', ID_PREFIXES.audit_log_action, [ - 'name', - ]), - auditLogEvents: store.collection('workos.audit_log_events', ID_PREFIXES.audit_log_event, [ - 'organization_id', - ]), - auditLogExports: store.collection('workos.audit_log_exports', ID_PREFIXES.audit_log_export, [ - 'organization_id', - ]), - featureFlags: store.collection('workos.feature_flags', ID_PREFIXES.feature_flag, ['slug']), - flagTargets: store.collection('workos.flag_targets', ID_PREFIXES.flag_target, [ - 'flag_slug', - 'resource_id', - ]), - connectApplications: store.collection( - 'workos.connect_applications', - ID_PREFIXES.connect_application, - ['client_id'], - ), - clientSecrets: store.collection('workos.client_secrets', ID_PREFIXES.client_secret, [ - 'application_id', - ]), - dataIntegrationAuths: store.collection( - 'workos.data_integration_auths', - ID_PREFIXES.data_integration_auth, - ['code', 'slug'], - ), - radarAttempts: store.collection('workos.radar_attempts', ID_PREFIXES.radar_attempt, [ - 'ip_address', - ]), - apiKeyRecords: store.collection('workos.api_keys', ID_PREFIXES.api_key, ['key', 'environment']), - events: store.collection('workos.events', ID_PREFIXES.event, ['event']), - webhookEndpoints: store.collection( - 'workos.webhook_endpoints', - ID_PREFIXES.webhook_endpoint, - ['endpoint_url'], - ), - }; - - store.setData(STORE_KEYS.workosStore, ws); - return ws; -} diff --git a/src/emulate/workos/webhook-signer.spec.ts b/src/emulate/workos/webhook-signer.spec.ts deleted file mode 100644 index d29ee503..00000000 --- a/src/emulate/workos/webhook-signer.spec.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import { createHmac } from 'node:crypto'; -import { signWebhookPayload } from './webhook-signer.js'; - -describe('signWebhookPayload', () => { - it('returns signature in t=...,v1=... format', () => { - const sig = signWebhookPayload('{"test":true}', 'secret123'); - expect(sig).toMatch(/^t=\d+,v1=[a-f0-9]{64}$/); - }); - - it('produces verifiable HMAC-SHA256 signature', () => { - const payload = '{"event":"user.created"}'; - const secret = 'whsec_test_key'; - const sig = signWebhookPayload(payload, secret); - - const match = sig.match(/^t=(\d+),v1=([a-f0-9]+)$/); - expect(match).toBeTruthy(); - - const [, timestamp, hash] = match!; - const expected = createHmac('sha256', secret).update(`${timestamp}.${payload}`).digest('hex'); - - expect(hash).toBe(expected); - }); - - it('produces different signatures for different secrets', () => { - const payload = '{"data":"same"}'; - const sig1 = signWebhookPayload(payload, 'secret_a'); - const sig2 = signWebhookPayload(payload, 'secret_b'); - - const hash1 = sig1.split(',v1=')[1]; - const hash2 = sig2.split(',v1=')[1]; - expect(hash1).not.toBe(hash2); - }); -}); diff --git a/src/emulate/workos/webhook-signer.ts b/src/emulate/workos/webhook-signer.ts deleted file mode 100644 index 0fd3b069..00000000 --- a/src/emulate/workos/webhook-signer.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { createHmac } from 'node:crypto'; - -export function signWebhookPayload(payload: string, secret: string): string { - const timestamp = Math.floor(Date.now() / 1000).toString(); - const signedPayload = `${timestamp}.${payload}`; - const signature = createHmac('sha256', secret).update(signedPayload).digest('hex'); - - return `t=${timestamp},v1=${signature}`; -} diff --git a/src/utils/help-json.ts b/src/utils/help-json.ts index b85e54a2..dd916458 100644 --- a/src/utils/help-json.ts +++ b/src/utils/help-json.ts @@ -1389,6 +1389,43 @@ const commands: CommandSchema[] = [ { name: 'wizard', description: 'Guided interactive migration wizard' }, ], }, + { + name: 'emulate', + description: 'Start a local WorkOS API emulator', + options: [ + { + name: 'port', + type: 'number', + description: 'Port to listen on', + required: false, + default: 4100, + alias: 'p', + hidden: false, + }, + { + name: 'seed', + type: 'string', + description: 'Path to seed config file (YAML or JSON)', + required: false, + alias: 's', + hidden: false, + }, + { + name: 'interactive', + type: 'boolean', + description: 'Show login pages for SSO/AuthKit', + required: false, + default: false, + alias: 'i', + hidden: false, + }, + ], + examples: [ + 'workos emulate', + 'workos emulate --port 9100 --json', + 'workos emulate --seed workos-emulate.config.yaml', + ], + }, ]; const globalOptions: OptionSchema[] = [