diff --git a/README.md b/README.md index 4cd93a0d..59a1e506 100644 --- a/README.md +++ b/README.md @@ -77,6 +77,10 @@ Resource Management: api-key Manage per-org API keys org-domain Manage organization domains +Local Development: + emulate Start a local WorkOS API emulator + dev Start emulator + your app in one command + Workflows: seed Declarative resource provisioning from YAML setup-org One-shot organization onboarding @@ -192,6 +196,158 @@ Inspects a directory's sync state, user/group counts, recent events, and detects workos debug-sync directory_01ABC123 ``` +### Local Development + +Test your WorkOS integration locally without hitting the live API. The emulator provides a full in-memory WorkOS API replacement with all major endpoints. + +#### `workos dev` — One command to start everything + +The fastest way to develop locally. Starts the emulator and your app together, auto-detecting your framework and injecting the right environment variables. + +```bash +# Auto-detects framework (Next.js, Vite, Remix, SvelteKit, etc.) and dev command +workos dev + +# Override the dev command +workos dev -- npx vite --port 5173 + +# Custom emulator port and seed data +workos dev --port 8080 --seed workos-emulate.config.yaml +``` + +Your app receives these environment variables automatically: + +- `WORKOS_API_BASE_URL` — points to the local emulator (e.g. `http://localhost:4100`) +- `WORKOS_API_KEY` — `sk_test_default` +- `WORKOS_CLIENT_ID` — `client_emulate` + +#### `workos emulate` — Standalone emulator + +Run the emulator on its own for CI, test suites, or when you want manual control. + +```bash +# Start with defaults (port 4100) +workos emulate + +# CI-friendly: JSON output, custom port +workos emulate --port 9100 --json +# → {"url":"http://localhost:9100","port":9100,"apiKey":"sk_test_default","health":"http://localhost:9100/health"} + +# Pre-load seed data +workos emulate --seed workos-emulate.config.yaml +``` + +The emulator supports `GET /health` for readiness polling and shuts down cleanly on Ctrl+C. + +#### Seed configuration + +Create a `workos-emulate.config.yaml` (auto-detected) or pass `--seed `: + +```yaml +users: + - email: alice@acme.com + first_name: Alice + password: test123 + email_verified: true + +organizations: + - name: Acme Corp + domains: + - domain: acme.com + state: verified + memberships: + - user_id: + role: admin + +connections: + - name: Acme SSO + organization: Acme Corp + connection_type: GenericSAML + domains: [acme.com] + +roles: + - slug: admin + name: Admin + permissions: [posts:read, posts:write] + +permissions: + - slug: posts:read + name: Read Posts + - slug: posts:write + name: Write Posts + +webhookEndpoints: + - url: http://localhost:3000/webhooks + events: [user.created, organization.updated] +``` + +#### Programmatic API + +Use the emulator directly in test suites without the CLI: + +```typescript +import { createEmulator } from 'workos/emulate'; + +const emulator = await createEmulator({ + port: 0, // random available port + seed: { + users: [{ email: 'test@example.com', password: 'secret' }], + }, +}); + +// Use emulator.url as your WORKOS_API_BASE_URL +const res = await fetch(`${emulator.url}/user_management/users`, { + headers: { Authorization: 'Bearer sk_test_default' }, +}); + +// Reset between tests (clears data, re-applies seed) +emulator.reset(); + +// Clean up +await emulator.close(); +``` + +#### Emulated endpoints + +The emulator covers the full WorkOS API surface (~84% of OpenAPI spec endpoints). Run `pnpm check:coverage ` to see exact coverage. + +| Endpoint Group | Routes | +| ------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Organizations | CRUD, external_id lookup, domain management | +| Users | CRUD, email uniqueness, password management | +| Organization memberships | CRUD, role assignment, deactivate/reactivate | +| Organization domains | CRUD, verification | +| SSO connections | CRUD, domain-based lookup | +| SSO flow | Authorize, token exchange, profile, JWKS, SSO logout | +| AuthKit | OAuth authorize (login_hint, multi-user), authenticate (7 grant types incl. refresh_token, MFA TOTP, org selection, device code), PKCE, sealed sessions, impersonation | +| Sessions | List, revoke, logout redirect, JWKS per client | +| Email verification | Send code, confirm | +| Password reset | Create token, confirm | +| Magic auth | Create code | +| Auth factors | TOTP enrollment, delete | +| MFA challenges | Create challenge, verify code | +| Invitations | CRUD, accept, revoke, resend, get by token | +| Config | Redirect URIs, CORS origins, JWT template | +| User features | Authorized apps, connected accounts, data providers | +| Widgets | Token generation | +| Authorization (RBAC) | Environment roles, org roles (priority ordering), permissions, role-permission management | +| Authorization (FGA) | Resources CRUD, permission checks, role assignments | +| Directory Sync | List/get/delete directories, users, groups | +| Audit Logs | Actions, schemas, events, exports, org config/retention | +| Feature Flags | List/get, enable/disable, targets, org/user evaluations | +| Connect | Applications CRUD, client secrets | +| Data Integrations | OAuth authorize + token exchange | +| Radar | Attempts list/get, allow/deny lists | +| API Keys | Validate, delete, list by org | +| Portal | Generate admin portal links | +| Legacy MFA | Enroll/get/delete factors, challenge/verify | +| Webhook Endpoints | CRUD with auto-generated secrets, secret masking | +| Events | Paginated event stream with type filtering | +| Event Bus | Auto-emits events on entity CRUD via collection hooks, fire-and-forget webhook delivery with HMAC signatures | +| Pipes | Connection CRUD, mock `getAccessToken()` | + +JWT tokens include `role` and `permissions` claims for org-scoped sessions. All list endpoints support cursor pagination (`before`, `after`, `limit`, `order`). Error responses match the WorkOS format (`{ message, code, errors }`). + ### Environment Management ```bash @@ -466,12 +622,13 @@ workos install --api-key sk_test_xxx --client-id client_xxx --no-commit 2>/dev/n ### Environment Variables -| Variable | Effect | -| ------------------------ | -------------------------------------------------------- | -| `WORKOS_API_KEY` | API key for management commands (bypasses stored config) | -| `WORKOS_NO_PROMPT=1` | Force non-interactive mode + JSON output | -| `WORKOS_FORCE_TTY=1` | Force interactive mode even when piped | -| `WORKOS_TELEMETRY=false` | Disable telemetry | +| Variable | Effect | +| ------------------------ | --------------------------------------------------------- | +| `WORKOS_API_KEY` | API key for management commands (bypasses stored config) | +| `WORKOS_API_BASE_URL` | Override API base URL (set automatically by `workos dev`) | +| `WORKOS_NO_PROMPT=1` | Force non-interactive mode + JSON output | +| `WORKOS_FORCE_TTY=1` | Force interactive mode even when piped | +| `WORKOS_TELEMETRY=false` | Disable telemetry | ### Command Discovery diff --git a/package.json b/package.json index 706bc5cc..ad767675 100644 --- a/package.json +++ b/package.json @@ -34,6 +34,16 @@ "typescript": { "definition": "dist/index.d.ts" }, + "exports": { + ".": { + "import": "./dist/index.js", + "types": "./dist/index.d.ts" + }, + "./emulate": { + "import": "./dist/emulate/index.js", + "types": "./dist/emulate/index.d.ts" + } + }, "dependencies": { "@anthropic-ai/claude-agent-sdk": "~0.2.62", "@anthropic-ai/sdk": "^0.78.0", @@ -51,6 +61,8 @@ "semver": "^7.7.4", "uuid": "^13.0.0", "xstate": "^5.28.0", + "hono": "^4", + "@hono/node-server": "^1", "yaml": "^2.8.2", "yargs": "^18.0.0", "zod": "^4.3.6" @@ -96,7 +108,9 @@ "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" + "eval:show": "tsx tests/evals/index.ts show", + "gen:routes": "tsx scripts/gen-routes.ts", + "check:coverage": "tsx scripts/check-coverage.ts" }, "author": "WorkOS", "license": "MIT" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8cc1e316..a158695e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -20,6 +20,9 @@ importers: '@clack/prompts': specifier: 1.0.1 version: 1.0.1 + '@hono/node-server': + specifier: ^1 + version: 1.19.8(hono@4.11.4) '@napi-rs/keyring': specifier: ^1.2.0 version: 1.2.0 @@ -38,6 +41,9 @@ 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) diff --git a/scripts/check-coverage.ts b/scripts/check-coverage.ts new file mode 100644 index 00000000..8fe4745d --- /dev/null +++ b/scripts/check-coverage.ts @@ -0,0 +1,237 @@ +#!/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 new file mode 100644 index 00000000..1f98a92f --- /dev/null +++ b/scripts/gen-routes-lib.spec.ts @@ -0,0 +1,659 @@ +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 new file mode 100644 index 00000000..94039972 --- /dev/null +++ b/scripts/gen-routes-lib.ts @@ -0,0 +1,647 @@ +/** + * 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 new file mode 100644 index 00000000..4ce45ff3 --- /dev/null +++ b/scripts/gen-routes.ts @@ -0,0 +1,96 @@ +#!/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 377531d2..d02400aa 100644 --- a/src/bin.ts +++ b/src/bin.ts @@ -175,6 +175,7 @@ const installerOptions = { if (!isJsonMode()) await checkForUpdates(); yargs(rawArgs) + .parserConfiguration({ 'populate--': true }) .env('WORKOS_INSTALLER') .option('json', { type: 'boolean', @@ -187,7 +188,12 @@ yargs(rawArgs) // Excluded: auth/claim/install/dashboard handle their own credential flows; // skills/doctor/env/debug are utility commands where the warning is unnecessary. const command = String(argv._?.[0] ?? ''); - if (['auth', 'skills', 'doctor', 'env', 'claim', 'install', 'debug', 'dashboard', ''].includes(command)) return; + if ( + ['auth', 'skills', 'doctor', 'env', 'claim', 'install', 'debug', 'dashboard', 'emulate', 'dev', ''].includes( + command, + ) + ) + return; await applyInsecureStorage(argv.insecureStorage as boolean | undefined); await maybeWarnUnclaimed(); }) @@ -2122,6 +2128,36 @@ yargs(rawArgs) await handleInstall(argv); }, ) + .command( + 'emulate', + 'Start a local WorkOS API emulator', + (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)' }, + }), + async (argv) => { + const { runEmulate } = await import('./commands/emulate.js'); + await runEmulate({ port: argv.port, seed: argv.seed, json: argv.json as boolean }); + }, + ) + .command( + 'dev', + 'Start emulator + your app in one command', + (yargs) => + yargs.options({ + port: { type: 'number', default: 4100, describe: 'Emulator port' }, + seed: { type: 'string', describe: 'Path to seed config file' }, + }), + async (argv) => { + const { runDev } = await import('./commands/dev.js'); + await runDev({ + port: argv.port, + seed: argv.seed, + '--': argv['--'] as string[] | undefined, + }); + }, + ) .command('debug', false, (yargs) => { yargs.options(insecureStorageOption); registerSubcommand( diff --git a/src/commands/dev.spec.ts b/src/commands/dev.spec.ts new file mode 100644 index 00000000..4552e982 --- /dev/null +++ b/src/commands/dev.spec.ts @@ -0,0 +1,49 @@ +import { describe, it, expect } from 'vitest'; +import { buildDevEnv } from './dev.js'; + +describe('buildDevEnv', () => { + it('includes WORKOS_API_BASE_URL pointing at emulator', () => { + const env = buildDevEnv('http://localhost:4100'); + expect(env.WORKOS_API_BASE_URL).toBe('http://localhost:4100'); + }); + + it('includes decomposed SDK env vars', () => { + const env = buildDevEnv('http://localhost:4100'); + expect(env.WORKOS_API_HOSTNAME).toBe('localhost'); + expect(env.WORKOS_API_PORT).toBe('4100'); + expect(env.WORKOS_API_HTTPS).toBe('false'); + }); + + it('includes WORKOS_API_KEY with test default key', () => { + const env = buildDevEnv('http://localhost:4100'); + expect(env.WORKOS_API_KEY).toBe('sk_test_default'); + }); + + it('uses custom API key when provided', () => { + const env = buildDevEnv('http://localhost:4100', 'sk_test_custom'); + expect(env.WORKOS_API_KEY).toBe('sk_test_custom'); + }); + + it('includes WORKOS_CLIENT_ID', () => { + const env = buildDevEnv('http://localhost:4100'); + expect(env.WORKOS_CLIENT_ID).toBe('client_emulated'); + }); + + it('uses the provided emulator URL and parses port correctly', () => { + const env = buildDevEnv('http://localhost:9999'); + expect(env.WORKOS_API_BASE_URL).toBe('http://localhost:9999'); + expect(env.WORKOS_API_PORT).toBe('9999'); + }); + + it('returns all expected keys', () => { + const env = buildDevEnv('http://localhost:4100'); + expect(Object.keys(env).sort()).toEqual([ + 'WORKOS_API_BASE_URL', + 'WORKOS_API_HOSTNAME', + 'WORKOS_API_HTTPS', + 'WORKOS_API_KEY', + 'WORKOS_API_PORT', + 'WORKOS_CLIENT_ID', + ]); + }); +}); diff --git a/src/commands/dev.ts b/src/commands/dev.ts new file mode 100644 index 00000000..cfc8c6ab --- /dev/null +++ b/src/commands/dev.ts @@ -0,0 +1,157 @@ +import { createEmulator, type EmulatorSeedConfig } from '../emulate/index.js'; +import { resolveDevCommand } from '../lib/dev-command.js'; +import { spawn, type ChildProcess } from 'node:child_process'; +import { readFileSync, existsSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { parse as parseYaml } from 'yaml'; +import chalk from 'chalk'; + +export interface DevArgs { + port: number; + seed?: string; + '--'?: string[]; +} + +function loadSeedFile(filePath: string): EmulatorSeedConfig { + const resolved = resolve(filePath); + if (!existsSync(resolved)) { + console.error(`Seed file not found: ${resolved}`); + process.exit(1); + } + + const content = readFileSync(resolved, 'utf-8'); + if (resolved.endsWith('.json')) { + return JSON.parse(content) as EmulatorSeedConfig; + } + return parseYaml(content) as EmulatorSeedConfig; +} + +function autoDetectSeedFile(): EmulatorSeedConfig | null { + const candidates = ['workos-emulate.config.yaml', 'workos-emulate.config.yml', 'workos-emulate.config.json']; + + for (const name of candidates) { + const filePath = resolve(name); + if (existsSync(filePath)) { + return loadSeedFile(filePath); + } + } + 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. + */ +export const DEFAULT_DEV_SEED: EmulatorSeedConfig = { + users: [ + { + email: 'test@example.com', + first_name: 'Test', + last_name: 'User', + password: 'password', + email_verified: true, + }, + ], + organizations: [ + { + name: 'Test Organization', + domains: [{ domain: 'example.com', state: 'verified' }], + }, + ], +}; + +export function buildDevEnv(emulatorUrl: string, apiKey = 'sk_test_default'): Record { + const url = new URL(emulatorUrl); + return { + WORKOS_API_BASE_URL: emulatorUrl, + WORKOS_API_HOSTNAME: url.hostname, + WORKOS_API_PORT: url.port, + WORKOS_API_HTTPS: 'false', + WORKOS_API_KEY: apiKey, + WORKOS_CLIENT_ID: 'client_emulated', + }; +} + +export async function runDev(argv: DevArgs): Promise { + const userSeed = argv.seed ? loadSeedFile(argv.seed) : autoDetectSeedFile(); + const seedConfig = userSeed ?? DEFAULT_DEV_SEED; + + // 1. Start emulator + const emulator = await createEmulator({ + port: argv.port, + seed: seedConfig, + }); + + // 2. Resolve dev command + const explicit = argv['--']; + const devCmd = + explicit && explicit.length > 0 + ? { command: explicit[0], args: explicit.slice(1), framework: null as string | null } + : await resolveDevCommand(process.cwd()); + + // 3. Print status banner + console.log(); + console.log(`${chalk.cyan('WorkOS Emulate')} ${chalk.dim(emulator.url)}`); + if (devCmd.framework) { + console.log(chalk.dim(`Detected ${devCmd.framework}`)); + } + console.log(chalk.dim(`Running: ${devCmd.command} ${devCmd.args.join(' ')}`)); + if (!userSeed) { + console.log(); + console.log(` ${chalk.dim('Email:')} test@example.com`); + console.log(` ${chalk.dim('Password:')} password`); + } + console.log(); + + // 4. Spawn child process with env vars + let child: ChildProcess; + try { + child = spawn(devCmd.command, devCmd.args, { + stdio: 'inherit', + env: { + ...process.env, + ...buildDevEnv(emulator.url, emulator.apiKey), + }, + }); + } catch { + console.error(chalk.red(`Failed to start: ${devCmd.command} ${devCmd.args.join(' ')}`)); + console.error(chalk.dim('Try specifying the command explicitly: workos dev -- ')); + await emulator.close(); + process.exit(1); + } + + child.on('error', async (err) => { + console.error(chalk.red(`Failed to start: ${devCmd.command}`)); + if ((err as NodeJS.ErrnoException).code === 'ENOENT') { + console.error(chalk.dim(`Command not found: ${devCmd.command}`)); + console.error(chalk.dim('Try specifying the command explicitly: workos dev -- ')); + } else { + console.error(chalk.dim(err.message)); + } + await emulator.close(); + process.exit(1); + }); + + // 5. Signal handling — forward to child, then close emulator + const shutdown = (signal: NodeJS.Signals) => { + child.kill(signal); + emulator.close().then(() => process.exit(0)); + }; + process.on('SIGINT', () => shutdown('SIGINT')); + process.on('SIGTERM', () => shutdown('SIGTERM')); + + // 6. If child exits, close emulator and exit with same code + child.on('exit', (code) => { + emulator.close().then(() => process.exit(code ?? 0)); + }); +} diff --git a/src/commands/emulate.spec.ts b/src/commands/emulate.spec.ts new file mode 100644 index 00000000..3e496204 --- /dev/null +++ b/src/commands/emulate.spec.ts @@ -0,0 +1,147 @@ +import { describe, it, expect, afterEach } from 'vitest'; +import { createEmulator, type Emulator } from '../emulate/index.js'; + +describe('createEmulator', () => { + let emulator: Emulator | undefined; + + afterEach(async () => { + if (emulator) { + await emulator.close(); + emulator = 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'); + }); + + it('accepts API key and returns user list', async () => { + emulator = await createEmulator({ port: 0 }); + + const res = await fetch(`${emulator.url}/user_management/users`, { + headers: { Authorization: 'Bearer sk_test_default' }, + }); + 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('seeds users from config', async () => { + emulator = await createEmulator({ + port: 0, + seed: { + users: [{ email: 'seeded@test.com', first_name: 'Seeded' }], + }, + }); + + const res = await fetch(`${emulator.url}/user_management/users`, { + headers: { Authorization: 'Bearer sk_test_default' }, + }); + 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' }], + }, + }); + + // 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'); + }); + + 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(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('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' }] }, + }); + + 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(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 new file mode 100644 index 00000000..d257e808 --- /dev/null +++ b/src/commands/emulate.ts @@ -0,0 +1,78 @@ +import { createEmulator, type EmulatorSeedConfig } from '../emulate/index.js'; +import { readFileSync, existsSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { parse as parseYaml } from 'yaml'; +import chalk from 'chalk'; + +export interface EmulateArgs { + port: number; + seed?: string; + json?: boolean; +} + +function loadSeedFile(filePath: string): EmulatorSeedConfig { + const resolved = resolve(filePath); + if (!existsSync(resolved)) { + console.error(`Seed file not found: ${resolved}`); + process.exit(1); + } + + const content = readFileSync(resolved, 'utf-8'); + if (resolved.endsWith('.json')) { + return JSON.parse(content) as EmulatorSeedConfig; + } + return parseYaml(content) as EmulatorSeedConfig; +} + +function autoDetectSeedFile(): EmulatorSeedConfig | null { + const candidates = ['workos-emulate.config.yaml', 'workos-emulate.config.yml', 'workos-emulate.config.json']; + + for (const name of candidates) { + const filePath = resolve(name); + if (existsSync(filePath)) { + return loadSeedFile(filePath); + } + } + return null; +} + +function printBanner(emulator: { url: string; port: number; apiKey: string }): void { + console.log(); + console.log(chalk.bold(' WorkOS Emulator')); + console.log(); + console.log(` ${chalk.dim('URL:')} ${emulator.url}`); + console.log(` ${chalk.dim('API Key:')} ${emulator.apiKey}`); + console.log(` ${chalk.dim('Health:')} ${emulator.url}/health`); + console.log(); + console.log(chalk.dim(' Press Ctrl+C to stop')); + console.log(); +} + +export async function runEmulate(argv: EmulateArgs): Promise { + const seedConfig = argv.seed ? loadSeedFile(argv.seed) : autoDetectSeedFile(); + + const emulator = await createEmulator({ + port: argv.port, + seed: seedConfig ?? undefined, + }); + + if (argv.json) { + console.log( + JSON.stringify({ + url: emulator.url, + port: emulator.port, + apiKey: emulator.apiKey, + health: `${emulator.url}/health`, + }), + ); + } else { + printBanner(emulator); + } + + const shutdown = () => { + if (!argv.json) console.log(`\n${chalk.dim('Shutting down...')}`); + emulator.close().then(() => process.exit(0)); + }; + process.once('SIGINT', shutdown); + process.once('SIGTERM', shutdown); +} diff --git a/src/emulate/core/id.spec.ts b/src/emulate/core/id.spec.ts new file mode 100644 index 00000000..ed87cf65 --- /dev/null +++ b/src/emulate/core/id.spec.ts @@ -0,0 +1,58 @@ +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 new file mode 100644 index 00000000..3e58e9ee --- /dev/null +++ b/src/emulate/core/id.ts @@ -0,0 +1,64 @@ +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: 'event', + invitation: 'inv', + session: 'session', + email_verification: 'email_verification', + password_reset: 'password_reset', + magic_auth: 'magic_auth', + authentication_factor: 'auth_factor', + authentication_challenge: 'auth_challenge', + api_key: 'api_key', + profile: 'prof', + pipe_connection: 'pipe_conn', + 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', +} as const; diff --git a/src/emulate/core/index.ts b/src/emulate/core/index.ts new file mode 100644 index 00000000..3e356de1 --- /dev/null +++ b/src/emulate/core/index.ts @@ -0,0 +1,25 @@ +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 { 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 new file mode 100644 index 00000000..2129aa94 --- /dev/null +++ b/src/emulate/core/jwt.spec.ts @@ -0,0 +1,91 @@ +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 new file mode 100644 index 00000000..b4f7df27 --- /dev/null +++ b/src/emulate/core/jwt.ts @@ -0,0 +1,111 @@ +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 new file mode 100644 index 00000000..8fcfd72c --- /dev/null +++ b/src/emulate/core/middleware/auth.ts @@ -0,0 +1,56 @@ +import type { Context, Next } from 'hono'; + +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) { + return c.json( + { + message: 'Unauthorized', + code: 'unauthorized', + }, + 401, + ); + } + + const token = authHeader.replace(/^Bearer\s+/i, '').trim(); + + if (!token.startsWith('sk_')) { + return c.json( + { + message: 'Unauthorized', + code: 'unauthorized', + }, + 401, + ); + } + + const keyInfo = apiKeys[token]; + if (!keyInfo) { + return c.json( + { + message: 'Unauthorized', + code: 'unauthorized', + }, + 401, + ); + } + + 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 new file mode 100644 index 00000000..2ec1466d --- /dev/null +++ b/src/emulate/core/middleware/error-handler.ts @@ -0,0 +1,83 @@ +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 new file mode 100644 index 00000000..b2b4716e --- /dev/null +++ b/src/emulate/core/pagination.spec.ts @@ -0,0 +1,101 @@ +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 new file mode 100644 index 00000000..08f84b24 --- /dev/null +++ b/src/emulate/core/pagination.ts @@ -0,0 +1,70 @@ +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 cursorPaginate( + items: T[], + options: CursorPaginationOptions = {}, +): CursorPaginatedResult { + 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 new file mode 100644 index 00000000..5f7d1415 --- /dev/null +++ b/src/emulate/core/plugin.ts @@ -0,0 +1,17 @@ +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 new file mode 100644 index 00000000..285b1606 --- /dev/null +++ b/src/emulate/core/server.ts @@ -0,0 +1,148 @@ +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 for API routes + app.use('/api/*', authMiddleware(apiKeys)); + app.use('/user_management/*', async (c, next) => { + const path = new URL(c.req.url).pathname; + // Public endpoints (no auth required) + if ( + path === '/user_management/authorize' || + path === '/user_management/authenticate' || + path === '/user_management/sessions/logout' || + path.startsWith('/user_management/sessions/jwks/') + ) { + return next(); + } + return authMiddleware(apiKeys)(c, next); + }); + app.use('/x/authkit/*', authMiddleware(apiKeys)); + app.use('/organizations', authMiddleware(apiKeys)); + app.use('/organizations/*', authMiddleware(apiKeys)); + app.use('/organization_memberships', authMiddleware(apiKeys)); + app.use('/organization_memberships/*', authMiddleware(apiKeys)); + app.use('/organization_domains', authMiddleware(apiKeys)); + app.use('/organization_domains/*', authMiddleware(apiKeys)); + app.use('/connections', authMiddleware(apiKeys)); + app.use('/connections/*', authMiddleware(apiKeys)); + app.use('/directories', authMiddleware(apiKeys)); + app.use('/directories/*', authMiddleware(apiKeys)); + app.use('/directory_groups', authMiddleware(apiKeys)); + app.use('/directory_groups/*', authMiddleware(apiKeys)); + app.use('/directory_users', authMiddleware(apiKeys)); + app.use('/directory_users/*', authMiddleware(apiKeys)); + app.use('/events', authMiddleware(apiKeys)); + app.use('/events/*', authMiddleware(apiKeys)); + app.use('/pipes/*', authMiddleware(apiKeys)); + app.use('/audit_logs/*', authMiddleware(apiKeys)); + app.use('/feature-flags', authMiddleware(apiKeys)); + app.use('/feature-flags/*', authMiddleware(apiKeys)); + app.use('/connect/*', authMiddleware(apiKeys)); + app.use('/data-integrations/*', async (c, next) => { + const path = new URL(c.req.url).pathname; + if (path.endsWith('/authorize')) return next(); + return authMiddleware(apiKeys)(c, next); + }); + app.use('/radar/*', authMiddleware(apiKeys)); + app.use('/api_keys', authMiddleware(apiKeys)); + app.use('/api_keys/*', authMiddleware(apiKeys)); + app.use('/portal/*', authMiddleware(apiKeys)); + app.use('/webhook_endpoints', authMiddleware(apiKeys)); + app.use('/webhook_endpoints/*', authMiddleware(apiKeys)); + app.use('/auth/factors', authMiddleware(apiKeys)); + app.use('/auth/factors/*', authMiddleware(apiKeys)); + app.use('/auth/challenges/*', authMiddleware(apiKeys)); + + // 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 new file mode 100644 index 00000000..2f35a530 --- /dev/null +++ b/src/emulate/core/store.spec.ts @@ -0,0 +1,214 @@ +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 new file mode 100644 index 00000000..6728cdf2 --- /dev/null +++ b/src/emulate/core/store.ts @@ -0,0 +1,177 @@ +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); + } + + 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; + return this.all().filter(filter).length; + } + + 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); + } + + 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 new file mode 100644 index 00000000..3f33a1f9 --- /dev/null +++ b/src/emulate/index.ts @@ -0,0 +1,80 @@ +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/entities.ts b/src/emulate/workos/entities.ts new file mode 100644 index 00000000..4aeb07e7 --- /dev/null +++ b/src/emulate/workos/entities.ts @@ -0,0 +1,403 @@ +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; +} + +// --- Phase 4: CRUD Domains --- + +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'; + 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 new file mode 100644 index 00000000..daf5a3d5 --- /dev/null +++ b/src/emulate/workos/event-bus.spec.ts @@ -0,0 +1,146 @@ +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', + url: 'http://localhost:9999/webhook', + secret: 'whsec_test', + enabled: false, + events: [], + description: null, + }); + + // 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', + url: 'http://localhost:9999/webhook', + secret: 'whsec_test', + enabled: true, + events: ['organization.created'], + description: null, + }); + + // 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', + url: 'http://localhost:9999/webhook', + secret, + enabled: true, + events: [], + description: null, + }); + + 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', + url: 'http://localhost:9999/webhook', + secret: 'whsec_test', + enabled: true, + events: [], + description: null, + }); + + // 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 new file mode 100644 index 00000000..33b8b2ac --- /dev/null +++ b/src/emulate/workos/event-bus.ts @@ -0,0 +1,54 @@ +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'; + +export interface EventPayload { + event: string; + data: Record; + environment_id?: string; +} + +export class EventBus { + constructor(private store: Store) {} + + 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, + }); + + const endpoints = ws.webhookEndpoints.all(); + for (const endpoint of endpoints) { + if (!endpoint.enabled) continue; + if (endpoint.events.length > 0 && !endpoint.events.includes(payload.event)) continue; + // Fire-and-forget — don't await + 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.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 new file mode 100644 index 00000000..8040c94d --- /dev/null +++ b/src/emulate/workos/helpers.ts @@ -0,0 +1,615 @@ +import { randomBytes, createHash, createCipheriv } from 'node:crypto'; +import { WorkOSApiError } 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, + WorkOSConnectApplication, + WorkOSClientSecret, + WorkOSRadarAttempt, + WorkOSApiKey, + WorkOSEvent, + WorkOSWebhookEndpoint, +} from './entities.js'; + +export function formatOrganization(org: WorkOSOrganization, ws: WorkOSStore): Record { + const 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 { + object: 'organization_domain', + id: domain.id, + organization_id: domain.organization_id, + domain: domain.domain, + state: domain.state, + verification_strategy: domain.verification_strategy, + verification_token: domain.verification_token, + verification_prefix: domain.verification_prefix, + created_at: domain.created_at, + updated_at: domain.updated_at, + }; +} + +export function formatMembership(m: WorkOSOrganizationMembership): Record { + return { + object: 'organization_membership', + id: m.id, + organization_id: m.organization_id, + user_id: m.user_id, + role: m.role, + status: m.status, + external_id: m.external_id, + metadata: m.metadata, + created_at: m.created_at, + updated_at: m.updated_at, + }; +} + +export function formatUser(user: WorkOSUser): Record { + return { + object: 'user', + id: user.id, + email: user.email, + first_name: user.first_name, + last_name: user.last_name, + email_verified: user.email_verified, + profile_picture_url: user.profile_picture_url, + last_sign_in_at: user.last_sign_in_at, + external_id: user.external_id, + metadata: user.metadata, + locale: user.locale, + created_at: user.created_at, + updated_at: user.updated_at, + }; +} + +export function formatSession(s: WorkOSSession): Record { + return { + object: 'session', + id: s.id, + user_id: s.user_id, + organization_id: s.organization_id, + ip_address: s.ip_address, + user_agent: s.user_agent, + created_at: s.created_at, + updated_at: s.updated_at, + }; +} + +export function formatEmailVerification(ev: WorkOSEmailVerification): Record { + return { + object: 'email_verification', + id: ev.id, + user_id: ev.user_id, + email: ev.email, + code: ev.code, + expires_at: ev.expires_at, + created_at: ev.created_at, + updated_at: ev.updated_at, + }; +} + +export function formatPasswordReset(pr: WorkOSPasswordReset): Record { + return { + object: 'password_reset', + id: pr.id, + user_id: pr.user_id, + email: pr.email, + token: pr.token, + expires_at: pr.expires_at, + created_at: pr.created_at, + updated_at: pr.updated_at, + }; +} + +export function formatMagicAuth(ma: WorkOSMagicAuth): Record { + return { + object: 'magic_auth', + id: ma.id, + user_id: ma.user_id, + email: ma.email, + code: ma.code, + expires_at: ma.expires_at, + created_at: ma.created_at, + updated_at: ma.updated_at, + }; +} + +export function formatAuthFactor(f: WorkOSAuthenticationFactor): Record { + return { + object: 'authentication_factor', + id: f.id, + user_id: f.user_id, + type: f.type, + totp: f.totp, + created_at: f.created_at, + updated_at: f.updated_at, + }; +} + +export function formatIdentity(i: WorkOSIdentity): Record { + return { + object: 'identity', + id: i.id, + user_id: i.user_id, + provider: i.provider, + provider_id: i.provider_id, + type: i.type, + created_at: i.created_at, + updated_at: i.updated_at, + }; +} + +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 { + object: 'connection', + id: conn.id, + organization_id: conn.organization_id, + connection_type: conn.connection_type, + name: conn.name, + state: conn.state, + domains: conn.domains, + created_at: conn.created_at, + updated_at: conn.updated_at, + }; +} + +export function formatSSOProfile(p: WorkOSSSOProfile): Record { + return { + object: 'profile', + id: p.id, + connection_id: p.connection_id, + connection_type: p.connection_type, + organization_id: p.organization_id, + idp_id: p.idp_id, + email: p.email, + first_name: p.first_name, + last_name: p.last_name, + groups: p.groups, + raw_attributes: p.raw_attributes, + created_at: p.created_at, + updated_at: p.updated_at, + }; +} + +export function formatPipeConnection(pc: WorkOSPipeConnection): Record { + return { + object: 'pipe_connection', + id: pc.id, + user_id: pc.user_id, + provider: pc.provider, + scopes: pc.scopes, + status: pc.status, + external_account_id: pc.external_account_id, + created_at: pc.created_at, + updated_at: pc.updated_at, + }; +} + +export function formatInvitation(inv: WorkOSInvitation): Record { + return { + object: 'invitation', + id: inv.id, + email: inv.email, + state: inv.state, + token: inv.token, + accept_invitation_url: inv.accept_invitation_url, + organization_id: inv.organization_id, + inviter_user_id: inv.inviter_user_id, + role_slug: inv.role_slug, + expires_at: inv.expires_at, + created_at: inv.created_at, + updated_at: inv.updated_at, + }; +} + +export function formatRedirectUri(r: WorkOSRedirectUri): Record { + return { + object: 'redirect_uri', + id: r.id, + uri: r.uri, + created_at: r.created_at, + updated_at: r.updated_at, + }; +} + +export function formatCorsOrigin(o: WorkOSCorsOrigin): Record { + return { + object: 'cors_origin', + id: o.id, + origin: o.origin, + created_at: o.created_at, + updated_at: o.updated_at, + }; +} + +export function formatAuthorizedApplication(a: WorkOSAuthorizedApplication): Record { + return { + object: 'authorized_application', + id: a.id, + user_id: a.user_id, + name: a.name, + redirect_uri: a.redirect_uri, + created_at: a.created_at, + updated_at: a.updated_at, + }; +} + +export function formatConnectedAccount(a: WorkOSConnectedAccount): Record { + return { + object: 'connected_account', + id: a.id, + user_id: a.user_id, + provider: a.provider, + provider_id: a.provider_id, + created_at: a.created_at, + updated_at: a.updated_at, + }; +} + +export function parseListParams(url: URL) { + const limit = Math.max(1, Math.min(parseInt(url.searchParams.get('limit') ?? '10'), 100)); + 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 }; +} + +/** 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', + ); + } +} + +export function formatAuthChallenge(c: WorkOSAuthenticationChallenge): Record { + return { + object: 'authentication_challenge', + id: c.id, + user_id: c.user_id, + factor_id: c.factor_id, + expires_at: c.expires_at, + created_at: c.created_at, + updated_at: c.updated_at, + }; +} + +export function formatRole(role: WorkOSRole): Record { + return { + object: 'role', + id: role.id, + slug: role.slug, + name: role.name, + description: role.description, + type: role.type, + organization_id: role.organization_id, + is_default_role: role.is_default_role, + priority: role.priority, + created_at: role.created_at, + updated_at: role.updated_at, + }; +} + +export function formatPermission(p: WorkOSPermission): Record { + return { + object: 'permission', + id: p.id, + slug: p.slug, + name: p.name, + description: p.description, + created_at: p.created_at, + updated_at: p.updated_at, + }; +} + +export function formatAuthorizationResource(r: WorkOSAuthorizationResource): Record { + return { + object: 'authorization_resource', + id: r.id, + resource_type_slug: r.resource_type_slug, + external_id: r.external_id, + organization_id: r.organization_id, + metadata: r.metadata, + created_at: r.created_at, + updated_at: r.updated_at, + }; +} + +export function formatRoleAssignment(ra: WorkOSRoleAssignment): Record { + return { + object: 'role_assignment', + id: ra.id, + organization_membership_id: ra.organization_membership_id, + role_id: ra.role_id, + created_at: ra.created_at, + updated_at: ra.updated_at, + }; +} + +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, + }; +} + +// --- Phase 4: CRUD Domain formatters --- + +export function formatDirectory(d: WorkOSDirectory): Record { + return { + object: 'directory', + id: d.id, + name: d.name, + organization_id: d.organization_id, + domain: d.domain, + type: d.type, + state: d.state, + external_key: d.external_key, + created_at: d.created_at, + updated_at: d.updated_at, + }; +} + +export function formatDirectoryUser(u: WorkOSDirectoryUser): Record { + return { + object: 'directory_user', + id: u.id, + directory_id: u.directory_id, + organization_id: u.organization_id, + idp_id: u.idp_id, + first_name: u.first_name, + last_name: u.last_name, + email: u.email, + username: u.username, + state: u.state, + role: u.role, + custom_attributes: u.custom_attributes, + raw_attributes: u.raw_attributes, + groups: u.groups, + created_at: u.created_at, + updated_at: u.updated_at, + }; +} + +export function formatDirectoryGroup(g: WorkOSDirectoryGroup): Record { + return { + object: 'directory_group', + id: g.id, + directory_id: g.directory_id, + organization_id: g.organization_id, + idp_id: g.idp_id, + name: g.name, + raw_attributes: g.raw_attributes, + created_at: g.created_at, + updated_at: g.updated_at, + }; +} + +export function formatAuditLogAction(a: WorkOSAuditLogAction): Record { + return { + object: 'audit_log_action', + id: a.id, + name: a.name, + description: a.description, + condition: a.condition, + created_at: a.created_at, + updated_at: a.updated_at, + }; +} + +export function formatAuditLogEvent(e: WorkOSAuditLogEvent): Record { + return { + object: 'audit_log_event', + id: e.id, + organization_id: e.organization_id, + action: e.action, + actor: e.actor, + targets: e.targets, + metadata: e.metadata, + occurred_at: e.occurred_at, + created_at: e.created_at, + updated_at: e.updated_at, + }; +} + +export function formatAuditLogExport(ex: WorkOSAuditLogExport): Record { + return { + object: 'audit_log_export', + id: ex.id, + organization_id: ex.organization_id, + state: ex.state, + url: ex.url, + filters: ex.filters, + created_at: ex.created_at, + updated_at: ex.updated_at, + }; +} + +export function formatFeatureFlag(f: WorkOSFeatureFlag): Record { + return { + object: 'feature_flag', + id: f.id, + slug: f.slug, + name: f.name, + description: f.description, + type: f.type, + default_value: f.default_value, + enabled: f.enabled, + created_at: f.created_at, + updated_at: f.updated_at, + }; +} + +export function formatConnectApplication(a: WorkOSConnectApplication): Record { + return { + object: 'connect_application', + id: a.id, + name: a.name, + redirect_uris: a.redirect_uris, + client_id: a.client_id, + logo_url: a.logo_url, + created_at: a.created_at, + updated_at: a.updated_at, + }; +} + +export function formatClientSecret(s: WorkOSClientSecret): Record { + return { + object: 'client_secret', + id: s.id, + application_id: s.application_id, + last_four: s.last_four, + created_at: s.created_at, + updated_at: s.updated_at, + }; +} + +export function formatRadarAttempt(a: WorkOSRadarAttempt): Record { + return { + object: 'radar_attempt', + id: a.id, + user_id: a.user_id, + ip_address: a.ip_address, + user_agent: a.user_agent, + verdict: a.verdict, + signals: a.signals, + created_at: a.created_at, + updated_at: a.updated_at, + }; +} + +export function formatApiKeyRecord(k: WorkOSApiKey): Record { + return { + object: 'api_key', + id: k.id, + name: k.name, + created_at: k.created_at, + updated_at: k.updated_at, + }; +} + +export function formatEvent(e: WorkOSEvent): Record { + return { + object: 'event', + id: e.id, + event: e.event, + data: e.data, + environment_id: e.environment_id, + created_at: e.created_at, + }; +} + +export function formatWebhookEndpoint( + ep: WorkOSWebhookEndpoint, + opts?: { includeSecret?: boolean }, +): Record { + return { + object: 'webhook_endpoint', + id: ep.id, + url: ep.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 new file mode 100644 index 00000000..1f3f6749 --- /dev/null +++ b/src/emulate/workos/index.ts @@ -0,0 +1,444 @@ +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 { + 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 { + 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[]; +} + +function seedDefaults(_store: Store, _baseUrl: string): void { + // No default seed data — users provide their own via config +} + +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) { + ws.webhookEndpoints.insert({ + object: 'webhook_endpoint', + url: whConfig.url, + 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('eventBus', eventBus); + const ws = getWorkOSStore(ctx.store); + + ws.users.setHooks({ + onInsert: (u) => eventBus.emit({ event: 'user.created', data: formatUser(u) }), + onUpdate: (u) => eventBus.emit({ event: 'user.updated', data: formatUser(u) }), + onDelete: (u) => eventBus.emit({ event: 'user.deleted', data: formatUser(u) }), + }); + ws.organizations.setHooks({ + onInsert: (o) => eventBus.emit({ event: 'organization.created', data: formatOrganization(o, ws) }), + onUpdate: (o) => eventBus.emit({ event: 'organization.updated', data: formatOrganization(o, ws) }), + onDelete: (o) => eventBus.emit({ event: 'organization.deleted', data: formatOrganization(o, ws) }), + }); + ws.organizationDomains.setHooks({ + onInsert: (d) => eventBus.emit({ event: 'organization_domain.created', data: formatDomain(d) }), + onUpdate: (d) => + eventBus.emit({ + event: d.state === 'verified' ? 'organization_domain.verified' : 'organization_domain.updated', + data: formatDomain(d), + }), + onDelete: (d) => eventBus.emit({ event: 'organization_domain.deleted', data: formatDomain(d) }), + }); + ws.organizationMemberships.setHooks({ + onInsert: (m) => eventBus.emit({ event: 'organization_membership.created', data: formatMembership(m) }), + onUpdate: (m) => eventBus.emit({ event: 'organization_membership.updated', data: formatMembership(m) }), + onDelete: (m) => eventBus.emit({ event: 'organization_membership.deleted', data: formatMembership(m) }), + }); + ws.connections.setHooks({ + onInsert: (c) => eventBus.emit({ event: 'connection.created', data: formatConnection(c) }), + onUpdate: (c) => eventBus.emit({ event: 'connection.updated', data: formatConnection(c) }), + onDelete: (c) => eventBus.emit({ event: 'connection.deleted', data: formatConnection(c) }), + }); + ws.sessions.setHooks({ + onInsert: (s) => eventBus.emit({ event: 'session.created', data: formatSession(s) }), + onDelete: (s) => eventBus.emit({ event: 'session.revoked', data: formatSession(s) }), + }); + ws.invitations.setHooks({ + onInsert: (i) => eventBus.emit({ event: 'invitation.created', data: formatInvitation(i) }), + }); + ws.roles.setHooks({ + onInsert: (r) => eventBus.emit({ event: 'role.created', data: formatRole(r) }), + onUpdate: (r) => eventBus.emit({ event: 'role.updated', data: formatRole(r) }), + onDelete: (r) => eventBus.emit({ event: 'role.deleted', data: formatRole(r) }), + }); + ws.permissions.setHooks({ + onInsert: (p) => eventBus.emit({ event: 'permission.created', data: formatPermission(p) }), + onUpdate: (p) => eventBus.emit({ event: 'permission.updated', data: formatPermission(p) }), + onDelete: (p) => eventBus.emit({ event: 'permission.deleted', data: formatPermission(p) }), + }); + ws.directories.setHooks({ + onInsert: (d) => eventBus.emit({ event: 'directory.created', data: formatDirectory(d) }), + onUpdate: (d) => eventBus.emit({ event: 'directory.updated', data: formatDirectory(d) }), + onDelete: (d) => eventBus.emit({ event: 'directory.deleted', data: formatDirectory(d) }), + }); + ws.directoryUsers.setHooks({ + onInsert: (u) => eventBus.emit({ event: 'directory_user.created', data: formatDirectoryUser(u) }), + onUpdate: (u) => eventBus.emit({ event: 'directory_user.updated', data: formatDirectoryUser(u) }), + onDelete: (u) => eventBus.emit({ event: 'directory_user.deleted', data: formatDirectoryUser(u) }), + }); + ws.directoryGroups.setHooks({ + onInsert: (g) => eventBus.emit({ event: 'directory_group.created', data: formatDirectoryGroup(g) }), + onUpdate: (g) => eventBus.emit({ event: 'directory_group.updated', data: formatDirectoryGroup(g) }), + onDelete: (g) => eventBus.emit({ event: 'directory_group.deleted', data: formatDirectoryGroup(g) }), + }); + }, + seed(store: Store, baseUrl: string): void { + seedDefaults(store, baseUrl); + }, +}; + +export default workosPlugin; diff --git a/src/emulate/workos/routes/api-keys.spec.ts b/src/emulate/workos/routes/api-keys.spec.ts new file mode 100644 index 00000000..21d0e17d --- /dev/null +++ b/src/emulate/workos/routes/api-keys.spec.ts @@ -0,0 +1,74 @@ +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 new file mode 100644 index 00000000..8d21916a --- /dev/null +++ b/src/emulate/workos/routes/api-keys.ts @@ -0,0 +1,38 @@ +import { type RouteContext, notFound, parseJsonBody } from '../../core/index.js'; +import { getWorkOSStore } from '../store.js'; +import { formatApiKeyRecord, parseListParams } from '../helpers.js'; +import type { ApiKeyMap } from '../../core/index.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('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({ + object: 'list', + data: result.data.map(formatApiKeyRecord), + list_metadata: result.list_metadata, + }); + }); +} diff --git a/src/emulate/workos/routes/audit-logs.spec.ts b/src/emulate/workos/routes/audit-logs.spec.ts new file mode 100644 index 00000000..b66c82a8 --- /dev/null +++ b/src/emulate/workos/routes/audit-logs.spec.ts @@ -0,0 +1,114 @@ +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 new file mode 100644 index 00000000..9b04f0c8 --- /dev/null +++ b/src/emulate/workos/routes/audit-logs.ts @@ -0,0 +1,120 @@ +import { type RouteContext, notFound, parseJsonBody, validationError } from '../../core/index.js'; +import { getWorkOSStore } from '../store.js'; +import { formatAuditLogAction, formatAuditLogEvent, formatAuditLogExport, parseListParams } from '../helpers.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({ + object: 'list', + data: result.data.map(formatAuditLogAction), + list_metadata: result.list_metadata, + }); + }); + + // 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(`audit_schema_${actionName}`, body); + return c.json(formatAuditLogAction(action)); + } + + action = ws.auditLogActions.insert({ + object: 'audit_log_action', + name: actionName, + description: null, + condition: null, + }); + store.setData(`audit_schema_${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 new file mode 100644 index 00000000..6497eeaf --- /dev/null +++ b/src/emulate/workos/routes/auth-challenges.spec.ts @@ -0,0 +1,136 @@ +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 new file mode 100644 index 00000000..970e8cfe --- /dev/null +++ b/src/emulate/workos/routes/auth-challenges.ts @@ -0,0 +1,59 @@ +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 new file mode 100644 index 00000000..ec9e9f1f --- /dev/null +++ b/src/emulate/workos/routes/auth-factors.ts @@ -0,0 +1,56 @@ +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 new file mode 100644 index 00000000..a7327ba8 --- /dev/null +++ b/src/emulate/workos/routes/auth.spec.ts @@ -0,0 +1,477 @@ +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 new file mode 100644 index 00000000..11317d1d --- /dev/null +++ b/src/emulate/workos/routes/auth.ts @@ -0,0 +1,422 @@ +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'; + +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.all().find((ac) => ac.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(`pending_auth:${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(`pending_auth:${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(`pending_auth:${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(`pending_auth:${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('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 new file mode 100644 index 00000000..421bf7a9 --- /dev/null +++ b/src/emulate/workos/routes/authorization-checks.spec.ts @@ -0,0 +1,190 @@ +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 new file mode 100644 index 00000000..a53bf889 --- /dev/null +++ b/src/emulate/workos/routes/authorization-checks.ts @@ -0,0 +1,151 @@ +import { type RouteContext, notFound, validationError, parseJsonBody } from '../../core/index.js'; +import { getWorkOSStore } from '../store.js'; +import { formatRoleAssignment, formatAuthorizationResource, parseListParams } 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; + + // Permission check + app.post('/authorization/organization_memberships/:id/check', async (c) => { + const ws = getWorkOSStore(store); + 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 ws = getWorkOSStore(store); + 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({ + object: 'list', + data: result.data.map(formatAuthorizationResource), + list_metadata: result.list_metadata, + }); + }); + + // List role assignments for a membership + app.get('/authorization/organization_memberships/:id/role_assignments', (c) => { + const ws = getWorkOSStore(store); + 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({ + object: 'list', + data: result.data.map(formatRoleAssignment), + list_metadata: result.list_metadata, + }); + }); + + // Create role assignment + app.post('/authorization/organization_memberships/:id/role_assignments', async (c) => { + const ws = getWorkOSStore(store); + 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 ws = getWorkOSStore(store); + 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 new file mode 100644 index 00000000..09870afc --- /dev/null +++ b/src/emulate/workos/routes/authorization-org-roles.spec.ts @@ -0,0 +1,194 @@ +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 new file mode 100644 index 00000000..e3cd4f8f --- /dev/null +++ b/src/emulate/workos/routes/authorization-org-roles.ts @@ -0,0 +1,223 @@ +import { type RouteContext, notFound, validationError, parseJsonBody } from '../../core/index.js'; +import { getWorkOSStore } from '../store.js'; +import { formatRole, formatPermission, parseListParams } from '../helpers.js'; + +export function authorizationOrgRoleRoutes(ctx: RouteContext): void { + const { app, store } = ctx; + + app.post('/authorization/organizations/:orgId/roles', async (c) => { + const ws = getWorkOSStore(store); + const orgId = c.req.param('orgId'); + const org = ws.organizations.get(orgId); + if (!org) throw notFound('Organization'); + + 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' }]); + } + + // Check uniqueness within this org + const existing = ws.roles + .findBy('organization_id', orgId) + .find((r) => r.slug === slug && r.type === 'OrganizationRole'); + if (existing) { + throw validationError('Role with this slug already exists in this organization', [ + { field: 'slug', code: 'duplicate' }, + ]); + } + + const role = ws.roles.insert({ + object: 'role', + slug, + name, + description: (body.description as string) ?? null, + type: 'OrganizationRole', + organization_id: orgId, + is_default_role: Boolean(body.is_default_role), + priority: typeof body.priority === 'number' ? body.priority : 0, + }); + + return c.json(formatRole(role), 201); + }); + + app.get('/authorization/organizations/:orgId/roles', (c) => { + const ws = getWorkOSStore(store); + const orgId = c.req.param('orgId'); + const url = new URL(c.req.url); + const params = parseListParams(url); + + const result = ws.roles.list({ + ...params, + filter: (r) => r.organization_id === orgId && r.type === 'OrganizationRole', + }); + + return c.json({ + object: 'list', + data: result.data.map(formatRole), + list_metadata: result.list_metadata, + }); + }); + + // Priority ordering — must be registered before :slug routes + app.put('/authorization/organizations/:orgId/roles/priority', async (c) => { + const ws = getWorkOSStore(store); + 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' }]); + } + + for (let i = 0; i < slugs.length; i++) { + const role = ws.roles + .findBy('organization_id', orgId) + .find((r) => r.slug === slugs[i] && r.type === 'OrganizationRole'); + if (!role) throw notFound('Role'); + ws.roles.update(role.id, { priority: i }); + } + + const roles = ws.roles + .findBy('organization_id', orgId) + .filter((r) => r.type === 'OrganizationRole') + .sort((a, b) => a.priority - b.priority); + + return c.json({ + object: 'list', + data: roles.map(formatRole), + list_metadata: { before: null, after: null }, + }); + }); + + app.get('/authorization/organizations/:orgId/roles/:slug', (c) => { + const ws = getWorkOSStore(store); + const orgId = c.req.param('orgId'); + const slug = c.req.param('slug'); + const role = ws.roles + .findBy('organization_id', orgId) + .find((r) => r.slug === slug && r.type === 'OrganizationRole'); + if (!role) throw notFound('Role'); + return c.json(formatRole(role)); + }); + + app.put('/authorization/organizations/:orgId/roles/:slug', async (c) => { + const ws = getWorkOSStore(store); + const orgId = c.req.param('orgId'); + const slug = c.req.param('slug'); + const role = ws.roles + .findBy('organization_id', orgId) + .find((r) => r.slug === slug && r.type === 'OrganizationRole'); + if (!role) throw notFound('Role'); + + 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('/authorization/organizations/:orgId/roles/:slug', (c) => { + const ws = getWorkOSStore(store); + const orgId = c.req.param('orgId'); + const slug = c.req.param('slug'); + const role = ws.roles + .findBy('organization_id', orgId) + .find((r) => r.slug === slug && r.type === 'OrganizationRole'); + if (!role) throw notFound('Role'); + + // Cascade: remove role-permission joins and role assignments + const rps = ws.rolePermissions.findBy('role_id', role.id); + for (const rp of rps) ws.rolePermissions.delete(rp.id); + const ras = ws.roleAssignments.findBy('role_id', role.id); + for (const ra of ras) ws.roleAssignments.delete(ra.id); + + ws.roles.delete(role.id); + return c.body(null, 204); + }); + + // Org role permissions + app.get('/authorization/organizations/:orgId/roles/:slug/permissions', (c) => { + const ws = getWorkOSStore(store); + const orgId = c.req.param('orgId'); + const slug = c.req.param('slug'); + const role = ws.roles + .findBy('organization_id', orgId) + .find((r) => r.slug === slug && r.type === 'OrganizationRole'); + if (!role) throw notFound('Role'); + + const rps = ws.rolePermissions.findBy('role_id', role.id); + const permissions = rps.map((rp) => ws.permissions.get(rp.permission_id)).filter(Boolean); + + return c.json({ + object: 'list', + data: permissions.map((p) => formatPermission(p!)), + list_metadata: { before: null, after: null }, + }); + }); + + app.post('/authorization/organizations/:orgId/roles/:slug/permissions', async (c) => { + const ws = getWorkOSStore(store); + const orgId = c.req.param('orgId'); + const slug = c.req.param('slug'); + const role = ws.roles + .findBy('organization_id', orgId) + .find((r) => r.slug === slug && r.type === 'OrganizationRole'); + if (!role) throw notFound('Role'); + + 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' }]); + } + + // Replace all + const existing = ws.rolePermissions.findBy('role_id', role.id); + for (const rp of existing) ws.rolePermissions.delete(rp.id); + + for (const permSlug of permissionSlugs) { + const perm = ws.permissions.findOneBy('slug', permSlug); + if (!perm) throw notFound('Permission'); + ws.rolePermissions.insert({ role_id: role.id, permission_id: perm.id }); + } + + const rps = ws.rolePermissions.findBy('role_id', role.id); + const permissions = rps.map((rp) => ws.permissions.get(rp.permission_id)).filter(Boolean); + + return c.json({ + object: 'list', + data: permissions.map((p) => formatPermission(p!)), + list_metadata: { before: null, after: null }, + }); + }); + + app.delete('/authorization/organizations/:orgId/roles/:slug/permissions/:permissionSlug', (c) => { + const ws = getWorkOSStore(store); + const orgId = c.req.param('orgId'); + const slug = c.req.param('slug'); + const permissionSlug = c.req.param('permissionSlug'); + + const role = ws.roles + .findBy('organization_id', orgId) + .find((r) => r.slug === slug && r.type === 'OrganizationRole'); + if (!role) throw notFound('Role'); + + const perm = ws.permissions.findOneBy('slug', 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 new file mode 100644 index 00000000..4a4553fc --- /dev/null +++ b/src/emulate/workos/routes/authorization-permissions.spec.ts @@ -0,0 +1,137 @@ +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 new file mode 100644 index 00000000..5275d147 --- /dev/null +++ b/src/emulate/workos/routes/authorization-permissions.ts @@ -0,0 +1,87 @@ +import { type RouteContext, notFound, validationError, parseJsonBody } from '../../core/index.js'; +import { getWorkOSStore } from '../store.js'; +import { formatPermission, parseListParams } from '../helpers.js'; + +export function authorizationPermissionRoutes(ctx: RouteContext): void { + const { app, store } = ctx; + + app.post('/authorization/permissions', async (c) => { + const ws = getWorkOSStore(store); + 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 ws = getWorkOSStore(store); + const url = new URL(c.req.url); + const params = parseListParams(url); + + const result = ws.permissions.list(params); + return c.json({ + object: 'list', + data: result.data.map(formatPermission), + list_metadata: result.list_metadata, + }); + }); + + app.get('/authorization/permissions/:slug', (c) => { + const ws = getWorkOSStore(store); + 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 ws = getWorkOSStore(store); + 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 ws = getWorkOSStore(store); + 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 + const rps = ws.rolePermissions.findBy('permission_id', permission.id); + for (const rp of rps) { + ws.rolePermissions.delete(rp.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 new file mode 100644 index 00000000..fdf56de9 --- /dev/null +++ b/src/emulate/workos/routes/authorization-resources.spec.ts @@ -0,0 +1,178 @@ +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 new file mode 100644 index 00000000..e9b9a1b1 --- /dev/null +++ b/src/emulate/workos/routes/authorization-resources.ts @@ -0,0 +1,140 @@ +import { type RouteContext, notFound, validationError, parseJsonBody } from '../../core/index.js'; +import { getWorkOSStore } from '../store.js'; +import { formatAuthorizationResource, formatMembership, parseListParams } from '../helpers.js'; + +export function authorizationResourceRoutes(ctx: RouteContext): void { + const { app, store } = ctx; + + app.post('/authorization/resources', async (c) => { + const ws = getWorkOSStore(store); + 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 ws = getWorkOSStore(store); + 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({ + object: 'list', + data: result.data.map(formatAuthorizationResource), + list_metadata: result.list_metadata, + }); + }); + + app.get('/authorization/resources/:resource_id', (c) => { + const ws = getWorkOSStore(store); + 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 ws = getWorkOSStore(store); + 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 ws = getWorkOSStore(store); + 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 ws = getWorkOSStore(store); + 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 ws = getWorkOSStore(store); + 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 ws = getWorkOSStore(store); + 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 new file mode 100644 index 00000000..486844f3 --- /dev/null +++ b/src/emulate/workos/routes/authorization-roles.spec.ts @@ -0,0 +1,175 @@ +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 new file mode 100644 index 00000000..fc7c5a81 --- /dev/null +++ b/src/emulate/workos/routes/authorization-roles.ts @@ -0,0 +1,147 @@ +import { type RouteContext, notFound, validationError, parseJsonBody } from '../../core/index.js'; +import { getWorkOSStore } from '../store.js'; +import { formatRole, formatPermission, parseListParams } from '../helpers.js'; + +export function authorizationRoleRoutes(ctx: RouteContext): void { + const { app, store } = ctx; + + app.post('/authorization/roles', async (c) => { + const ws = getWorkOSStore(store); + 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' }]); + } + + // Check uniqueness among environment roles + const existing = ws.roles.findBy('slug', slug).find((r) => r.type === 'EnvironmentRole'); + if (existing) { + throw validationError('Role with this slug already exists', [{ field: 'slug', code: 'duplicate' }]); + } + + const role = ws.roles.insert({ + object: 'role', + slug, + name, + description: (body.description as string) ?? null, + type: 'EnvironmentRole', + 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('/authorization/roles', (c) => { + const ws = getWorkOSStore(store); + const url = new URL(c.req.url); + const params = parseListParams(url); + + const result = ws.roles.list({ + ...params, + filter: (r) => r.type === 'EnvironmentRole', + }); + + return c.json({ + object: 'list', + data: result.data.map(formatRole), + list_metadata: result.list_metadata, + }); + }); + + app.get('/authorization/roles/:slug', (c) => { + const ws = getWorkOSStore(store); + const slug = c.req.param('slug'); + const role = ws.roles.findBy('slug', slug).find((r) => r.type === 'EnvironmentRole'); + if (!role) throw notFound('Role'); + return c.json(formatRole(role)); + }); + + app.put('/authorization/roles/:slug', async (c) => { + const ws = getWorkOSStore(store); + const slug = c.req.param('slug'); + const role = ws.roles.findBy('slug', slug).find((r) => r.type === 'EnvironmentRole'); + if (!role) throw notFound('Role'); + + 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('/authorization/roles/:slug', (c) => { + const ws = getWorkOSStore(store); + const slug = c.req.param('slug'); + const role = ws.roles.findBy('slug', slug).find((r) => r.type === 'EnvironmentRole'); + if (!role) throw notFound('Role'); + + // Cascade: remove role-permission joins and role assignments + const rps = ws.rolePermissions.findBy('role_id', role.id); + for (const rp of rps) ws.rolePermissions.delete(rp.id); + const ras = ws.roleAssignments.findBy('role_id', role.id); + for (const ra of ras) ws.roleAssignments.delete(ra.id); + + ws.roles.delete(role.id); + return c.body(null, 204); + }); + + // Role permissions management + app.get('/authorization/roles/:slug/permissions', (c) => { + const ws = getWorkOSStore(store); + const slug = c.req.param('slug'); + const role = ws.roles.findBy('slug', slug).find((r) => r.type === 'EnvironmentRole'); + if (!role) throw notFound('Role'); + + const rps = ws.rolePermissions.findBy('role_id', role.id); + const permissions = rps.map((rp) => ws.permissions.get(rp.permission_id)).filter(Boolean); + + return c.json({ + object: 'list', + data: permissions.map((p) => formatPermission(p!)), + list_metadata: { before: null, after: null }, + }); + }); + + app.post('/authorization/roles/:slug/permissions', async (c) => { + const ws = getWorkOSStore(store); + const slug = c.req.param('slug'); + const role = ws.roles.findBy('slug', slug).find((r) => r.type === 'EnvironmentRole'); + if (!role) throw notFound('Role'); + + 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' }]); + } + + // Replace all: delete existing, add new + const existing = ws.rolePermissions.findBy('role_id', role.id); + for (const rp of existing) ws.rolePermissions.delete(rp.id); + + for (const permSlug of permissionSlugs) { + const perm = ws.permissions.findOneBy('slug', permSlug); + if (!perm) throw notFound('Permission'); + ws.rolePermissions.insert({ role_id: role.id, permission_id: perm.id }); + } + + const rps = ws.rolePermissions.findBy('role_id', role.id); + const permissions = rps.map((rp) => ws.permissions.get(rp.permission_id)).filter(Boolean); + + return c.json({ + object: 'list', + data: permissions.map((p) => formatPermission(p!)), + list_metadata: { before: null, after: null }, + }); + }); +} diff --git a/src/emulate/workos/routes/config.spec.ts b/src/emulate/workos/routes/config.spec.ts new file mode 100644 index 00000000..0ea7fa82 --- /dev/null +++ b/src/emulate/workos/routes/config.spec.ts @@ -0,0 +1,99 @@ +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 new file mode 100644 index 00000000..901caaf5 --- /dev/null +++ b/src/emulate/workos/routes/config.ts @@ -0,0 +1,66 @@ +import { type RouteContext, parseJsonBody, WorkOSApiError, validationError } from '../../core/index.js'; +import { getWorkOSStore } from '../store.js'; +import { formatRedirectUri, formatCorsOrigin } from '../helpers.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>('jwt_template') ?? { + 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('jwt_template', template); + return c.json(template); + }); +} diff --git a/src/emulate/workos/routes/connect.spec.ts b/src/emulate/workos/routes/connect.spec.ts new file mode 100644 index 00000000..26987a99 --- /dev/null +++ b/src/emulate/workos/routes/connect.spec.ts @@ -0,0 +1,96 @@ +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 new file mode 100644 index 00000000..9fb738e9 --- /dev/null +++ b/src/emulate/workos/routes/connect.ts @@ -0,0 +1,83 @@ +import { type RouteContext, notFound, parseJsonBody, validationError } from '../../core/index.js'; +import { generateId } from '../../core/index.js'; +import { getWorkOSStore } from '../store.js'; +import { + formatConnectApplication, + formatClientSecret, + parseListParams, + generateVerificationToken, +} 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({ + object: 'list', + data: result.data.map(formatConnectApplication), + list_metadata: result.list_metadata, + }); + }); + + // 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 new file mode 100644 index 00000000..2d1c9306 --- /dev/null +++ b/src/emulate/workos/routes/connections.spec.ts @@ -0,0 +1,96 @@ +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 new file mode 100644 index 00000000..cee6e3ed --- /dev/null +++ b/src/emulate/workos/routes/connections.ts @@ -0,0 +1,84 @@ +import { type RouteContext, notFound, parseJsonBody, generateId } from '../../core/index.js'; +import { getWorkOSStore } from '../store.js'; +import { formatConnection, parseListParams } 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({ + object: 'list', + data: result.data.map(formatConnection), + list_metadata: result.list_metadata, + }); + }); + + 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 new file mode 100644 index 00000000..17aa325b --- /dev/null +++ b/src/emulate/workos/routes/data-integrations.spec.ts @@ -0,0 +1,91 @@ +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 new file mode 100644 index 00000000..d10d735c --- /dev/null +++ b/src/emulate/workos/routes/data-integrations.ts @@ -0,0 +1,65 @@ +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 new file mode 100644 index 00000000..616a0181 --- /dev/null +++ b/src/emulate/workos/routes/directories.spec.ts @@ -0,0 +1,149 @@ +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 new file mode 100644 index 00000000..f7698f51 --- /dev/null +++ b/src/emulate/workos/routes/directories.ts @@ -0,0 +1,111 @@ +import { type RouteContext, notFound } from '../../core/index.js'; +import { getWorkOSStore } from '../store.js'; +import { formatDirectory, formatDirectoryUser, formatDirectoryGroup, parseListParams } 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({ + object: 'list', + data: result.data.map(formatDirectory), + list_metadata: result.list_metadata, + }); + }); + + // 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'); + + const users = ws.directoryUsers.findBy('directory_id', dir.id); + for (const u of users) ws.directoryUsers.delete(u.id); + + const groups = ws.directoryGroups.findBy('directory_id', dir.id); + for (const g of groups) ws.directoryGroups.delete(g.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({ + object: 'list', + data: result.data.map(formatDirectoryUser), + list_metadata: result.list_metadata, + }); + }); + + // 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({ + object: 'list', + data: result.data.map(formatDirectoryGroup), + list_metadata: result.list_metadata, + }); + }); + + // 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 new file mode 100644 index 00000000..6791ddd9 --- /dev/null +++ b/src/emulate/workos/routes/email-verification.ts @@ -0,0 +1,56 @@ +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 new file mode 100644 index 00000000..ace3475b --- /dev/null +++ b/src/emulate/workos/routes/events.spec.ts @@ -0,0 +1,68 @@ +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 new file mode 100644 index 00000000..88ffe96f --- /dev/null +++ b/src/emulate/workos/routes/events.ts @@ -0,0 +1,25 @@ +import { type RouteContext } from '../../core/index.js'; +import { getWorkOSStore } from '../store.js'; +import { formatEvent, parseListParams } 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({ + object: 'list', + data: result.data.map(formatEvent), + list_metadata: result.list_metadata, + }); + }); +} diff --git a/src/emulate/workos/routes/feature-flags.spec.ts b/src/emulate/workos/routes/feature-flags.spec.ts new file mode 100644 index 00000000..783b8408 --- /dev/null +++ b/src/emulate/workos/routes/feature-flags.spec.ts @@ -0,0 +1,139 @@ +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 new file mode 100644 index 00000000..02d2bfc9 --- /dev/null +++ b/src/emulate/workos/routes/feature-flags.ts @@ -0,0 +1,149 @@ +import { type RouteContext, notFound, parseJsonBody } from '../../core/index.js'; +import { getWorkOSStore } from '../store.js'; +import { formatFeatureFlag, parseListParams } from '../helpers.js'; + +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({ + object: 'list', + data: result.data.map(formatFeatureFlag), + list_metadata: result.list_metadata, + }); + }); + + // 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({ + object: 'flag_target', + id: updated!.id, + flag_slug: updated!.flag_slug, + resource_id: updated!.resource_id, + resource_type: updated!.resource_type, + value: updated!.value, + }); + } + + 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( + { + object: 'flag_target', + id: target.id, + flag_slug: target.flag_slug, + resource_id: target.resource_id, + resource_type: target.resource_type, + value: target.value, + }, + 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) => { + const orgId = c.req.param('orgId'); + const flags = ws.featureFlags.all(); + + const evaluations = flags.map((flag) => { + const target = ws.flagTargets.findBy('flag_slug', flag.slug).find((t) => t.resource_id === orgId); + + return { + slug: flag.slug, + type: flag.type, + value: target ? target.value : flag.enabled ? flag.default_value : null, + enabled: flag.enabled, + }; + }); + + return c.json({ + object: 'list', + data: evaluations, + list_metadata: { before: null, after: null }, + }); + }); + + // Evaluate flags for user (replaces stub in user-features.ts) + app.get('/user_management/users/:userId/feature-flags', (c) => { + const userId = c.req.param('userId'); + const flags = ws.featureFlags.all(); + + const evaluations = flags.map((flag) => { + const target = ws.flagTargets.findBy('flag_slug', flag.slug).find((t) => t.resource_id === userId); + + return { + slug: flag.slug, + type: flag.type, + value: target ? target.value : flag.enabled ? flag.default_value : null, + enabled: flag.enabled, + }; + }); + + return c.json({ + object: 'list', + data: evaluations, + list_metadata: { before: null, after: null }, + }); + }); +} diff --git a/src/emulate/workos/routes/invitations.spec.ts b/src/emulate/workos/routes/invitations.spec.ts new file mode 100644 index 00000000..2e7b3cd2 --- /dev/null +++ b/src/emulate/workos/routes/invitations.spec.ts @@ -0,0 +1,193 @@ +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 new file mode 100644 index 00000000..4f1db272 --- /dev/null +++ b/src/emulate/workos/routes/invitations.ts @@ -0,0 +1,138 @@ +import { type RouteContext, notFound, validationError, parseJsonBody, WorkOSApiError } from '../../core/index.js'; +import { getWorkOSStore } from '../store.js'; +import { formatInvitation, generateVerificationToken, expiresIn, parseListParams } from '../helpers.js'; +import type { EventBus } from '../event-bus.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({ + object: 'list', + data: result.data.map(formatInvitation), + list_metadata: result.list_metadata, + }); + }); + + 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('eventBus'); + eventBus?.emit({ event: 'invitation.accepted', 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('eventBus'); + eventBus?.emit({ event: 'invitation.revoked', 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('eventBus'); + eventBus?.emit({ event: 'invitation.resent', 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 new file mode 100644 index 00000000..e61969a7 --- /dev/null +++ b/src/emulate/workos/routes/legacy-mfa.spec.ts @@ -0,0 +1,96 @@ +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 new file mode 100644 index 00000000..c9ac435f --- /dev/null +++ b/src/emulate/workos/routes/legacy-mfa.ts @@ -0,0 +1,83 @@ +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 new file mode 100644 index 00000000..e6686514 --- /dev/null +++ b/src/emulate/workos/routes/magic-auth.ts @@ -0,0 +1,35 @@ +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 new file mode 100644 index 00000000..75a02f4a --- /dev/null +++ b/src/emulate/workos/routes/memberships.spec.ts @@ -0,0 +1,114 @@ +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 new file mode 100644 index 00000000..18646859 --- /dev/null +++ b/src/emulate/workos/routes/memberships.ts @@ -0,0 +1,127 @@ +import { type RouteContext, notFound, validationError, parseJsonBody, WorkOSApiError } from '../../core/index.js'; +import { getWorkOSStore } from '../store.js'; +import { formatMembership, parseListParams } 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({ + object: 'list', + data: result.data.map(formatMembership), + list_metadata: result.list_metadata, + }); + }); + + 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 new file mode 100644 index 00000000..7efd7ff2 --- /dev/null +++ b/src/emulate/workos/routes/organization-domains.ts @@ -0,0 +1,64 @@ +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 new file mode 100644 index 00000000..09dd9829 --- /dev/null +++ b/src/emulate/workos/routes/organizations.spec.ts @@ -0,0 +1,146 @@ +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 new file mode 100644 index 00000000..09a0438e --- /dev/null +++ b/src/emulate/workos/routes/organizations.ts @@ -0,0 +1,147 @@ +import { type RouteContext, notFound, validationError, parseJsonBody } from '../../core/index.js'; +import { getWorkOSStore } from '../store.js'; +import { formatOrganization, generateVerificationToken, parseListParams } from '../helpers.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; + }, + }); + + return c.json({ + object: 'list', + data: result.data.map((org) => formatOrganization(org, ws)), + list_metadata: result.list_metadata, + }); + }); + + 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'); + + const domains = ws.organizationDomains.findBy('organization_id', org.id); + for (const d of domains) { + ws.organizationDomains.delete(d.id); + } + + const memberships = ws.organizationMemberships.findBy('organization_id', org.id); + for (const m of memberships) { + ws.organizationMemberships.delete(m.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 new file mode 100644 index 00000000..4295b032 --- /dev/null +++ b/src/emulate/workos/routes/password-reset.ts @@ -0,0 +1,70 @@ +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 new file mode 100644 index 00000000..d712b626 --- /dev/null +++ b/src/emulate/workos/routes/pipes.spec.ts @@ -0,0 +1,261 @@ +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 new file mode 100644 index 00000000..d9de84f1 --- /dev/null +++ b/src/emulate/workos/routes/pipes.ts @@ -0,0 +1,97 @@ +import { type RouteContext, notFound, validationError, parseJsonBody } from '../../core/index.js'; +import { getWorkOSStore } from '../store.js'; +import { formatPipeConnection, parseListParams } 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({ + object: 'list', + data: result.data.map(formatPipeConnection), + list_metadata: result.list_metadata, + }); + }); + + 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 new file mode 100644 index 00000000..70d110cd --- /dev/null +++ b/src/emulate/workos/routes/portal.spec.ts @@ -0,0 +1,47 @@ +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 new file mode 100644 index 00000000..1ff91663 --- /dev/null +++ b/src/emulate/workos/routes/portal.ts @@ -0,0 +1,21 @@ +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 new file mode 100644 index 00000000..523f09ee --- /dev/null +++ b/src/emulate/workos/routes/radar.spec.ts @@ -0,0 +1,86 @@ +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 new file mode 100644 index 00000000..984407a4 --- /dev/null +++ b/src/emulate/workos/routes/radar.ts @@ -0,0 +1,47 @@ +import { type RouteContext, notFound, parseJsonBody } from '../../core/index.js'; +import { getWorkOSStore } from '../store.js'; +import { formatRadarAttempt, parseListParams } 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({ + object: 'list', + data: result.data.map(formatRadarAttempt), + list_metadata: result.list_metadata, + }); + }); + + // 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 new file mode 100644 index 00000000..13710b5f --- /dev/null +++ b/src/emulate/workos/routes/sessions.spec.ts @@ -0,0 +1,106 @@ +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 new file mode 100644 index 00000000..bacd1ae4 --- /dev/null +++ b/src/emulate/workos/routes/sessions.ts @@ -0,0 +1,58 @@ +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 new file mode 100644 index 00000000..0043b9c7 --- /dev/null +++ b/src/emulate/workos/routes/sso.spec.ts @@ -0,0 +1,107 @@ +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 new file mode 100644 index 00000000..75a586c8 --- /dev/null +++ b/src/emulate/workos/routes/sso.ts @@ -0,0 +1,185 @@ +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'; + +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.all().find((a) => a.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(`sso_token:${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(`sso_token:${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(`sso_logout:${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(`sso_logout:${logoutToken}`); + if (!profileId) { + throw new WorkOSApiError(400, 'Invalid logout token', 'invalid_logout_token'); + } + + store.setData(`sso_logout:${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 new file mode 100644 index 00000000..7d810b8b --- /dev/null +++ b/src/emulate/workos/routes/user-features.spec.ts @@ -0,0 +1,133 @@ +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 new file mode 100644 index 00000000..e1cd1db7 --- /dev/null +++ b/src/emulate/workos/routes/user-features.ts @@ -0,0 +1,54 @@ +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 new file mode 100644 index 00000000..658f0745 --- /dev/null +++ b/src/emulate/workos/routes/users.spec.ts @@ -0,0 +1,225 @@ +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 new file mode 100644 index 00000000..424eb672 --- /dev/null +++ b/src/emulate/workos/routes/users.ts @@ -0,0 +1,140 @@ +import { type RouteContext, notFound, validationError, parseJsonBody, WorkOSApiError } from '../../core/index.js'; +import { getWorkOSStore } from '../store.js'; +import { formatUser, formatIdentity, hashPassword, parseListParams } 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({ + object: 'list', + data: result.data.map(formatUser), + list_metadata: result.list_metadata, + }); + }); + + 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 new file mode 100644 index 00000000..244b81d0 --- /dev/null +++ b/src/emulate/workos/routes/webhook-endpoints.spec.ts @@ -0,0 +1,120 @@ +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({ url: 'http://localhost:3000/webhooks' }), + }); + expect(res.status).toBe(201); + const ep = await json(res); + expect(ep.object).toBe('webhook_endpoint'); + expect(ep.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({ + 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({ 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({ 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({ 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({ 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('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 new file mode 100644 index 00000000..724c4aaa --- /dev/null +++ b/src/emulate/workos/routes/webhook-endpoints.ts @@ -0,0 +1,76 @@ +import { randomBytes } from 'node:crypto'; +import { type RouteContext, notFound, validationError, parseJsonBody } from '../../core/index.js'; +import { getWorkOSStore } from '../store.js'; +import { formatWebhookEndpoint, parseListParams } 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 url = body.url as string | undefined; + if (!url || typeof url !== 'string') { + throw validationError('URL is required', [{ field: 'url', code: 'required' }]); + } + + const secret = (body.secret as string) ?? randomBytes(32).toString('hex'); + + const endpoint = ws.webhookEndpoints.insert({ + object: 'webhook_endpoint', + url, + 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({ + object: 'list', + data: result.data.map((ep) => formatWebhookEndpoint(ep)), + list_metadata: result.list_metadata, + }); + }); + + 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 ('url' in body) { + if (!body.url || typeof body.url !== 'string') { + throw validationError('URL is required', [{ field: 'url', code: 'required' }]); + } + updates.url = body.url; + } + 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 new file mode 100644 index 00000000..ce3df860 --- /dev/null +++ b/src/emulate/workos/routes/widgets.spec.ts @@ -0,0 +1,62 @@ +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 new file mode 100644 index 00000000..e095c302 --- /dev/null +++ b/src/emulate/workos/routes/widgets.ts @@ -0,0 +1,31 @@ +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 new file mode 100644 index 00000000..12de6eef --- /dev/null +++ b/src/emulate/workos/store.ts @@ -0,0 +1,193 @@ +import { type Store, type Collection } from '../core/index.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 { + return { + organizations: store.collection('workos.organizations', 'org', ['name', 'external_id']), + organizationDomains: store.collection('workos.organization_domains', 'org_domain', [ + 'organization_id', + 'domain', + ]), + organizationMemberships: store.collection('workos.organization_memberships', 'om', [ + 'organization_id', + 'user_id', + ]), + users: store.collection('workos.users', 'user', ['email', 'external_id']), + sessions: store.collection('workos.sessions', 'session', ['user_id']), + emailVerifications: store.collection('workos.email_verifications', 'email_verification', [ + 'user_id', + ]), + passwordResets: store.collection('workos.password_resets', 'password_reset', ['user_id']), + magicAuths: store.collection('workos.magic_auths', 'magic_auth', ['user_id']), + authFactors: store.collection('workos.auth_factors', 'auth_factor', ['user_id']), + authCodes: store.collection('workos.auth_codes', 'auth_code', ['user_id', 'code']), + identities: store.collection('workos.identities', 'identity', ['user_id']), + connections: store.collection('workos.connections', 'conn', ['organization_id']), + ssoProfiles: store.collection('workos.sso_profiles', 'prof', ['connection_id', 'email']), + ssoAuthorizations: store.collection('workos.sso_authorizations', 'sso_auth', ['code']), + pipeConnections: store.collection('workos.pipe_connections', 'pipe_conn', [ + 'user_id', + 'provider', + ]), + refreshTokens: store.collection('workos.refresh_tokens', 'ref', [ + 'token', + 'user_id', + 'session_id', + ]), + authChallenges: store.collection('workos.auth_challenges', 'auth_challenge', [ + 'user_id', + 'factor_id', + ]), + deviceAuthorizations: store.collection('workos.device_authorizations', 'dev_auth', [ + 'device_code', + 'user_code', + ]), + invitations: store.collection('workos.invitations', 'inv', ['email', 'token', 'organization_id']), + redirectUris: store.collection('workos.redirect_uris', 'redir', ['uri']), + corsOrigins: store.collection('workos.cors_origins', 'cors', ['origin']), + authorizedApplications: store.collection( + 'workos.authorized_applications', + 'auth_app', + ['user_id'], + ), + connectedAccounts: store.collection('workos.connected_accounts', 'conn_acct', [ + 'user_id', + 'provider', + ]), + roles: store.collection('workos.roles', 'role', ['slug', 'organization_id']), + permissions: store.collection('workos.permissions', 'perm', ['slug']), + rolePermissions: store.collection('workos.role_permissions', 'rp', [ + 'role_id', + 'permission_id', + ]), + authorizationResources: store.collection( + 'workos.authorization_resources', + 'auth_res', + ['organization_id', 'resource_type_slug'], + ), + roleAssignments: store.collection('workos.role_assignments', 'ra', [ + 'organization_membership_id', + 'role_id', + ]), + directories: store.collection('workos.directories', 'directory', ['organization_id']), + directoryUsers: store.collection('workos.directory_users', 'directory_user', [ + 'directory_id', + 'organization_id', + ]), + directoryGroups: store.collection('workos.directory_groups', 'directory_grp', [ + 'directory_id', + 'organization_id', + ]), + auditLogActions: store.collection('workos.audit_log_actions', 'audit_action', ['name']), + auditLogEvents: store.collection('workos.audit_log_events', 'audit_event', [ + 'organization_id', + ]), + auditLogExports: store.collection('workos.audit_log_exports', 'audit_export', [ + 'organization_id', + ]), + featureFlags: store.collection('workos.feature_flags', 'ff', ['slug']), + flagTargets: store.collection('workos.flag_targets', 'ff_target', ['flag_slug', 'resource_id']), + connectApplications: store.collection('workos.connect_applications', 'connect_app', [ + 'client_id', + ]), + clientSecrets: store.collection('workos.client_secrets', 'client_secret', ['application_id']), + dataIntegrationAuths: store.collection('workos.data_integration_auths', 'di_auth', [ + 'code', + 'slug', + ]), + radarAttempts: store.collection('workos.radar_attempts', 'radar_attempt', ['ip_address']), + apiKeyRecords: store.collection('workos.api_keys', 'api_key', ['key', 'environment']), + events: store.collection('workos.events', 'evt', ['event']), + webhookEndpoints: store.collection('workos.webhook_endpoints', 'we', ['url']), + }; +} diff --git a/src/emulate/workos/webhook-signer.spec.ts b/src/emulate/workos/webhook-signer.spec.ts new file mode 100644 index 00000000..d29ee503 --- /dev/null +++ b/src/emulate/workos/webhook-signer.spec.ts @@ -0,0 +1,34 @@ +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 new file mode 100644 index 00000000..0fd3b069 --- /dev/null +++ b/src/emulate/workos/webhook-signer.ts @@ -0,0 +1,9 @@ +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/lib/dev-command.spec.ts b/src/lib/dev-command.spec.ts new file mode 100644 index 00000000..652e4b2e --- /dev/null +++ b/src/lib/dev-command.spec.ts @@ -0,0 +1,209 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { resolveDevCommand, _detectPackageManager } from './dev-command.js'; +import { mkdtempSync, writeFileSync, mkdirSync, rmSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; + +describe('resolveDevCommand', () => { + let projectDir: string; + + beforeEach(() => { + projectDir = mkdtempSync(join(tmpdir(), 'workos-dev-test-')); + }); + + afterEach(() => { + rmSync(projectDir, { recursive: true, force: true }); + }); + + function writePackageJson(content: Record): void { + writeFileSync(join(projectDir, 'package.json'), JSON.stringify(content)); + } + + function writeFile(name: string, content = ''): void { + writeFileSync(join(projectDir, name), content); + } + + it('uses scripts.dev from package.json over framework defaults', async () => { + writePackageJson({ + scripts: { dev: 'next dev --turbopack' }, + dependencies: { next: '15.0.0' }, + }); + + const result = await resolveDevCommand(projectDir); + expect(result.command).toBe('npm'); + expect(result.args).toEqual(['run', 'dev']); + expect(result.framework).toBe('Next.js'); + }); + + it('detects Next.js and falls back to next dev without scripts.dev', async () => { + writePackageJson({ + dependencies: { next: '15.0.0' }, + }); + + const result = await resolveDevCommand(projectDir); + expect(result.command).toContain('next'); + expect(result.args).toEqual(['dev']); + expect(result.framework).toBe('Next.js'); + }); + + it('detects Vite project', async () => { + writePackageJson({ + devDependencies: { vite: '^5.0.0' }, + }); + + const result = await resolveDevCommand(projectDir); + expect(result.command).toContain('vite'); + expect(result.args).toEqual(['dev']); + expect(result.framework).toBe('Vite'); + }); + + it('detects Remix project', async () => { + writePackageJson({ + dependencies: { '@remix-run/dev': '^2.0.0' }, + }); + + const result = await resolveDevCommand(projectDir); + expect(result.command).toContain('remix'); + expect(result.args).toEqual(['dev']); + expect(result.framework).toBe('Remix'); + }); + + it('detects SvelteKit project', async () => { + writePackageJson({ + devDependencies: { '@sveltejs/kit': '^2.0.0' }, + }); + + const result = await resolveDevCommand(projectDir); + expect(result.command).toContain('vite'); + expect(result.args).toEqual(['dev']); + expect(result.framework).toBe('SvelteKit'); + }); + + it('detects Django project via manage.py', async () => { + writeFile('manage.py', '#!/usr/bin/env python'); + + const result = await resolveDevCommand(projectDir); + expect(result.command).toBe('python'); + expect(result.args).toEqual(['manage.py', 'runserver']); + expect(result.framework).toBe('Django'); + }); + + it('detects Rails project via Gemfile', async () => { + writeFile('Gemfile', 'source "https://rubygems.org"'); + + const result = await resolveDevCommand(projectDir); + expect(result.command).toBe('rails'); + expect(result.args).toEqual(['server']); + expect(result.framework).toBe('Rails'); + }); + + it('detects Go project via go.mod', async () => { + writeFile('go.mod', 'module example.com/app'); + + const result = await resolveDevCommand(projectDir); + expect(result.command).toBe('go'); + expect(result.args).toEqual(['run', '.']); + expect(result.framework).toBe('Go'); + }); + + it('falls back to npm run dev when nothing is detected', async () => { + // Empty directory, no package.json, no framework files + const result = await resolveDevCommand(projectDir); + expect(result.command).toBe('npm'); + expect(result.args).toEqual(['run', 'dev']); + expect(result.framework).toBeNull(); + }); + + it('detects pnpm package manager from lockfile', async () => { + writePackageJson({ + scripts: { dev: 'next dev' }, + dependencies: { next: '15.0.0' }, + }); + writeFile('pnpm-lock.yaml'); + + const result = await resolveDevCommand(projectDir); + expect(result.command).toBe('pnpm'); + expect(result.args).toEqual(['run', 'dev']); + }); + + it('detects yarn package manager from lockfile', async () => { + writePackageJson({ + scripts: { dev: 'next dev' }, + dependencies: { next: '15.0.0' }, + }); + writeFile('yarn.lock'); + + const result = await resolveDevCommand(projectDir); + expect(result.command).toBe('yarn'); + expect(result.args).toEqual(['run', 'dev']); + }); + + it('detects bun package manager from lockfile', async () => { + writePackageJson({ + scripts: { dev: 'next dev' }, + dependencies: { next: '15.0.0' }, + }); + writeFile('bun.lockb'); + + const result = await resolveDevCommand(projectDir); + expect(result.command).toBe('bun'); + expect(result.args).toEqual(['run', 'dev']); + }); + + it('prefers scripts.dev with detected framework for display', async () => { + writePackageJson({ + scripts: { dev: 'remix dev' }, + dependencies: { '@remix-run/dev': '^2.0.0' }, + }); + + const result = await resolveDevCommand(projectDir); + // Uses package manager run (scripts.dev takes priority) + expect(result.args).toEqual(['run', 'dev']); + // But still reports the detected framework + expect(result.framework).toBe('Remix'); + }); + + it('uses node_modules/.bin path when binary exists', async () => { + writePackageJson({ + dependencies: { next: '15.0.0' }, + }); + // Create a fake node_modules/.bin/next + mkdirSync(join(projectDir, 'node_modules', '.bin'), { recursive: true }); + writeFile('node_modules/.bin/next', '#!/usr/bin/env node'); + + const result = await resolveDevCommand(projectDir); + expect(result.command).toBe(join(projectDir, 'node_modules', '.bin', 'next')); + expect(result.args).toEqual(['dev']); + }); +}); + +describe('_detectPackageManager', () => { + let projectDir: string; + + beforeEach(() => { + projectDir = mkdtempSync(join(tmpdir(), 'workos-pm-test-')); + }); + + afterEach(() => { + rmSync(projectDir, { recursive: true, force: true }); + }); + + it('defaults to npm when no lockfile found', () => { + expect(_detectPackageManager(projectDir)).toBe('npm'); + }); + + it('detects pnpm', () => { + writeFileSync(join(projectDir, 'pnpm-lock.yaml'), ''); + expect(_detectPackageManager(projectDir)).toBe('pnpm'); + }); + + it('detects yarn', () => { + writeFileSync(join(projectDir, 'yarn.lock'), ''); + expect(_detectPackageManager(projectDir)).toBe('yarn'); + }); + + it('detects bun', () => { + writeFileSync(join(projectDir, 'bun.lockb'), ''); + expect(_detectPackageManager(projectDir)).toBe('bun'); + }); +}); diff --git a/src/lib/dev-command.ts b/src/lib/dev-command.ts new file mode 100644 index 00000000..60158aae --- /dev/null +++ b/src/lib/dev-command.ts @@ -0,0 +1,150 @@ +import { readFileSync, existsSync } from 'node:fs'; +import { resolve, join } from 'node:path'; + +export interface DevCommandResult { + command: string; + args: string[]; + framework: string | null; +} + +interface PackageJson { + scripts?: Record; + dependencies?: Record; + devDependencies?: Record; +} + +/** + * Framework-to-dev-command mapping. Checked in order after package.json detection. + * Each entry maps a dependency name to a framework display name and default dev command. + */ +const FRAMEWORK_DEV_COMMANDS: Array<{ + dep: string; + framework: string; + command: string; + args: string[]; +}> = [ + { dep: 'next', framework: 'Next.js', command: 'next', args: ['dev'] }, + { dep: '@remix-run/dev', framework: 'Remix', command: 'remix', args: ['dev'] }, + { dep: 'react-router', framework: 'React Router', command: 'react-router', args: ['dev'] }, + { dep: '@tanstack/react-start', framework: 'TanStack Start', command: 'vinxi', args: ['dev'] }, + { dep: '@sveltejs/kit', framework: 'SvelteKit', command: 'vite', args: ['dev'] }, + { dep: 'vite', framework: 'Vite', command: 'vite', args: ['dev'] }, + { dep: 'nuxt', framework: 'Nuxt', command: 'nuxt', args: ['dev'] }, + { dep: 'express', framework: 'Express', command: 'node', args: ['index.js'] }, +]; + +/** + * Non-JS framework detection: checks for well-known files in the project directory. + */ +const NON_JS_FRAMEWORKS: Array<{ + file: string; + framework: string; + command: string; + args: string[]; +}> = [ + { file: 'manage.py', framework: 'Django', command: 'python', args: ['manage.py', 'runserver'] }, + { file: 'Gemfile', framework: 'Rails', command: 'rails', args: ['server'] }, + { file: 'go.mod', framework: 'Go', command: 'go', args: ['run', '.'] }, +]; + +function readPackageJson(projectDir: string): PackageJson | null { + const pkgPath = resolve(projectDir, 'package.json'); + if (!existsSync(pkgPath)) return null; + try { + return JSON.parse(readFileSync(pkgPath, 'utf-8')) as PackageJson; + } catch { + return null; + } +} + +function hasDependency(pkg: PackageJson, dep: string): boolean { + return !!(pkg.dependencies?.[dep] || pkg.devDependencies?.[dep]); +} + +/** + * Resolve the npx-style command for a given binary. + * Returns the binary path under node_modules/.bin if it exists, + * otherwise returns the bare command name (assumes it's globally available). + */ +function resolveNodeBin(projectDir: string, command: string): string { + const binPath = join(projectDir, 'node_modules', '.bin', command); + if (existsSync(binPath)) return binPath; + return command; +} + +/** + * Resolve the dev command for a project directory. + * + * Priority: + * 1. `scripts.dev` from package.json (developer's config is authoritative) + * 2. Framework-specific default based on dependency detection + * 3. Non-JS framework detection (Django, Rails, Go) + * 4. Error — no dev command could be resolved + */ +export async function resolveDevCommand(projectDir: string): Promise { + const pkg = readPackageJson(projectDir); + + if (pkg) { + // Detect framework from dependencies first (for display purposes) + let detectedFramework: string | null = null; + for (const entry of FRAMEWORK_DEV_COMMANDS) { + if (hasDependency(pkg, entry.dep)) { + detectedFramework = entry.framework; + break; + } + } + + // Priority 1: scripts.dev from package.json + if (pkg.scripts?.dev) { + // Use the package manager's run command to execute scripts.dev + const packageManager = detectPackageManager(projectDir); + return { + command: packageManager, + args: ['run', 'dev'], + framework: detectedFramework, + }; + } + + // Priority 2: Framework-specific default + for (const entry of FRAMEWORK_DEV_COMMANDS) { + if (hasDependency(pkg, entry.dep)) { + return { + command: resolveNodeBin(projectDir, entry.command), + args: entry.args, + framework: entry.framework, + }; + } + } + } + + // Priority 3: Non-JS frameworks + for (const entry of NON_JS_FRAMEWORKS) { + if (existsSync(resolve(projectDir, entry.file))) { + return { + command: entry.command, + args: entry.args, + framework: entry.framework, + }; + } + } + + // No framework or scripts.dev found + return { + command: 'npm', + args: ['run', 'dev'], + framework: null, + }; +} + +/** + * Detect the package manager used in the project. + */ +function detectPackageManager(projectDir: string): string { + if (existsSync(resolve(projectDir, 'pnpm-lock.yaml'))) return 'pnpm'; + if (existsSync(resolve(projectDir, 'yarn.lock'))) return 'yarn'; + if (existsSync(resolve(projectDir, 'bun.lockb')) || existsSync(resolve(projectDir, 'bun.lock'))) return 'bun'; + return 'npm'; +} + +// Export for testing +export { readPackageJson as _readPackageJson, detectPackageManager as _detectPackageManager }; diff --git a/src/utils/help-json.ts b/src/utils/help-json.ts index 8da463c7..9a6fb7ed 100644 --- a/src/utils/help-json.ts +++ b/src/utils/help-json.ts @@ -1048,6 +1048,29 @@ const commands: CommandSchema[] = [ }, ], }, + // --- Emulator --- + { + name: 'emulate', + description: 'Start a local WorkOS API emulator', + options: [ + { name: 'port', type: 'number', description: 'Port to listen on', required: false, default: 4100, hidden: false }, + { + name: 'seed', + type: 'string', + description: 'Path to seed config file (YAML or JSON)', + required: false, + hidden: false, + }, + ], + }, + { + name: 'dev', + description: 'Start emulator + your app in one command', + options: [ + { name: 'port', type: 'number', description: 'Emulator port', required: false, default: 4100, hidden: false }, + { name: 'seed', type: 'string', description: 'Path to seed config file', required: false, hidden: false }, + ], + }, // --- Workflow Commands --- { name: 'seed', diff --git a/vitest.config.ts b/vitest.config.ts index d43a60f3..a5a8a710 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -4,7 +4,7 @@ export default defineConfig({ test: { globals: true, environment: 'node', - include: ['src/**/*.spec.ts', 'tests/evals/**/*.spec.ts'], + include: ['src/**/*.spec.ts', 'tests/evals/**/*.spec.ts', 'scripts/**/*.spec.ts'], coverage: { provider: 'v8', reporter: ['text', 'json', 'html'],