diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml new file mode 100644 index 0000000..d66121c --- /dev/null +++ b/.github/workflows/lint.yml @@ -0,0 +1,26 @@ +name: Lint + +on: + push: + branches: [main] + pull_request: + +jobs: + lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + with: + node-version: 24 + cache: npm + + - name: Install + run: npm ci + + - name: Lint + run: npm run lint + + - name: Format check + run: npm run fmt:check diff --git a/.oxfmtrc.json b/.oxfmtrc.json new file mode 100644 index 0000000..775667a --- /dev/null +++ b/.oxfmtrc.json @@ -0,0 +1,6 @@ +{ + "singleQuote": true, + "printWidth": 120, + "sortPackageJson": false, + "ignorePatterns": ["package-lock.json", "CHANGELOG.md"] +} diff --git a/.oxlintrc.json b/.oxlintrc.json new file mode 100644 index 0000000..31e054f --- /dev/null +++ b/.oxlintrc.json @@ -0,0 +1,11 @@ +{ + "$schema": "./node_modules/oxlint/configuration_schema.json", + "plugins": ["typescript", "unicorn", "oxc"], + "categories": { + "correctness": "error" + }, + "rules": {}, + "env": { + "builtin": true + } +} diff --git a/.vscode/settings.json b/.vscode/settings.json index a0f8c2b..d56fb6f 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,8 +1,9 @@ { - "[json]": { - "editor.defaultFormatter": "vscode.json-language-features" - }, - "[markdown]": { - "editor.defaultFormatter": "esbenp.prettier-vscode" - } + "oxc.fmt.configPath": ".oxfmtrc.json", + "[json]": { + "editor.defaultFormatter": "oxc.oxc-vscode" + }, + "[markdown]": { + "editor.defaultFormatter": "oxc.oxc-vscode" + } } diff --git a/README.md b/README.md index a8b73c1..5ee7ba8 100644 --- a/README.md +++ b/README.md @@ -91,12 +91,12 @@ The same pattern works for any language with a WorkOS SDK (Ruby, Go, Java, etc.) ## Programmatic API (Node.js) ```ts -import { createEmulator } from "@workos/emulate"; +import { createEmulator } from '@workos/emulate'; const emulator = await createEmulator({ port: 0, seed: { - users: [{ email: "test@example.com", password: "secret" }], + users: [{ email: 'test@example.com', password: 'secret' }], }, }); @@ -137,6 +137,72 @@ permissions: name: Write Posts ``` +## Testing Your Login Flow End-to-End + +The emulator implements the full [workos.com/docs](https://workos.com/docs) login story: every resource creation and authentication outcome fires a signed webhook, with event names and payload shapes generated from the WorkOS OpenAPI spec. You can run your app's entire login flow — hosted authorize, callback, token exchange, webhook handling — against the emulator without touching the real API. + +### 1. Register a webhook endpoint + +Seed it (an empty `events` list subscribes to everything): + +```yaml +webhookEndpoints: + - endpoint_url: http://localhost:5005/webhooks + events: [] +``` + +Or register at runtime and choose your own signing secret: + +```bash +curl -X POST http://localhost:4100/webhook_endpoints \ + -H "Authorization: Bearer sk_test_default" \ + -H "Content-Type: application/json" \ + -d '{"endpoint_url":"http://localhost:5005/webhooks","secret":"whsec_test","events":[]}' +``` + +### 2. Walk the login flow + +Point your SDK's base URL at the emulator and follow the AuthKit quickstart exactly as documented: + +1. **Create a user** — `POST /user_management/users` → a `user.created` webhook arrives. +2. **Redirect to AuthKit** — send the browser to `GET /user_management/authorize?redirect_uri=...&state=...`. By default the emulator immediately redirects back to your callback with a `code`; with `--interactive` it serves a real login page first. +3. **Exchange the code** — your callback calls `POST /user_management/authenticate` with `grant_type=authorization_code`. You get back the user, `access_token`, and `refresh_token` — and `session.created` plus `authentication.oauth_succeeded` webhooks arrive. +4. **Other methods work the same way** — password, Magic Auth, email verification, MFA, and SSO logins all emit their spec-named `authentication.*_succeeded` events; failed attempts emit `authentication.*_failed` with an `error: { code, message }` object. + +Codes that WorkOS would deliver by email are delivered to you in the webhook payload instead: `magic_auth.created` carries the Magic Auth `code`, `password_reset.created` carries the reset `token`, and `email_verification.created` carries the verification `code`. Your test can drive the whole flow from webhooks alone — see `src/e2e.spec.ts` for a complete worked example. + +### 3. Verify signatures + +Webhooks are signed exactly like production WorkOS: `WorkOS-Signature: t=,v1=` where the HMAC-SHA256 is computed over `"{timestamp}.{body}"` with the endpoint's secret. The official SDKs' `webhooks.constructEvent` verifies them unchanged. + +### Emitted events + +Authentication events carry the spec payload `{ type, status, user_id, email, ip_address, user_agent }` (plus `error` on failures and `sso` details on SSO events). + +| Trigger | Events | +| -------------------------------------- | -------------------------------------------------------------------------------------------------------- | +| Login success (per method) | `authentication.{oauth,password,magic_auth,email_verification,mfa,sso}_succeeded` | +| Login failure (bad/expired credential) | `authentication.{oauth,password,magic_auth,email_verification,mfa,sso}_failed` | +| Sessions | `session.created`, `session.revoked` | +| Users | `user.created`, `user.updated`, `user.deleted` | +| Login-flow resources | `magic_auth.created`, `email_verification.created`, `password_reset.created`, `password_reset.succeeded` | +| Organizations & domains | `organization.*`, `organization_domain.*` (incl. `organization_domain.verified`) | +| Memberships & invitations | `organization_membership.*`, `invitation.{created,accepted,revoked,resent}` | +| Connections | `connection.activated`, `connection.deactivated`, `connection.deleted` | +| Directory Sync | `dsync.activated`, `dsync.deleted`, `dsync.user.*`, `dsync.group.*` | +| Roles & permissions | `role.*`, `organization_role.*`, `permission.*` | +| API keys & feature flags | `api_key.{created,updated,revoked}`, `flag.{created,updated,deleted}` | + +The full catalog (including names the emulator never emits, like `authentication.passkey_*` and `vault.*`) lives in `src/workos/generated/events.ts`, generated from the [`@workos/openapi-spec`](https://www.npmjs.com/package/@workos/openapi-spec) package. + +All events are also queryable at `GET /events` (filter with `?events[]=user.created`). + +### Caveats + +- Delivery is fire-and-forget with a 5-second timeout and no retries — poll your receiver in tests rather than asserting immediately. +- Resources defined in a seed file record events (visible at `GET /events`) but are not delivered to webhook endpoints from the same seed file — endpoints are registered last, mirroring real WorkOS, where pre-existing data never replays. Register endpoints via the API if you want deliveries for setup data. +- `dsync.group.user_added` / `dsync.group.user_removed` are catalogued but never emitted: the emulator has no directory group membership mutation surface. + ## Error Hooks Error hooks let you force the emulator to return non-200 responses so you can test how your app handles WorkOS API failures (422, 500, etc.). @@ -151,19 +217,19 @@ errorHooks: path: /user_management/users status: 422 body: - message: "Validation failed" - code: "unprocessable_entity" + message: 'Validation failed' + code: 'unprocessable_entity' errors: - field: email code: invalid - message: "must be a valid email" + message: 'must be a valid email' - method: GET path: /user_management/users status: 500 # Fail the first 3 requests, then let them through - - method: "*" + - method: '*' path: /organizations status: 503 count: 3 @@ -201,10 +267,10 @@ const emulator = await createEmulator({ port: 0 }); // Make user creation return a 422 const hook = emulator.addErrorHook({ - method: "POST", - path: "/user_management/users", + method: 'POST', + path: '/user_management/users', status: 422, - body: { message: "Email is invalid", code: "unprocessable_entity" }, + body: { message: 'Email is invalid', code: 'unprocessable_entity' }, }); // Your app code under test handles the error... @@ -233,11 +299,9 @@ workos-emulate --interactive --seed workos-emulate.config.yaml const emulator = await createEmulator({ interactiveAuth: true, seed: { - users: [{ email: "test@example.com", password: "secret" }], - connections: [ - { name: "Test SSO", organization: "Acme", domains: ["example.com"] }, - ], - organizations: [{ name: "Acme" }], + users: [{ email: 'test@example.com', password: 'secret' }], + connections: [{ name: 'Test SSO', organization: 'Acme', domains: ['example.com'] }], + organizations: [{ name: 'Acme' }], }, }); ``` @@ -261,12 +325,12 @@ The `login_hint` parameter pre-fills the email field, so agent browsers can skip ### E2E example with Playwright ```ts -test("SSO login flow", async ({ page }) => { - await page.goto("http://localhost:3000/login"); - await page.click("text=Sign in with SSO"); +test('SSO login flow', async ({ page }) => { + await page.goto('http://localhost:3000/login'); + await page.click('text=Sign in with SSO'); // Emulator serves the login page - await page.fill('input[name="email"]', "alice@example.com"); + await page.fill('input[name="email"]', 'alice@example.com'); await page.click('button[type="submit"]'); // Redirected back to your app with a valid session diff --git a/package-lock.json b/package-lock.json index f9d2cdf..2502990 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20,6 +20,9 @@ "devDependencies": { "@types/node": "~22.19.7", "@vitest/coverage-v8": "^4.0.18", + "@workos/openapi-spec": "^0.6.0", + "oxfmt": "^0.54.0", + "oxlint": "^1.69.0", "tsx": "^4.20.3", "typescript": "^5.9.3", "vitest": "^4.0.18" @@ -64,6 +67,16 @@ "node": ">=6.0.0" } }, + "node_modules/@babel/runtime": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", + "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/types": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", @@ -633,10 +646,27 @@ "url": "https://github.com/sponsors/Boshen" } }, - "node_modules/@rolldown/binding-android-arm64": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.2.tgz", - "integrity": "sha512-ZS4D1JPGn/MYQN/SYDWftIE/nVsM8j/AFOYEzAoOE2O3NktQOZru+/vYXGbR/qtdLdIfGCP0lcoJiYVzsEz+iQ==", + "node_modules/@oxfmt/binding-android-arm-eabi": { + "version": "0.54.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-android-arm-eabi/-/binding-android-arm-eabi-0.54.0.tgz", + "integrity": "sha512-NAtpl/SiaeU103e7/OmZw0MvUnsUUopW7hEm/ecegJg7YM0skQaA0IXEZoyTV6NUdiNPupdIUreRqUZTShbn/g==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-android-arm64": { + "version": "0.54.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-android-arm64/-/binding-android-arm64-0.54.0.tgz", + "integrity": "sha512-B4VZfBUlKK1rmMChsssNZbkZjE8+FzG3avMjGgMDwbGxXRoXkoeXiAZ+78Oa+eyDPHvDCiUb4zH/vmCOUSafLQ==", "cpu": [ "arm64" ], @@ -650,10 +680,10 @@ "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@rolldown/binding-darwin-arm64": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.2.tgz", - "integrity": "sha512-vdFA9+C/rekyGce7WqHs/xoT0ioZEWaOFyZLIV1mEeNFaFDUQrPIo8Vs2GvJ6eetb3rzDUtUBgzto3ExpXJB3w==", + "node_modules/@oxfmt/binding-darwin-arm64": { + "version": "0.54.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-darwin-arm64/-/binding-darwin-arm64-0.54.0.tgz", + "integrity": "sha512-i02vF75b+ePsQP3tHqSxVYI5S6b8X/xqdPu7/mDHXtpgXLTYXi3jJmfHU0j+dnZZDKaYTx/ioCK7QYJmtiJR2g==", "cpu": [ "arm64" ], @@ -667,10 +697,10 @@ "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@rolldown/binding-darwin-x64": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.2.tgz", - "integrity": "sha512-BewSOwTHazv77DTYiAZXSqqKZ4KP/KonFisDMVU7PImxoWfB2aepnPhd2E4SWz3zDzYgDNbs6jBmTdgNnF02GA==", + "node_modules/@oxfmt/binding-darwin-x64": { + "version": "0.54.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-darwin-x64/-/binding-darwin-x64-0.54.0.tgz", + "integrity": "sha512-8VMFvGvooXj7mswkbrhdVZ2/sgiDaBzWpkkbtO+qGDLV4EfJd67nQadHkQC0ZNbaWA9ajXfqI6i7PZLIeDzxEQ==", "cpu": [ "x64" ], @@ -684,10 +714,10 @@ "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@rolldown/binding-freebsd-x64": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.2.tgz", - "integrity": "sha512-m41o7M0YWtUdqk61Tb+jnKb2rN++iRdIASlExkUoKfIAH30DOHCB8fVLzSUpbWHHU8esmEioY62PxzexE8MBuA==", + "node_modules/@oxfmt/binding-freebsd-x64": { + "version": "0.54.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-freebsd-x64/-/binding-freebsd-x64-0.54.0.tgz", + "integrity": "sha512-0cRHnp43WN1Jrc5s0BdbdKgR1XirdvHy7TAFi3JEsoEVQVJxTXMbpVd76sxXlgRswNMDhVFSJw+y7Eb8mEavFQ==", "cpu": [ "x64" ], @@ -701,10 +731,10 @@ "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@rolldown/binding-linux-arm-gnueabihf": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.2.tgz", - "integrity": "sha512-jcojB9H7W/jS29pMKWAK1N+fU99vXodHDTatS3b3y/XSOCiHo0kkA74pL3jJmkoQtYpOCxDvaKs1fo2Ij/1X5w==", + "node_modules/@oxfmt/binding-linux-arm-gnueabihf": { + "version": "0.54.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-0.54.0.tgz", + "integrity": "sha512-JyQAk3hK/OEtup7Rw6kZwfdzbKqTVD5jXXb8Xpfay29suwZyfBDMVW/bj4RqEPySYWc6zCp198pOluf8n5uYzg==", "cpu": [ "arm" ], @@ -718,10 +748,27 @@ "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@rolldown/binding-linux-arm64-gnu": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.2.tgz", - "integrity": "sha512-1jn6qDU5iiOgFgygDzKUuKP0maTi0/f1+sBLgvij/76C77Nm3ts6ufz9Bjg5q5dduxiUIxtq86JIoBvo1xQ4Ig==", + "node_modules/@oxfmt/binding-linux-arm-musleabihf": { + "version": "0.54.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-0.54.0.tgz", + "integrity": "sha512-qnvLatTpM8vtvjOfcckBOzJjk+n6ce/wwpP8OFeUrD5aNLYcKyWAitwj+Rk3PK9jGanbZvKsJnv14JGQ6XqFdw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-linux-arm64-gnu": { + "version": "0.54.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-0.54.0.tgz", + "integrity": "sha512-SMkhnCzIYZYDk9vw3W/80eeYKmrMpGF0Giuxt4HruFlCH7jEtnPeb3SdQKMfgYi/dgtaf+hZAb5XWPYnxqCQ3w==", "cpu": [ "arm64" ], @@ -738,10 +785,10 @@ "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@rolldown/binding-linux-arm64-musl": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.2.tgz", - "integrity": "sha512-QVLO/czFMdoMFSqlX3bcswcJNm/23r+qoa/jgtmFc/qEp6/jXmIkDjF/XIo8dPfGaiwy1xfQn8o77L79GeXFgw==", + "node_modules/@oxfmt/binding-linux-arm64-musl": { + "version": "0.54.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-arm64-musl/-/binding-linux-arm64-musl-0.54.0.tgz", + "integrity": "sha512-QrwJlBFFKnxOd95TAaszpMbZBLzMoYMpGaQTZF8oibacnF5rv8l12IhILhQRPmksWiBqg0YSe2Mnl7ayeJAHSA==", "cpu": [ "arm64" ], @@ -758,10 +805,10 @@ "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@rolldown/binding-linux-ppc64-gnu": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.2.tgz", - "integrity": "sha512-hgO5Abm0w5UL6FEa2iFnZqo2KlK7TQ5QhV5x09hujBf7t5KzHQ1VmfPuTpqRy/rNlSxua3eWH374xxiVrP+lcA==", + "node_modules/@oxfmt/binding-linux-ppc64-gnu": { + "version": "0.54.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-0.54.0.tgz", + "integrity": "sha512-WILatiol/TUHTlhod7R09+7Az/XlhKwmY1MHfLZNmewltPWNN/EwxP2rQSHahibZ/cB8gmckEBjBOByD+5bYsQ==", "cpu": [ "ppc64" ], @@ -778,10 +825,50 @@ "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@rolldown/binding-linux-s390x-gnu": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.2.tgz", - "integrity": "sha512-fy8rXxuYEu602abC8MUNaPjYLIFzReOaEIEMKMUa0rFEUxNpVXhs15KSSQ4qlqSaM7B6rcj9rDZgADh/IGDzLQ==", + "node_modules/@oxfmt/binding-linux-riscv64-gnu": { + "version": "0.54.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-0.54.0.tgz", + "integrity": "sha512-f05YMG4BH4G8S4ME6UM6fi1MnJ9094mrnvO5Pa4SJlMfWlUM+1/ZWMEF4NnjM7shZAvbHsHRuVYpUo0PHC4P9Q==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-linux-riscv64-musl": { + "version": "0.54.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-0.54.0.tgz", + "integrity": "sha512-UfL+2hj1ClNqcCRT9s8vBU4axDpjxgVxX96G+9DYAYjoc5b0u15CJtn2jgsi9iM+EbGNc5CW1HVRgwVu76UsSA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-linux-s390x-gnu": { + "version": "0.54.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-0.54.0.tgz", + "integrity": "sha512-3/XZe931Hka+J6NjnaqJzYpsWWxDTuRdUdwSQHnOuJEgbC+SehIMFJS8hsEjV7LBhVSL2OCnRLvbVW8O97XIyw==", "cpu": [ "s390x" ], @@ -798,10 +885,10 @@ "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@rolldown/binding-linux-x64-gnu": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.2.tgz", - "integrity": "sha512-0+bOkiQ779+r1WpoHOWHqncvyySci0vKph+myNDYb+im6meJAzHQXay6oEgnkHuUGouM1LKTZwqKpBow6Kj7CQ==", + "node_modules/@oxfmt/binding-linux-x64-gnu": { + "version": "0.54.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-x64-gnu/-/binding-linux-x64-gnu-0.54.0.tgz", + "integrity": "sha512-Ik93RlObtu43GbxApafayFjwYE06L6Xr08cSwpBPYbDrLp2ReZx0Jm1DqwRyYRnukUJy+rK2WaEvUQOxdytU9Q==", "cpu": [ "x64" ], @@ -818,10 +905,10 @@ "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@rolldown/binding-linux-x64-musl": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.2.tgz", - "integrity": "sha512-mjSkrzZK5Qsl0a9d1JgILOiuZOSDTVdKENcSXBoqbzSrspLR/4/IRVDo5wd2GgZjNss/viBFJdeq+j7qH2nypw==", + "node_modules/@oxfmt/binding-linux-x64-musl": { + "version": "0.54.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-x64-musl/-/binding-linux-x64-musl-0.54.0.tgz", + "integrity": "sha512-yZcakmPlD86CNymknd7KfW+FH+qfbqJH+i0h69CYfV1+KMoVeM9UED+8+TDVoU4haxI0NxY7RPCvRLy3Sqd2Qg==", "cpu": [ "x64" ], @@ -838,10 +925,10 @@ "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@rolldown/binding-openharmony-arm64": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.2.tgz", - "integrity": "sha512-1v5vHasdfQAZoEHakBV72LIFAC9JjnymsiKxp+GEr/ma3+NJCPSaYK+qavInOovJkgwFrs7GccX2d6IgDA3Z5w==", + "node_modules/@oxfmt/binding-openharmony-arm64": { + "version": "0.54.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-openharmony-arm64/-/binding-openharmony-arm64-0.54.0.tgz", + "integrity": "sha512-GiVBZNnEZnKu00f1jTg49nomv187d0GQX+O+ocykoLeiaALuEO+swoTehHn9TehTfi7V8H0i0e/yvUjCqnwk1w==", "cpu": [ "arm64" ], @@ -855,31 +942,29 @@ "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@rolldown/binding-wasm32-wasi": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.2.tgz", - "integrity": "sha512-mb1VobWn6NheziTk5/WEaR6AKVbrwT5sOi6C7zk3gy/pD1qtJfU1j4PgTo2NJnOtbL9Dl3Aeei8w9jJ7qC2jZQ==", + "node_modules/@oxfmt/binding-win32-arm64-msvc": { + "version": "0.54.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-0.54.0.tgz", + "integrity": "sha512-J0SSB8Z1Fre2sxRolYcW6Rl1RQmKdQ2hnHyq4YJrfBRiXTObLw4DXnIVraM/UyqGqwOi7yTrQA4VT7DPxlHVKA==", "cpu": [ - "wasm32" + "arm64" ], "dev": true, "license": "MIT", "optional": true, - "dependencies": { - "@emnapi/core": "1.10.0", - "@emnapi/runtime": "1.10.0", - "@napi-rs/wasm-runtime": "^1.1.4" - }, + "os": [ + "win32" + ], "engines": { "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@rolldown/binding-win32-arm64-msvc": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.2.tgz", - "integrity": "sha512-SqKonF56vA/L2yHwHYcEp2P34URpOZ7d1fS635cTkpDnUtEGdUbhI6NzsPdqeSWvAAeGDrxjWjNmibDIdFf9/A==", + "node_modules/@oxfmt/binding-win32-ia32-msvc": { + "version": "0.54.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-0.54.0.tgz", + "integrity": "sha512-O61UDVj8zz6yXJjkHPf05VaMLOXmEF8P5kf/N0W7AQMmd6bcQogl+KJc7rMutKTL524oE9iH32JXZClBFmEQIg==", "cpu": [ - "arm64" + "ia32" ], "dev": true, "license": "MIT", @@ -891,10 +976,10 @@ "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@rolldown/binding-win32-x64-msvc": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.2.tgz", - "integrity": "sha512-v7qRI7gXLRINcOGXt+7YmAZ6iFuyZVMIoXAxhd8oP+DR9dLfL9GfNIx7PLMxmhZdvq8waUJBQiWN9EKNy+TRBQ==", + "node_modules/@oxfmt/binding-win32-x64-msvc": { + "version": "0.54.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-win32-x64-msvc/-/binding-win32-x64-msvc-0.54.0.tgz", + "integrity": "sha512-1MDpqJPiFqxWtIHas8vkb1VZ7f7eKyTffAwmO8isxQYMaG1OFKsH666BWLeXQLO+IWNfiMssLD55hbR1lIPTqg==", "cpu": [ "x64" ], @@ -908,70 +993,749 @@ "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@rolldown/pluginutils": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", - "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "node_modules/@oxlint/binding-android-arm-eabi": { + "version": "1.69.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm-eabi/-/binding-android-arm-eabi-1.69.0.tgz", + "integrity": "sha512-DKQQbD5cZ/MYfDgDI7YGyGD9FSxABlsBsYFo5p26lloob543tP9+4N3guwdXIYJN+7HSZxLe8YJuwcOWw5qnHg==", + "cpu": [ + "arm" + ], "dev": true, - "license": "MIT" + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@standard-schema/spec": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", - "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "node_modules/@oxlint/binding-android-arm64": { + "version": "1.69.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm64/-/binding-android-arm64-1.69.0.tgz", + "integrity": "sha512-lEhb+I5pr4inux+JFwfCa1HRq3Os7NirEFQ0H1I35SVEHPm6byX0Ah47xmRha3qi6LAkxUcxViL8o/9PivjzBg==", + "cpu": [ + "arm64" + ], "dev": true, - "license": "MIT" + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@tybys/wasm-util": { - "version": "0.10.2", - "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz", - "integrity": "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==", + "node_modules/@oxlint/binding-darwin-arm64": { + "version": "1.69.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-arm64/-/binding-darwin-arm64-1.69.0.tgz", + "integrity": "sha512-GY2YE8lOZW59BW1Ia1y+1gR0XyjrZRvVWHAr8LGeGhYHE0OQJ/7cRKXTkx1P+E9/6awEc3SX8a68SFTjh/E//A==", + "cpu": [ + "arm64" + ], "dev": true, "license": "MIT", "optional": true, - "dependencies": { - "tslib": "^2.4.0" + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@types/chai": { - "version": "5.2.3", - "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", - "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "node_modules/@oxlint/binding-darwin-x64": { + "version": "1.69.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-x64/-/binding-darwin-x64-1.69.0.tgz", + "integrity": "sha512-ax1oZnOjHX3LB7myQyHEaQkDwfLb6str3/nSP6O7EVUviQGNkEGzGV0EqcBJWK+Ufwx0l4xPgyYayurvhAdl2Q==", + "cpu": [ + "x64" + ], "dev": true, "license": "MIT", - "dependencies": { - "@types/deep-eql": "*", - "assertion-error": "^2.0.1" + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@types/deep-eql": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", - "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "node_modules/@oxlint/binding-freebsd-x64": { + "version": "1.69.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-freebsd-x64/-/binding-freebsd-x64-1.69.0.tgz", + "integrity": "sha512-kHWeHv4g2h8NY+mpCxzCtY4uerMJWTN/TSnNj1CPbakFpHEJ6cTya2wWV0pDSYWOJ2+0UiEbhn3AtXxHtsnKjg==", + "cpu": [ + "x64" + ], "dev": true, - "license": "MIT" + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@types/estree": { - "version": "1.0.9", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", - "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "node_modules/@oxlint/binding-linux-arm-gnueabihf": { + "version": "1.69.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.69.0.tgz", + "integrity": "sha512-gq84vM1a1oEehXo27YCDzGVcxPsZDI1yswZwz2Da1/cbnWtrL16XZZnz0G/+gIU8edtHpfjxq5c+vWEHqJfWoQ==", + "cpu": [ + "arm" + ], "dev": true, - "license": "MIT" + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@types/node": { - "version": "22.19.19", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.19.tgz", - "integrity": "sha512-dyh/xO2Fh5bYrfWaaqGrRQQGkNdmYw6AmaAUvYeUMNTWQtvb796ikLdmTchRmOlOiIJ1TDXfWgVx1QkUlQ6Hew==", + "node_modules/@oxlint/binding-linux-arm-musleabihf": { + "version": "1.69.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-1.69.0.tgz", + "integrity": "sha512-kIqEa98JQ0VRyrcncxA417m2AzasqTlD+FyVT1AksjvjkqQcvm7pBWYvoW3/mpyOP2XYvi5nSCCTIe6De1yu5g==", + "cpu": [ + "arm" + ], "dev": true, "license": "MIT", - "dependencies": { - "undici-types": "~6.21.0" + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@vitest/coverage-v8": { - "version": "4.1.7", - "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.7.tgz", - "integrity": "sha512-qsYPeXc5Q9dFLd1i8Ap+Bx8sQgcp+rFVQo4R0dDsWNBzl26ldVF1qOO+RL24K7FDrR6pA+50XedRLSoSG24bVQ==", + "node_modules/@oxlint/binding-linux-arm64-gnu": { + "version": "1.69.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.69.0.tgz", + "integrity": "sha512-j+xYiXozxGWx2cpjCrwwGR4awTxPFsRv3JZrv23RCogEPMc4R7UqjHW47p/RG0aRlbWiROCJ8coUfCwy0dvzHA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-arm64-musl": { + "version": "1.69.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.69.0.tgz", + "integrity": "sha512-xEPpNppTfN1l/nM7gYSf9iocscu/as+p/7vxkLeLEKnYU+09Dm+5V6IhDYDh+Uz6FajEupWwCLt5SOG0y1PCKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-ppc64-gnu": { + "version": "1.69.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.69.0.tgz", + "integrity": "sha512-Ug0+eU7HJBlek+SjklYH62IlOMirEJsdxpihH0kSqX0XdrDD4NdHpQc10fK1JC35yn6KrrcN+uYzlHD38XAf8Q==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-riscv64-gnu": { + "version": "1.69.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-1.69.0.tgz", + "integrity": "sha512-iEyI3GIg0l/s3G4qy2TlaaWKdzj4PJJStwtlocpDTC00PY9hZueotf6OKUj9+yfQh0lrpBW/pLMgTztbAHKJEg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-riscv64-musl": { + "version": "1.69.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-1.69.0.tgz", + "integrity": "sha512-NjHjpiI4WIKSMwuoJSZi5VToPeoYOS1FR52HLIDG6lidMdqquusgtODb4iLk0+lb1q3Z0nv2/aPRcC/olmpQGg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-s390x-gnu": { + "version": "1.69.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.69.0.tgz", + "integrity": "sha512-Ai/prDewoItkDXbp38gwGZi41DycZbUTZJ3UidwoHgQC0/DaqC2TGdtBTQLJ6hSD+SAxASzh8+/eSBPmxfOacA==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-x64-gnu": { + "version": "1.69.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.69.0.tgz", + "integrity": "sha512-Gt3KHgp46mRKz4sJeaASmKvD8ayXookRw07RMf+NowhEztGGDZ7VrXpoW96XuKJLjFukWizOFVNjmYb/u7caNQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-x64-musl": { + "version": "1.69.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-musl/-/binding-linux-x64-musl-1.69.0.tgz", + "integrity": "sha512-7tQhJ2+p/oHv1zcfnjYI7YVzC/7iBaVOfIvFYtxdJ5F45mWgEdrCyXZXZGfiLey5t/5JhOhsaMnnv1kAzckd7g==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-openharmony-arm64": { + "version": "1.69.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-openharmony-arm64/-/binding-openharmony-arm64-1.69.0.tgz", + "integrity": "sha512-vmWz6TKp/3hfA4lksR0zHBv/6xuX1jhym6eqOjdH2DXsDDHZWcp2f0KG0VCAnlVbIrjk29G4wAWMXb/Hn1YobA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-win32-arm64-msvc": { + "version": "1.69.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.69.0.tgz", + "integrity": "sha512-9RExaLgmaw6IoIkU9cTpT71mLfI0xZ86iZH8x518LVsOkjquJMYqb9P7KpC8lgd1t0Dxs41p2pxynq4XR3Ttzw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-win32-ia32-msvc": { + "version": "1.69.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-1.69.0.tgz", + "integrity": "sha512-1907kRPF8/PrcIw1E7LMs9JbVrpgnt/MvFdss3an8oDkYNAACXzTntV3t3869ZZhMZxb2AzRGbz1pA/jdFatXA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-win32-x64-msvc": { + "version": "1.69.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.69.0.tgz", + "integrity": "sha512-w8SOXv3mT9Fi6jY8OXdXCfnvX/3KNLXGNr4HEz2TA7S4Mv/PYAOmpB8y/ge40mxvBMgGNaSaaDwZpAsQn7HtWA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@redocly/ajv": { + "version": "8.18.3", + "resolved": "https://registry.npmjs.org/@redocly/ajv/-/ajv-8.18.3.tgz", + "integrity": "sha512-l42u0of3hY98sN2A+M4qTX1O/KrpgGH32Hu9kP2GtHyD5Dfqq86PKFLe5dwaD8DEnNmlOlll2BAmeEtf0DaySg==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/@redocly/config": { + "version": "0.49.0", + "resolved": "https://registry.npmjs.org/@redocly/config/-/config-0.49.0.tgz", + "integrity": "sha512-OI/rpEffX3fKUuy+OuBHPRspRI/S30b9aiqxfZLMpSWZzDncEGPxSEP1O2LrBVshnDX4hLjVjLvCZ4YT85+1rw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-schema-to-ts": "2.7.2" + } + }, + "node_modules/@redocly/openapi-core": { + "version": "2.32.0", + "resolved": "https://registry.npmjs.org/@redocly/openapi-core/-/openapi-core-2.32.0.tgz", + "integrity": "sha512-B4CsuuokMc3vgNh9e+eFX8FAbjMTWUVgYtBvPXm0X2pwBs9nO2v+m6ARp7lEpg1P42B6XztIdI08BGMqUAerJQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@redocly/ajv": "^8.18.1", + "@redocly/config": "^0.49.0", + "ajv": "npm:@redocly/ajv@8.18.1", + "ajv-formats": "^3.0.1", + "colorette": "^1.2.0", + "js-levenshtein": "^1.1.6", + "js-yaml": "^4.1.0", + "picomatch": "^4.0.4", + "pluralize": "^8.0.0", + "yaml-ast-parser": "0.0.43" + }, + "engines": { + "node": ">=22.12.0 || >=20.19.0 <21.0.0", + "npm": ">=10" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.2.tgz", + "integrity": "sha512-ZS4D1JPGn/MYQN/SYDWftIE/nVsM8j/AFOYEzAoOE2O3NktQOZru+/vYXGbR/qtdLdIfGCP0lcoJiYVzsEz+iQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.2.tgz", + "integrity": "sha512-vdFA9+C/rekyGce7WqHs/xoT0ioZEWaOFyZLIV1mEeNFaFDUQrPIo8Vs2GvJ6eetb3rzDUtUBgzto3ExpXJB3w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.2.tgz", + "integrity": "sha512-BewSOwTHazv77DTYiAZXSqqKZ4KP/KonFisDMVU7PImxoWfB2aepnPhd2E4SWz3zDzYgDNbs6jBmTdgNnF02GA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.2.tgz", + "integrity": "sha512-m41o7M0YWtUdqk61Tb+jnKb2rN++iRdIASlExkUoKfIAH30DOHCB8fVLzSUpbWHHU8esmEioY62PxzexE8MBuA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.2.tgz", + "integrity": "sha512-jcojB9H7W/jS29pMKWAK1N+fU99vXodHDTatS3b3y/XSOCiHo0kkA74pL3jJmkoQtYpOCxDvaKs1fo2Ij/1X5w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.2.tgz", + "integrity": "sha512-1jn6qDU5iiOgFgygDzKUuKP0maTi0/f1+sBLgvij/76C77Nm3ts6ufz9Bjg5q5dduxiUIxtq86JIoBvo1xQ4Ig==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.2.tgz", + "integrity": "sha512-QVLO/czFMdoMFSqlX3bcswcJNm/23r+qoa/jgtmFc/qEp6/jXmIkDjF/XIo8dPfGaiwy1xfQn8o77L79GeXFgw==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.2.tgz", + "integrity": "sha512-hgO5Abm0w5UL6FEa2iFnZqo2KlK7TQ5QhV5x09hujBf7t5KzHQ1VmfPuTpqRy/rNlSxua3eWH374xxiVrP+lcA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.2.tgz", + "integrity": "sha512-fy8rXxuYEu602abC8MUNaPjYLIFzReOaEIEMKMUa0rFEUxNpVXhs15KSSQ4qlqSaM7B6rcj9rDZgADh/IGDzLQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.2.tgz", + "integrity": "sha512-0+bOkiQ779+r1WpoHOWHqncvyySci0vKph+myNDYb+im6meJAzHQXay6oEgnkHuUGouM1LKTZwqKpBow6Kj7CQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.2.tgz", + "integrity": "sha512-mjSkrzZK5Qsl0a9d1JgILOiuZOSDTVdKENcSXBoqbzSrspLR/4/IRVDo5wd2GgZjNss/viBFJdeq+j7qH2nypw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.2.tgz", + "integrity": "sha512-1v5vHasdfQAZoEHakBV72LIFAC9JjnymsiKxp+GEr/ma3+NJCPSaYK+qavInOovJkgwFrs7GccX2d6IgDA3Z5w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.2.tgz", + "integrity": "sha512-mb1VobWn6NheziTk5/WEaR6AKVbrwT5sOi6C7zk3gy/pD1qtJfU1j4PgTo2NJnOtbL9Dl3Aeei8w9jJ7qC2jZQ==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.10.0", + "@emnapi/runtime": "1.10.0", + "@napi-rs/wasm-runtime": "^1.1.4" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.2.tgz", + "integrity": "sha512-SqKonF56vA/L2yHwHYcEp2P34URpOZ7d1fS635cTkpDnUtEGdUbhI6NzsPdqeSWvAAeGDrxjWjNmibDIdFf9/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.2.tgz", + "integrity": "sha512-v7qRI7gXLRINcOGXt+7YmAZ6iFuyZVMIoXAxhd8oP+DR9dLfL9GfNIx7PLMxmhZdvq8waUJBQiWN9EKNy+TRBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.2", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz", + "integrity": "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "22.19.19", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.19.tgz", + "integrity": "sha512-dyh/xO2Fh5bYrfWaaqGrRQQGkNdmYw6AmaAUvYeUMNTWQtvb796ikLdmTchRmOlOiIJ1TDXfWgVx1QkUlQ6Hew==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@vitest/coverage-v8": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.7.tgz", + "integrity": "sha512-qsYPeXc5Q9dFLd1i8Ap+Bx8sQgcp+rFVQo4R0dDsWNBzl26ldVF1qOO+RL24K7FDrR6pA+50XedRLSoSG24bVQ==", "dev": true, "license": "MIT", "dependencies": { @@ -1112,6 +1876,103 @@ "url": "https://opencollective.com/vitest" } }, + "node_modules/@workos/oagen": { + "version": "0.22.4", + "resolved": "https://registry.npmjs.org/@workos/oagen/-/oagen-0.22.4.tgz", + "integrity": "sha512-tjJvQAkQj2Yuk16tqg/YWlZgjfKsK86nz9ps07SwNRDJFq0zVhfrMZKG2UEkoL8ltP6OkGWTUyF/RfcIiBPOag==", + "dev": true, + "license": "MIT", + "dependencies": { + "@redocly/openapi-core": "^2.25.1", + "commander": "^13.1.0", + "dotenv": "^17.3.1", + "tree-sitter": "^0.21.1", + "tree-sitter-c-sharp": "0.23.1", + "tree-sitter-elixir": "^0.3.5", + "tree-sitter-go": "^0.23.4", + "tree-sitter-kotlin": "github:fwcd/tree-sitter-kotlin#f66d2908542e93c0204c6c241f794afe4e9cd5d1", + "tree-sitter-php": "^0.23.12", + "tree-sitter-python": "^0.21.0", + "tree-sitter-ruby": "^0.21.0", + "tree-sitter-rust": "^0.21.0", + "tree-sitter-typescript": "^0.23.2", + "tsx": "^4.19.0", + "typescript": "^6.0.0" + }, + "bin": { + "oagen": "dist/cli/index.mjs" + }, + "engines": { + "node": ">=24.10.0" + } + }, + "node_modules/@workos/oagen/node_modules/typescript": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", + "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/@workos/openapi-spec": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/@workos/openapi-spec/-/openapi-spec-0.6.0.tgz", + "integrity": "sha512-+nNvkvNsjYYX1r9HRB0ZJgdCYMJCLFXhitiui9vzQrhFwsWWqwl3fJuXss2MOPp6hB8S74pLMqU2hVIYjfoXLw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@workos/oagen": "^0.22.4" + } + }, + "node_modules/ajv": { + "name": "@redocly/ajv", + "version": "8.18.1", + "resolved": "https://registry.npmjs.org/@redocly/ajv/-/ajv-8.18.1.tgz", + "integrity": "sha512-Ifm/pP/tul1qmAecpbVxCBluVE32rKfjf8gYXH4xI2gCv9mRWFhJMHzkPDM4TXlxwPQYIFegymlsy8lXz7optA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, "node_modules/assertion-error": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", @@ -1156,6 +2017,23 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, + "node_modules/colorette": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/colorette/-/colorette-1.4.0.tgz", + "integrity": "sha512-Y2oEozpomLn7Q3HFP7dpww7AtMJplbM9lGZP6RDfHqmbeRjiwRg4n6VM6j4KLmRke85uWEI7JqF17f3pqdRA0g==", + "dev": true, + "license": "MIT" + }, + "node_modules/commander": { + "version": "13.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-13.1.0.tgz", + "integrity": "sha512-/rFeCpNJQbhSZjGVwO9RFV3xPqbnERS8MmIQzCtD/zl6gpJuV/bMLuN92oG3F7d8oDEHHRrujSXNUr8fpjntKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/convert-source-map": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", @@ -1173,6 +2051,19 @@ "node": ">=8" } }, + "node_modules/dotenv": { + "version": "17.4.2", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.2.tgz", + "integrity": "sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, "node_modules/es-module-lexer": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.1.0.tgz", @@ -1242,6 +2133,30 @@ "node": ">=12.0.0" } }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz", + "integrity": "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, "node_modules/fdir": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", @@ -1340,6 +2255,16 @@ "node": ">=8" } }, + "node_modules/js-levenshtein": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/js-levenshtein/-/js-levenshtein-1.1.6.tgz", + "integrity": "sha512-X2BB11YZtrRqY4EnQcLX5Rh373zbK4alC1FW7D7MBhL2gtcC17cTnr6DmfHZeS0s2rTHjUTMMHfG7gO8SSdw+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/js-tokens": { "version": "10.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-10.0.0.tgz", @@ -1347,6 +2272,51 @@ "dev": true, "license": "MIT" }, + "node_modules/js-yaml": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.2.0.tgz", + "integrity": "sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/json-schema-to-ts": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/json-schema-to-ts/-/json-schema-to-ts-2.7.2.tgz", + "integrity": "sha512-R1JfqKqbBR4qE8UyBR56Ms30LL62/nlhoz+1UkfI/VE7p54Awu919FZ6ZUPG8zIa3XB65usPJgr1ONVncUGSaQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.18.3", + "@types/json-schema": "^7.0.9", + "ts-algebra": "^1.2.0" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "dev": true, + "license": "MIT" + }, "node_modules/lightningcss": { "version": "1.32.0", "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", @@ -1677,6 +2647,28 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, + "node_modules/node-addon-api": { + "version": "8.8.0", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.8.0.tgz", + "integrity": "sha512-c5Ko1fZJIJmzhFIkhRN76WTq+fC6tWnGy9CXA0fA+XygsWZmEwG8vmbkNqxMyoaa0Tin4djul49NzdVcJJcjeA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18 || ^20 || >= 21" + } + }, + "node_modules/node-gyp-build": { + "version": "4.8.4", + "resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz", + "integrity": "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==", + "dev": true, + "license": "MIT", + "bin": { + "node-gyp-build": "bin.js", + "node-gyp-build-optional": "optional.js", + "node-gyp-build-test": "build-test.js" + } + }, "node_modules/obug": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", @@ -1688,6 +2680,107 @@ ], "license": "MIT" }, + "node_modules/oxfmt": { + "version": "0.54.0", + "resolved": "https://registry.npmjs.org/oxfmt/-/oxfmt-0.54.0.tgz", + "integrity": "sha512-DjnMwn7smSLF+Mc2+pRItnuPftm/dkUFpY/d4+33y9TfKrsHZo8GLhmUg9BrOIUEy94Rlom1Q11N6vuhE+e0oQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinypool": "2.1.0" + }, + "bin": { + "oxfmt": "bin/oxfmt" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/sponsors/Boshen" + }, + "optionalDependencies": { + "@oxfmt/binding-android-arm-eabi": "0.54.0", + "@oxfmt/binding-android-arm64": "0.54.0", + "@oxfmt/binding-darwin-arm64": "0.54.0", + "@oxfmt/binding-darwin-x64": "0.54.0", + "@oxfmt/binding-freebsd-x64": "0.54.0", + "@oxfmt/binding-linux-arm-gnueabihf": "0.54.0", + "@oxfmt/binding-linux-arm-musleabihf": "0.54.0", + "@oxfmt/binding-linux-arm64-gnu": "0.54.0", + "@oxfmt/binding-linux-arm64-musl": "0.54.0", + "@oxfmt/binding-linux-ppc64-gnu": "0.54.0", + "@oxfmt/binding-linux-riscv64-gnu": "0.54.0", + "@oxfmt/binding-linux-riscv64-musl": "0.54.0", + "@oxfmt/binding-linux-s390x-gnu": "0.54.0", + "@oxfmt/binding-linux-x64-gnu": "0.54.0", + "@oxfmt/binding-linux-x64-musl": "0.54.0", + "@oxfmt/binding-openharmony-arm64": "0.54.0", + "@oxfmt/binding-win32-arm64-msvc": "0.54.0", + "@oxfmt/binding-win32-ia32-msvc": "0.54.0", + "@oxfmt/binding-win32-x64-msvc": "0.54.0" + }, + "peerDependencies": { + "svelte": "^5.0.0", + "vite-plus": "*" + }, + "peerDependenciesMeta": { + "svelte": { + "optional": true + }, + "vite-plus": { + "optional": true + } + } + }, + "node_modules/oxlint": { + "version": "1.69.0", + "resolved": "https://registry.npmjs.org/oxlint/-/oxlint-1.69.0.tgz", + "integrity": "sha512-ypZkK/aDc5NQV8zIR6s2H2Tl3aNW8FmJ1m9+2qsaYuRenl8vgnHNCGwTHviWJdUQzglOlHFchgopdtGhSy17Rw==", + "dev": true, + "license": "MIT", + "bin": { + "oxlint": "bin/oxlint" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/sponsors/Boshen" + }, + "optionalDependencies": { + "@oxlint/binding-android-arm-eabi": "1.69.0", + "@oxlint/binding-android-arm64": "1.69.0", + "@oxlint/binding-darwin-arm64": "1.69.0", + "@oxlint/binding-darwin-x64": "1.69.0", + "@oxlint/binding-freebsd-x64": "1.69.0", + "@oxlint/binding-linux-arm-gnueabihf": "1.69.0", + "@oxlint/binding-linux-arm-musleabihf": "1.69.0", + "@oxlint/binding-linux-arm64-gnu": "1.69.0", + "@oxlint/binding-linux-arm64-musl": "1.69.0", + "@oxlint/binding-linux-ppc64-gnu": "1.69.0", + "@oxlint/binding-linux-riscv64-gnu": "1.69.0", + "@oxlint/binding-linux-riscv64-musl": "1.69.0", + "@oxlint/binding-linux-s390x-gnu": "1.69.0", + "@oxlint/binding-linux-x64-gnu": "1.69.0", + "@oxlint/binding-linux-x64-musl": "1.69.0", + "@oxlint/binding-openharmony-arm64": "1.69.0", + "@oxlint/binding-win32-arm64-msvc": "1.69.0", + "@oxlint/binding-win32-ia32-msvc": "1.69.0", + "@oxlint/binding-win32-x64-msvc": "1.69.0" + }, + "peerDependencies": { + "oxlint-tsgolint": ">=0.22.1", + "vite-plus": "*" + }, + "peerDependenciesMeta": { + "oxlint-tsgolint": { + "optional": true + }, + "vite-plus": { + "optional": true + } + } + }, "node_modules/pathe": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", @@ -1715,6 +2808,16 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/pluralize": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/pluralize/-/pluralize-8.0.0.tgz", + "integrity": "sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/postcss": { "version": "8.5.15", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", @@ -1744,6 +2847,16 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/rolldown": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.2.tgz", @@ -1869,6 +2982,16 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/tinypool": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-2.1.0.tgz", + "integrity": "sha512-Pugqs6M0m7Lv1I7FtxN4aoyToKg1C4tu+/381vH35y8oENM/Ai7f7C4StcoK4/+BSw9ebcS8jRiVrORFKCALLw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.0.0 || >=22.0.0" + } + }, "node_modules/tinyrainbow": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", @@ -1879,6 +3002,242 @@ "node": ">=14.0.0" } }, + "node_modules/tree-sitter": { + "version": "0.21.1", + "resolved": "https://registry.npmjs.org/tree-sitter/-/tree-sitter-0.21.1.tgz", + "integrity": "sha512-7dxoA6kYvtgWw80265MyqJlkRl4yawIjO7S5MigytjELkX43fV2WsAXzsNfO7sBpPPCF5Gp0+XzHk0DwLCq3xQ==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "node-addon-api": "^8.0.0", + "node-gyp-build": "^4.8.0" + } + }, + "node_modules/tree-sitter-c-sharp": { + "version": "0.23.1", + "resolved": "https://registry.npmjs.org/tree-sitter-c-sharp/-/tree-sitter-c-sharp-0.23.1.tgz", + "integrity": "sha512-9zZ4FlcTRWWfRf6f4PgGhG8saPls6qOOt75tDfX7un9vQZJmARjPrAC6yBNCX2T/VKcCjIDbgq0evFaB3iGhQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "node-addon-api": "^8.2.2", + "node-gyp-build": "^4.8.2" + }, + "peerDependencies": { + "tree-sitter": "^0.21.1" + }, + "peerDependenciesMeta": { + "tree-sitter": { + "optional": true + } + } + }, + "node_modules/tree-sitter-elixir": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/tree-sitter-elixir/-/tree-sitter-elixir-0.3.5.tgz", + "integrity": "sha512-xozQMvYK0aSolcQZAx2d84Xe/YMWFuRPYFlLVxO01bM2GITh5jyiIp0TqPCQa8754UzRAI7A83hZmfiYub5TZQ==", + "dev": true, + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "node-addon-api": "^7.1.0", + "node-gyp-build": "^4.8.0" + }, + "peerDependencies": { + "tree-sitter": "^0.21.0" + } + }, + "node_modules/tree-sitter-elixir/node_modules/node-addon-api": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz", + "integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/tree-sitter-go": { + "version": "0.23.4", + "resolved": "https://registry.npmjs.org/tree-sitter-go/-/tree-sitter-go-0.23.4.tgz", + "integrity": "sha512-iQaHEs4yMa/hMo/ZCGqLfG61F0miinULU1fFh+GZreCRtKylFLtvn798ocCZjO2r/ungNZgAY1s1hPFyAwkc7w==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "node-addon-api": "^8.2.1", + "node-gyp-build": "^4.8.2" + }, + "peerDependencies": { + "tree-sitter": "^0.21.1" + }, + "peerDependenciesMeta": { + "tree-sitter": { + "optional": true + } + } + }, + "node_modules/tree-sitter-javascript": { + "version": "0.23.1", + "resolved": "https://registry.npmjs.org/tree-sitter-javascript/-/tree-sitter-javascript-0.23.1.tgz", + "integrity": "sha512-/bnhbrTD9frUYHQTiYnPcxyHORIw157ERBa6dqzaKxvR/x3PC4Yzd+D1pZIMS6zNg2v3a8BZ0oK7jHqsQo9fWA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "node-addon-api": "^8.2.2", + "node-gyp-build": "^4.8.2" + }, + "peerDependencies": { + "tree-sitter": "^0.21.1" + }, + "peerDependenciesMeta": { + "tree-sitter": { + "optional": true + } + } + }, + "node_modules/tree-sitter-kotlin": { + "version": "0.4.0", + "resolved": "git+ssh://git@github.com/fwcd/tree-sitter-kotlin.git#f66d2908542e93c0204c6c241f794afe4e9cd5d1", + "integrity": "sha512-onbogYgMICW34xos1mQNJEKnoq+m643z9MBC+AYa7mn4mH/KU4VJZnMVLcTViUErJ8h99KTRQbPH6wPlQLpepg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "node-addon-api": "^8.2.2", + "node-gyp-build": "^4.8.2" + }, + "peerDependencies": { + "tree-sitter": "^0.21.1" + }, + "peerDependenciesMeta": { + "tree_sitter": { + "optional": true + } + } + }, + "node_modules/tree-sitter-php": { + "version": "0.23.12", + "resolved": "https://registry.npmjs.org/tree-sitter-php/-/tree-sitter-php-0.23.12.tgz", + "integrity": "sha512-VwkBVOahhC2NYXK/Fuqq30NxuL/6c2hmbxEF4jrB7AyR5rLc7nT27mzF3qoi+pqx9Gy2AbXnGezF7h4MeM6YRA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "node-addon-api": "^8.2.2", + "node-gyp-build": "^4.8.2" + }, + "peerDependencies": { + "tree-sitter": "^0.21.1" + }, + "peerDependenciesMeta": { + "tree-sitter": { + "optional": true + } + } + }, + "node_modules/tree-sitter-python": { + "version": "0.21.0", + "resolved": "https://registry.npmjs.org/tree-sitter-python/-/tree-sitter-python-0.21.0.tgz", + "integrity": "sha512-IUKx7JcTVbByUx1iHGFS/QsIjx7pqwTMHL9bl/NGyhyyydbfNrpruo2C7W6V4KZrbkkCOlX8QVrCoGOFW5qecg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "node-addon-api": "^7.1.0", + "node-gyp-build": "^4.8.0" + }, + "peerDependencies": { + "tree-sitter": "^0.21.0" + }, + "peerDependenciesMeta": { + "tree_sitter": { + "optional": true + } + } + }, + "node_modules/tree-sitter-python/node_modules/node-addon-api": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz", + "integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/tree-sitter-ruby": { + "version": "0.21.0", + "resolved": "https://registry.npmjs.org/tree-sitter-ruby/-/tree-sitter-ruby-0.21.0.tgz", + "integrity": "sha512-UrMpF9CZxKbZ5UFuPdXDuraaaYSMMlAiuzTpQXwNm7y0D48ibc9stWU5D6vDyJD0qf5/R+3yKTYHdHkqibmLSQ==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "node-addon-api": "^8.0.0", + "node-gyp-build": "^4.8.1" + }, + "peerDependencies": { + "tree-sitter": "^0.21.0" + }, + "peerDependenciesMeta": { + "tree_sitter": { + "optional": true + } + } + }, + "node_modules/tree-sitter-rust": { + "version": "0.21.0", + "resolved": "https://registry.npmjs.org/tree-sitter-rust/-/tree-sitter-rust-0.21.0.tgz", + "integrity": "sha512-unVr73YLn3VC4Qa/GF0Nk+Wom6UtI526p5kz9Rn2iZSqwIFedyCZ3e0fKCEmUJLIPGrTb/cIEdu3ZUNGzfZx7A==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "node-addon-api": "^7.1.0", + "node-gyp-build": "^4.8.0" + }, + "peerDependencies": { + "tree-sitter": "^0.21.1" + }, + "peerDependenciesMeta": { + "tree_sitter": { + "optional": true + } + } + }, + "node_modules/tree-sitter-rust/node_modules/node-addon-api": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz", + "integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/tree-sitter-typescript": { + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/tree-sitter-typescript/-/tree-sitter-typescript-0.23.2.tgz", + "integrity": "sha512-e04JUUKxTT53/x3Uq1zIL45DoYKVfHH4CZqwgZhPg5qYROl5nQjV+85ruFzFGZxu+QeFVbRTPDRnqL9UbU4VeA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "node-addon-api": "^8.2.2", + "node-gyp-build": "^4.8.2", + "tree-sitter-javascript": "^0.23.1" + }, + "peerDependencies": { + "tree-sitter": "^0.21.0" + }, + "peerDependenciesMeta": { + "tree-sitter": { + "optional": true + } + } + }, + "node_modules/ts-algebra": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/ts-algebra/-/ts-algebra-1.2.2.tgz", + "integrity": "sha512-kloPhf1hq3JbCPOTYoOWDKxebWjNb2o/LKnNfkWhxVVisFFmMJPPdJeGoGmM+iRLyoXAR61e08Pb+vUXINg8aA==", + "dev": true, + "license": "MIT" + }, "node_modules/tslib": { "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", @@ -2126,6 +3485,13 @@ "funding": { "url": "https://github.com/sponsors/eemeli" } + }, + "node_modules/yaml-ast-parser": { + "version": "0.0.43", + "resolved": "https://registry.npmjs.org/yaml-ast-parser/-/yaml-ast-parser-0.0.43.tgz", + "integrity": "sha512-2PTINUwsRqSd+s8XxKaJWQlUuEMHJQyEuh2edBbW8KNJz0SJPwUSD2zRWqezFEdN7IzAgeuYHFUCF7o8zRdZ0A==", + "dev": true, + "license": "Apache-2.0" } } } diff --git a/package.json b/package.json index b3b643e..965c457 100644 --- a/package.json +++ b/package.json @@ -48,6 +48,9 @@ "devDependencies": { "@types/node": "~22.19.7", "@vitest/coverage-v8": "^4.0.18", + "@workos/openapi-spec": "^0.6.0", + "oxfmt": "^0.54.0", + "oxlint": "^1.69.0", "tsx": "^4.20.3", "typescript": "^5.9.3", "vitest": "^4.0.18" @@ -65,7 +68,12 @@ "test:watch": "vitest", "test:coverage": "vitest run --coverage", "typecheck": "tsc --noEmit", + "lint": "oxlint", + "lint:fix": "oxlint --fix", + "fmt": "oxfmt", + "fmt:check": "oxfmt --check", "gen:routes": "tsx scripts/gen-routes.ts", + "gen:events": "tsx scripts/gen-events.ts", "check:coverage": "tsx scripts/check-coverage.ts" }, "author": "WorkOS", diff --git a/renovate.json b/renovate.json index d702521..144f4fc 100644 --- a/renovate.json +++ b/renovate.json @@ -1,6 +1,4 @@ { "$schema": "https://docs.renovatebot.com/renovate-schema.json", - "extends": [ - "github>workos/renovate-config:public" - ] + "extends": ["github>workos/renovate-config:public"] } diff --git a/scripts/gen-events-lib.spec.ts b/scripts/gen-events-lib.spec.ts new file mode 100644 index 0000000..599bc41 --- /dev/null +++ b/scripts/gen-events-lib.spec.ts @@ -0,0 +1,204 @@ +import { describe, it, expect } from 'vitest'; +import { + type EventSchemaNode, + eventConstantKey, + parseEventCatalog, + deriveAuthEventDataFields, + generateEventsFile, +} from './gen-events-lib.js'; + +// --------------------------------------------------------------------------- +// Fixture: a miniature spec exercising every extraction path — the +// subscribable enum, inline data schemas, $ref data schemas, const +// type/status fields, and the failed-event error object. +// --------------------------------------------------------------------------- + +const fixtureSpec: EventSchemaNode = { + openapi: '3.1.0', + components: { + schemas: { + CreateWebhookEndpointDto: { + type: 'object', + properties: { + endpoint_url: { type: 'string' }, + events: { + type: 'array', + items: { + type: 'string', + enum: ['user.created', 'authentication.password_succeeded', 'authentication.password_failed'], + }, + }, + }, + }, + UserlandUser: { + type: 'object', + properties: { + object: { const: 'user' }, + id: { type: 'string' }, + email: { type: 'string' }, + }, + required: ['object', 'id', 'email'], + }, + Event: { + oneOf: [ + { + type: 'object', + properties: { + id: { type: 'string' }, + event: { type: 'string', const: 'user.created' }, + data: { $ref: '#/components/schemas/UserlandUser' }, + }, + }, + { + type: 'object', + properties: { + id: { type: 'string' }, + event: { type: 'string', const: 'authentication.password_succeeded' }, + data: { + type: 'object', + properties: { + type: { type: 'string', const: 'password' }, + status: { type: 'string', const: 'succeeded' }, + user_id: { type: ['string', 'null'] }, + email: { type: 'string' }, + ip_address: { type: ['string', 'null'] }, + user_agent: { type: ['string', 'null'] }, + }, + required: ['type', 'status', 'user_id', 'email', 'ip_address', 'user_agent'], + }, + }, + }, + { + type: 'object', + properties: { + id: { type: 'string' }, + event: { type: 'string', const: 'authentication.password_failed' }, + data: { + type: 'object', + properties: { + type: { type: 'string', const: 'password' }, + status: { type: 'string', const: 'failed' }, + user_id: { type: ['string', 'null'] }, + email: { type: ['string', 'null'] }, + ip_address: { type: ['string', 'null'] }, + user_agent: { type: ['string', 'null'] }, + error: { + type: 'object', + properties: { code: { type: 'string' }, message: { type: 'string' } }, + required: ['code', 'message'], + }, + }, + required: ['type', 'status', 'user_id', 'email', 'ip_address', 'user_agent', 'error'], + }, + }, + }, + ], + }, + }, + }, +}; + +// --------------------------------------------------------------------------- +// eventConstantKey +// --------------------------------------------------------------------------- + +describe('eventConstantKey', () => { + it('converts dotted snake_case event names to camelCase keys', () => { + expect(eventConstantKey('user.created')).toBe('userCreated'); + expect(eventConstantKey('authentication.magic_auth_failed')).toBe('authenticationMagicAuthFailed'); + expect(eventConstantKey('dsync.group.user_added')).toBe('dsyncGroupUserAdded'); + }); +}); + +// --------------------------------------------------------------------------- +// parseEventCatalog +// --------------------------------------------------------------------------- + +describe('parseEventCatalog', () => { + it('extracts subscribable names from CreateWebhookEndpointDto', () => { + const catalog = parseEventCatalog(fixtureSpec); + expect(catalog.subscribable).toEqual([ + 'authentication.password_failed', + 'authentication.password_succeeded', + 'user.created', + ]); + }); + + it('throws a clear error when the subscribable enum is missing', () => { + expect(() => parseEventCatalog({ components: { schemas: {} } })).toThrow(/CreateWebhookEndpointDto/); + }); + + it('finds event payload schemas anywhere in the spec tree', () => { + const catalog = parseEventCatalog(fixtureSpec); + expect(catalog.events.map((e) => e.name)).toEqual([ + 'authentication.password_failed', + 'authentication.password_succeeded', + 'user.created', + ]); + }); + + it('resolves $ref data schemas for required fields', () => { + const catalog = parseEventCatalog(fixtureSpec); + const userCreated = catalog.events.find((e) => e.name === 'user.created')!; + expect(userCreated.dataRequired).toEqual(['object', 'id', 'email']); + }); + + it('captures const type and status from auth event data', () => { + const catalog = parseEventCatalog(fixtureSpec); + const failed = catalog.events.find((e) => e.name === 'authentication.password_failed')!; + expect(failed.dataType).toBe('password'); + expect(failed.dataStatus).toBe('failed'); + expect(failed.dataRequired).toContain('error'); + }); +}); + +// --------------------------------------------------------------------------- +// deriveAuthEventDataFields +// --------------------------------------------------------------------------- + +describe('deriveAuthEventDataFields', () => { + it('derives literal unions for const fields and merges nullability', () => { + const catalog = parseEventCatalog(fixtureSpec); + const fields = deriveAuthEventDataFields(catalog.events); + const byName = Object.fromEntries(fields.map((f) => [f.name, f])); + + expect(byName.status.tsType).toBe("'failed' | 'succeeded'"); + expect(byName.type.tsType).toBe("'password'"); + // email is `string` on succeeded and `string | null` on failed → merged + expect(byName.email.tsType).toBe('string | null'); + expect(byName.email.optional).toBe(false); + }); + + it('marks fields absent from some auth schemas as optional', () => { + const catalog = parseEventCatalog(fixtureSpec); + const fields = deriveAuthEventDataFields(catalog.events); + const error = fields.find((f) => f.name === 'error')!; + expect(error.optional).toBe(true); + expect(error.tsType).toBe('{ code: string; message: string }'); + }); +}); + +// --------------------------------------------------------------------------- +// generateEventsFile +// --------------------------------------------------------------------------- + +describe('generateEventsFile', () => { + it('generates EVENTS constants, the subscribable list, and requirements', () => { + const catalog = parseEventCatalog(fixtureSpec); + const output = generateEventsFile(catalog); + + expect(output).toContain("userCreated: 'user.created',"); + expect(output).toContain("authenticationPasswordFailed: 'authentication.password_failed',"); + expect(output).toContain('export type WorkOSEventName'); + expect(output).toContain('export const SUBSCRIBABLE_EVENTS'); + expect(output).toContain('export interface AuthenticationEventData'); + expect(output).toContain( + "'authentication.password_failed': { type: 'password', status: 'failed', required: ['type', 'status', 'user_id', 'email', 'ip_address', 'user_agent', 'error'] },", + ); + }); + + it('is deterministic (same catalog → same output)', () => { + const catalog = parseEventCatalog(fixtureSpec); + expect(generateEventsFile(catalog)).toBe(generateEventsFile(catalog)); + }); +}); diff --git a/scripts/gen-events-lib.ts b/scripts/gen-events-lib.ts new file mode 100644 index 0000000..cd4d646 --- /dev/null +++ b/scripts/gen-events-lib.ts @@ -0,0 +1,237 @@ +/** + * Core codegen logic for gen-events. Separated from the CLI entry point + * so the transformation functions can be unit-tested independently. + * + * Extracts the webhook event catalog from a WorkOS OpenAPI spec: + * - subscribable event names (CreateWebhookEndpointDto.properties.events.items.enum) + * - per-event payload schemas (any object schema with properties.event.const) + * and generates src/workos/generated/events.ts. + */ + +import { toPascalCase } from './gen-routes-lib.js'; + +// --------------------------------------------------------------------------- +// Spec types (looser than gen-routes-lib: `type` may be a string or an array +// per JSON Schema 2020, and we walk arbitrary nesting) +// --------------------------------------------------------------------------- + +export interface EventSchemaNode { + type?: string | string[]; + const?: string; + enum?: string[]; + properties?: Record; + required?: string[]; + items?: EventSchemaNode; + $ref?: string; + [key: string]: unknown; +} + +export interface ParsedEvent { + /** Event name, e.g. "authentication.magic_auth_failed" */ + name: string; + /** Required fields of the event's data payload */ + dataRequired: string[]; + /** Properties of the data payload schema (post-$ref resolution) */ + dataProperties: Record; + /** const value of data.type, when present (authentication events) */ + dataType?: string; + /** const value of data.status, when present */ + dataStatus?: string; +} + +export interface ParsedEventCatalog { + /** Names subscribable via webhook endpoints */ + subscribable: string[]; + /** Every event with a payload schema in the spec, sorted by name */ + events: ParsedEvent[]; +} + +// --------------------------------------------------------------------------- +// Extraction +// --------------------------------------------------------------------------- + +/** Convert an event name to a camelCase constant key: "dsync.group.user_added" → "dsyncGroupUserAdded" */ +export function eventConstantKey(name: string): string { + const pascal = toPascalCase(name.replace(/\./g, '_')); + return pascal.charAt(0).toLowerCase() + pascal.slice(1); +} + +function resolveRef(node: EventSchemaNode | undefined, spec: EventSchemaNode): EventSchemaNode | undefined { + if (!node?.$ref) return node; + const match = node.$ref.match(/^#\/components\/schemas\/(.+)$/); + if (!match) return node; + const schemas = (spec as { components?: { schemas?: Record } }).components?.schemas; + return schemas?.[match[1]] ?? node; +} + +export function parseEventCatalog(spec: EventSchemaNode): ParsedEventCatalog { + // Subscribable names: CreateWebhookEndpointDto.properties.events.items.enum + const schemas = (spec as { components?: { schemas?: Record } }).components?.schemas ?? {}; + const subscribable = schemas.CreateWebhookEndpointDto?.properties?.events?.items?.enum; + if (!subscribable || subscribable.length === 0) { + throw new Error('Could not find CreateWebhookEndpointDto.properties.events.items.enum in the spec'); + } + + // Payload schemas: walk the whole spec for object schemas shaped like an + // event (properties.event.const + properties.data). Location-independent so + // spec refactors don't break extraction. + const byName = new Map(); + const visited = new WeakSet(); + + const visit = (node: unknown): void => { + if (node === null || typeof node !== 'object') return; + if (visited.has(node)) return; + visited.add(node); + + if (Array.isArray(node)) { + for (const item of node) visit(item); + return; + } + + const schema = node as EventSchemaNode; + const eventName = schema.properties?.event?.const; + if (typeof eventName === 'string' && schema.properties?.data && !byName.has(eventName)) { + const data = resolveRef(schema.properties.data, spec) ?? {}; + byName.set(eventName, { + name: eventName, + dataRequired: data.required ?? [], + dataProperties: data.properties ?? {}, + dataType: data.properties?.type?.const, + dataStatus: data.properties?.status?.const, + }); + } + + for (const value of Object.values(schema)) visit(value); + }; + visit(spec); + + return { + subscribable: [...subscribable].sort(), + events: [...byName.values()].sort((a, b) => a.name.localeCompare(b.name)), + }; +} + +// --------------------------------------------------------------------------- +// AuthenticationEventData derivation +// --------------------------------------------------------------------------- + +function isAuthOutcomeEvent(name: string): boolean { + return name.startsWith('authentication.') && (name.endsWith('_succeeded') || name.endsWith('_failed')); +} + +function schemaToTs(node: EventSchemaNode): string { + if (node.const) return `'${node.const}'`; + const types = Array.isArray(node.type) ? node.type : node.type ? [node.type] : []; + if (types.includes('object') || node.properties) { + const props = node.properties ?? {}; + const required = new Set(node.required ?? []); + const entries = Object.entries(props).map( + ([key, value]) => `${key}${required.has(key) ? '' : '?'}: ${schemaToTs(value)}`, + ); + return entries.length > 0 ? `{ ${entries.join('; ')} }` : 'Record'; + } + const mapped = types.map((t) => (t === 'null' ? 'null' : t === 'integer' ? 'number' : t)); + return mapped.length > 0 ? mapped.join(' | ') : 'unknown'; +} + +/** + * Derive the AuthenticationEventData interface from the union of all + * authentication.*_succeeded / *_failed data schemas. Fields with const + * values become literal unions (status, type); fields missing from some + * schemas become optional (error only exists on failed events). + */ +export function deriveAuthEventDataFields( + events: ParsedEvent[], +): Array<{ name: string; tsType: string; optional: boolean }> { + const authEvents = events.filter((e) => isAuthOutcomeEvent(e.name)); + if (authEvents.length === 0) return []; + + const fieldNames: string[] = []; + for (const event of authEvents) { + for (const name of Object.keys(event.dataProperties)) { + if (!fieldNames.includes(name)) fieldNames.push(name); + } + } + + return fieldNames.map((name) => { + const presentIn = authEvents.filter((e) => name in e.dataProperties); + const optional = presentIn.length < authEvents.length; + + const consts = new Set(); + const nonConstTypes = new Set(); + for (const event of presentIn) { + const node = event.dataProperties[name]; + if (node.const) { + consts.add(`'${node.const}'`); + } else { + // Flatten union atoms so "string" + "string | null" dedup to "string | null" + const ts = schemaToTs(node); + for (const atom of ts.includes('{') ? [ts] : ts.split(' | ')) nonConstTypes.add(atom); + } + } + const atoms = [...nonConstTypes].sort((a, b) => (a === 'null' ? 1 : b === 'null' ? -1 : a.localeCompare(b))); + const tsType = + consts.size > 0 && atoms.length === 0 ? [...consts].sort().join(' | ') : atoms.join(' | ') || 'unknown'; + return { name, tsType, optional }; + }); +} + +// --------------------------------------------------------------------------- +// Code generation +// --------------------------------------------------------------------------- + +export function generateEventsFile(catalog: ParsedEventCatalog): string { + const allNames = [...new Set([...catalog.subscribable, ...catalog.events.map((e) => e.name)])].sort(); + + const lines: string[] = []; + lines.push('/**'); + lines.push(' * Generated by scripts/gen-events.ts — do not edit by hand.'); + lines.push(' * Source: the @workos/openapi-spec package. Regenerate with:'); + lines.push(' * npm run gen:events'); + lines.push(' */'); + lines.push(''); + lines.push('/** All WorkOS event names defined in the OpenAPI spec. */'); + lines.push('export const EVENTS = {'); + for (const name of allNames) { + lines.push(` ${eventConstantKey(name)}: '${name}',`); + } + lines.push('} as const;'); + lines.push(''); + lines.push('export type WorkOSEventName = (typeof EVENTS)[keyof typeof EVENTS];'); + lines.push(''); + lines.push('/** Event names subscribable via webhook endpoints (CreateWebhookEndpointDto). */'); + lines.push('export const SUBSCRIBABLE_EVENTS: readonly WorkOSEventName[] = ['); + for (const name of catalog.subscribable) { + lines.push(` '${name}',`); + } + lines.push('];'); + lines.push(''); + + const authFields = deriveAuthEventDataFields(catalog.events); + if (authFields.length > 0) { + lines.push('/** Payload shape shared by authentication.*_succeeded / *_failed events. */'); + lines.push('export interface AuthenticationEventData {'); + for (const field of authFields) { + lines.push(` ${field.name}${field.optional ? '?' : ''}: ${field.tsType};`); + } + lines.push('}'); + lines.push(''); + } + + lines.push('/** Per-event payload requirements from the spec, for test assertions. */'); + lines.push('export const EVENT_DATA_REQUIREMENTS: Record<'); + lines.push(' string,'); + lines.push(' { type?: string; status?: string; required: readonly string[] }'); + lines.push('> = {'); + for (const event of catalog.events) { + const parts: string[] = []; + if (event.dataType) parts.push(`type: '${event.dataType}'`); + if (event.dataStatus) parts.push(`status: '${event.dataStatus}'`); + parts.push(`required: [${event.dataRequired.map((f) => `'${f}'`).join(', ')}]`); + lines.push(` '${event.name}': { ${parts.join(', ')} },`); + } + lines.push('};'); + lines.push(''); + + return lines.join('\n'); +} diff --git a/scripts/gen-events.ts b/scripts/gen-events.ts new file mode 100644 index 0000000..ec689df --- /dev/null +++ b/scripts/gen-events.ts @@ -0,0 +1,80 @@ +#!/usr/bin/env tsx +/** + * Codegen script: reads the WorkOS OpenAPI spec and generates the event catalog + * (src/workos/generated/events.ts) — event names, subscribable list, the + * authentication event payload interface, and per-event payload requirements. + * + * By default the spec comes from the @workos/openapi-spec devDependency, so + * regenerating is just: + * npm run gen:events + * Update the dependency to pick up a newer spec: + * npm install -D @workos/openapi-spec@latest && npm run gen:events + * A local spec file can still be passed explicitly: + * npm run gen:events -- path/to/openapi.yaml [--out ] [--dry-run] + * + * The generated file is committed, so consumers of the package never need the + * spec. Re-running against a newer spec is the drift check. Running twice on + * the same spec produces identical output (idempotent). + */ + +import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'node:fs'; +import { createRequire } from 'node:module'; +import { resolve, extname, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import YAML from 'yaml'; +import { format, type FormatConfig } from 'oxfmt'; + +import { type EventSchemaNode, parseEventCatalog, generateEventsFile } from './gen-events-lib.js'; + +/** Load the project's oxfmt config so generated output matches `npm run fmt`. */ +function loadFormatConfig(): FormatConfig { + const configPath = resolve(dirname(fileURLToPath(import.meta.url)), '..', '.oxfmtrc.json'); + return existsSync(configPath) ? (JSON.parse(readFileSync(configPath, 'utf-8')) as FormatConfig) : {}; +} + +async function main(): Promise { + const args = process.argv.slice(2); + const flags = args.filter((a) => a.startsWith('--')); + const positional = args.filter((a) => !a.startsWith('--')); + + // Default to the published spec package; a positional path overrides it. + const specPath = positional[0] ?? createRequire(import.meta.url).resolve('@workos/openapi-spec/spec'); + + const dryRun = flags.includes('--dry-run'); + const outIdx = args.indexOf('--out'); + const outFile = outIdx !== -1 ? args[outIdx + 1] : 'src/workos/generated/events.ts'; + + 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(); + const spec: EventSchemaNode = + ext === '.yaml' || ext === '.yml' ? (YAML.parse(raw) as EventSchemaNode) : (JSON.parse(raw) as EventSchemaNode); + + const catalog = parseEventCatalog(spec); + const resolvedOut = resolve(outFile); + // The output path's `.ts` extension tells oxfmt to use the TypeScript parser. + const formatted = await format(resolvedOut, generateEventsFile(catalog), loadFormatConfig()); + if (formatted.errors.length > 0) { + console.error('oxfmt reported errors while formatting generated output:'); + for (const err of formatted.errors) console.error(` ${err.severity}: ${err.message}`); + process.exit(1); + } + const content = formatted.code; + + if (dryRun) { + console.log(content); + return; + } + + mkdirSync(dirname(resolvedOut), { recursive: true }); + writeFileSync(resolvedOut, content, 'utf-8'); + console.log(` wrote ${resolvedOut}`); + console.log(`\nCatalog: ${catalog.subscribable.length} subscribable, ${catalog.events.length} payload schemas`); +} + +await main(); diff --git a/src/core/error-hooks.spec.ts b/src/core/error-hooks.spec.ts index 62f5526..e0827af 100644 --- a/src/core/error-hooks.spec.ts +++ b/src/core/error-hooks.spec.ts @@ -1,7 +1,7 @@ import { describe, it, expect, beforeEach } from 'vitest'; import { createServer, type ApiKeyMap } from './index.js'; import { workosPlugin } from '../workos/index.js'; -import { addErrorHook, getErrorHooks, removeErrorHook, setErrorHooks } from './error-hooks.js'; +import { addErrorHook, getErrorHooks, removeErrorHook } from './error-hooks.js'; import type { Store } from './store.js'; const apiKeys: ApiKeyMap = { sk_test_hooks: { environment: 'test' } }; diff --git a/src/core/store.ts b/src/core/store.ts index a899698..338904e 100644 --- a/src/core/store.ts +++ b/src/core/store.ts @@ -12,7 +12,7 @@ export type SortFn = (a: T, b: T) => number; export interface CollectionHooks { onInsert?: (item: T) => void; - onUpdate?: (item: T) => void; + onUpdate?: (item: T, previous: T) => void; onDelete?: (item: T) => void; } @@ -101,7 +101,7 @@ export class Collection { } as T; this.items.set(id, updated); this.addToIndex(updated); - this.hooks.onUpdate?.(updated); + this.hooks.onUpdate?.(updated, existing); return updated; } diff --git a/src/e2e.spec.ts b/src/e2e.spec.ts new file mode 100644 index 0000000..5d01d06 --- /dev/null +++ b/src/e2e.spec.ts @@ -0,0 +1,304 @@ +/** + * End-to-end login flow story, over real HTTP. + * + * Boots the emulator with createEmulator() plus a local webhook receiver, then + * walks the workos.com/docs login flows and asserts that every resource + * creation and authentication outcome delivers a signed webhook whose name and + * payload match the OpenAPI spec (via the generated EVENT_DATA_REQUIREMENTS). + */ +import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest'; +import { createServer, type Server } from 'node:http'; +import { createHmac } from 'node:crypto'; +import { createEmulator, type Emulator } from './index.js'; +import { EVENT_DATA_REQUIREMENTS } from './workos/generated/events.js'; + +const WEBHOOK_SECRET = 'whsec_e2e_test_secret'; + +interface ReceivedWebhook { + id: string; + event: string; + data: Record; + created_at: string; + signature: string; + rawBody: string; +} + +interface WebhookReceiver { + url: string; + received: ReceivedWebhook[]; + close: () => Promise; +} + +function startWebhookReceiver(): Promise { + const received: ReceivedWebhook[] = []; + const server: Server = createServer((req, res) => { + let rawBody = ''; + req.on('data', (chunk) => (rawBody += chunk)); + req.on('end', () => { + const parsed = JSON.parse(rawBody); + received.push({ ...parsed, signature: req.headers['workos-signature'] as string, rawBody }); + res.writeHead(200).end(); + }); + }); + return new Promise((resolve) => { + server.listen(0, '127.0.0.1', () => { + const addr = server.address(); + const port = typeof addr === 'object' && addr ? addr.port : 0; + resolve({ + url: `http://127.0.0.1:${port}/webhooks`, + received, + close: () => new Promise((res2, rej) => server.close((err) => (err ? rej(err) : res2()))), + }); + }); + }); +} + +describe('end-to-end login flow (workos.com/docs story)', () => { + let emulator: Emulator; + let receiver: WebhookReceiver; + let userId: string; + const email = 'alice@e2e-story.test'; + + const api = (path: string, init?: RequestInit) => + fetch(`${emulator.url}${path}`, { + ...init, + headers: { + Authorization: `Bearer ${emulator.apiKey}`, + 'Content-Type': 'application/json', + ...init?.headers, + }, + }); + + /** Deliveries are fire-and-forget — poll until the named webhook arrives past the cursor. */ + function waitForWebhook(event: string, opts?: { after?: number; timeout?: number }): Promise { + return vi.waitFor( + () => { + const hit = receiver.received.slice(opts?.after ?? 0).find((w) => w.event === event); + if (!hit) { + const seen = receiver.received.map((w) => w.event).join(', ') || '(none)'; + throw new Error(`no '${event}' webhook yet; saw: ${seen}`); + } + return hit; + }, + { timeout: opts?.timeout ?? 3000, interval: 25 }, + ); + } + + /** WorkOS-Signature: t=,v1= — same scheme the official SDKs verify. */ + function verifySignature(webhook: ReceivedWebhook): void { + const match = webhook.signature?.match(/^t=(\d+),v1=([a-f0-9]{64})$/); + expect(match, `unexpected signature format: ${webhook.signature}`).toBeTruthy(); + const expected = createHmac('sha256', WEBHOOK_SECRET).update(`${match![1]}.${webhook.rawBody}`).digest('hex'); + expect(match![2]).toBe(expected); + } + + /** Assert the payload carries every field the OpenAPI spec marks required for this event. */ + function expectSpecShape(webhook: ReceivedWebhook): void { + const requirements = EVENT_DATA_REQUIREMENTS[webhook.event]; + expect(requirements, `event '${webhook.event}' is not in the spec catalog`).toBeDefined(); + for (const field of requirements.required) { + expect(webhook.data, `'${webhook.event}' payload is missing required field '${field}'`).toHaveProperty(field); + } + if (requirements.type) expect(webhook.data.type).toBe(requirements.type); + if (requirements.status) expect(webhook.data.status).toBe(requirements.status); + } + + beforeAll(async () => { + receiver = await startWebhookReceiver(); + emulator = await createEmulator({ port: 0 }); + + const res = await api('/webhook_endpoints', { + method: 'POST', + body: JSON.stringify({ endpoint_url: receiver.url, secret: WEBHOOK_SECRET, events: [] }), + }); + expect(res.status).toBe(201); + }); + + afterAll(async () => { + await emulator.close(); + await receiver.close(); + }); + + it('delivers a signed, spec-shaped user.created webhook when a user registers', async () => { + const cursor = receiver.received.length; + + const res = await api('/user_management/users', { + method: 'POST', + body: JSON.stringify({ email, password: 'correct horse battery staple', first_name: 'Alice' }), + }); + expect(res.status).toBe(201); + userId = (await res.json()).id; + + const webhook = await waitForWebhook('user.created', { after: cursor }); + expect(webhook.data.email).toBe(email); + expect(webhook.data.id).toBe(userId); + verifySignature(webhook); + expectSpecShape(webhook); + }); + + it('delivers organization.created and organization_membership.created webhooks', async () => { + const cursor = receiver.received.length; + + const orgRes = await api('/organizations', { + method: 'POST', + body: JSON.stringify({ name: 'E2E Story Org' }), + }); + const org = await orgRes.json(); + + await api('/user_management/organization_memberships', { + method: 'POST', + body: JSON.stringify({ user_id: userId, organization_id: org.id }), + }); + + const orgWebhook = await waitForWebhook('organization.created', { after: cursor }); + expect(orgWebhook.data.name).toBe('E2E Story Org'); + verifySignature(orgWebhook); + expectSpecShape(orgWebhook); + + const membershipWebhook = await waitForWebhook('organization_membership.created', { after: cursor }); + expect(membershipWebhook.data.user_id).toBe(userId); + expect(membershipWebhook.data.organization_id).toBe(org.id); + verifySignature(membershipWebhook); + }); + + it('completes the hosted authorize → authenticate flow with session and oauth webhooks', async () => { + const cursor = receiver.received.length; + + // Step 1 (docs: "Redirect users to AuthKit"): the app sends the browser to /authorize + const authorizeRes = await fetch( + `${emulator.url}/user_management/authorize?` + + new URLSearchParams({ + response_type: 'code', + client_id: 'client_e2e', + redirect_uri: 'http://localhost:3000/callback', + state: 'e2e-state', + login_hint: email, + }), + { redirect: 'manual' }, + ); + expect(authorizeRes.status).toBe(302); + const callback = new URL(authorizeRes.headers.get('location')!); + expect(callback.searchParams.get('state')).toBe('e2e-state'); + const code = callback.searchParams.get('code')!; + expect(code).toBeTruthy(); + + // Step 2 (docs: "Exchange the code"): the callback handler authenticates + const authRes = await fetch(`${emulator.url}/user_management/authenticate`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ grant_type: 'authorization_code', code, client_id: 'client_e2e' }), + }); + expect(authRes.status).toBe(200); + const auth = await authRes.json(); + expect(auth.access_token).toBeTruthy(); + expect(auth.refresh_token).toBeTruthy(); + expect(auth.user.email).toBe(email); + expect(auth.authentication_method).toBe('OAuth'); + + // Step 3: webhooks for the new session and the authentication outcome + const sessionWebhook = await waitForWebhook('session.created', { after: cursor }); + expect(sessionWebhook.data).toMatchObject({ user_id: userId, auth_method: 'oauth', status: 'active' }); + verifySignature(sessionWebhook); + expectSpecShape(sessionWebhook); + + const authWebhook = await waitForWebhook('authentication.oauth_succeeded', { after: cursor }); + expect(authWebhook.data).toMatchObject({ type: 'oauth', status: 'succeeded', user_id: userId, email }); + verifySignature(authWebhook); + expectSpecShape(authWebhook); + }); + + it('signs in with a password and emits authentication.password_succeeded', async () => { + const cursor = receiver.received.length; + + const res = await fetch(`${emulator.url}/user_management/authenticate`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ grant_type: 'password', email, password: 'correct horse battery staple' }), + }); + expect(res.status).toBe(200); + + const webhook = await waitForWebhook('authentication.password_succeeded', { after: cursor }); + expect(webhook.data).toMatchObject({ type: 'password', status: 'succeeded', user_id: userId, email }); + expectSpecShape(webhook); + }); + + it('completes magic auth using the code delivered by the magic_auth.created webhook', async () => { + const cursor = receiver.received.length; + + const res = await api('/user_management/magic_auth', { + method: 'POST', + body: JSON.stringify({ email }), + }); + expect(res.status).toBe(201); + + // The story beat: the webhook carries the code your app would have emailed + const createdWebhook = await waitForWebhook('magic_auth.created', { after: cursor }); + expectSpecShape(createdWebhook); + const code = createdWebhook.data.code as string; + expect(code).toBeTruthy(); + + const authRes = await fetch(`${emulator.url}/user_management/authenticate`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ grant_type: 'urn:workos:oauth:grant-type:magic-auth:code', code, email }), + }); + expect(authRes.status).toBe(200); + + const webhook = await waitForWebhook('authentication.magic_auth_succeeded', { after: cursor }); + expect(webhook.data).toMatchObject({ type: 'magic_auth', status: 'succeeded', user_id: userId, email }); + expectSpecShape(webhook); + }); + + it('emits authentication.password_failed with an error object on a bad password', async () => { + const cursor = receiver.received.length; + + const res = await fetch(`${emulator.url}/user_management/authenticate`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ grant_type: 'password', email, password: 'wrong password' }), + }); + expect(res.status).toBe(401); + + const webhook = await waitForWebhook('authentication.password_failed', { after: cursor }); + expect(webhook.data).toMatchObject({ + type: 'password', + status: 'failed', + email, + error: { code: 'invalid_credentials', message: 'Invalid credentials' }, + }); + verifySignature(webhook); + expectSpecShape(webhook); + }); + + it('completes a password reset driven entirely by webhooks', async () => { + const cursor = receiver.received.length; + + const res = await api('/user_management/password_reset', { + method: 'POST', + body: JSON.stringify({ email }), + }); + expect(res.status).toBe(201); + + const createdWebhook = await waitForWebhook('password_reset.created', { after: cursor }); + expectSpecShape(createdWebhook); + const token = createdWebhook.data.token as string; + expect(token).toBeTruthy(); + + const confirmRes = await api('/user_management/password_reset/confirm', { + method: 'POST', + body: JSON.stringify({ token, new_password: 'an even better passphrase' }), + }); + expect(confirmRes.status).toBe(200); + + await waitForWebhook('password_reset.succeeded', { after: cursor }); + + // The new password works — and emits its own success event + const loginRes = await fetch(`${emulator.url}/user_management/authenticate`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ grant_type: 'password', email, password: 'an even better passphrase' }), + }); + expect(loginRes.status).toBe(200); + await waitForWebhook('authentication.password_succeeded', { after: cursor }); + }); +}); diff --git a/src/index.ts b/src/index.ts index e0ec6bc..1de0e2f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,4 +1,12 @@ -import { createServer, type ApiKeyMap, addErrorHook, removeErrorHook, getErrorHooks, type ErrorHook, type ErrorHookInput } from './core/index.js'; +import { + createServer, + type ApiKeyMap, + addErrorHook, + removeErrorHook, + getErrorHooks, + type ErrorHook, + type ErrorHookInput, +} from './core/index.js'; import { workosPlugin, seedFromConfig, type WorkOSSeedConfig } from './workos/index.js'; import { STORE_KEYS } from './workos/constants.js'; import { serve } from '@hono/node-server'; diff --git a/src/workos/constants.ts b/src/workos/constants.ts index cdbda29..59bc01b 100644 --- a/src/workos/constants.ts +++ b/src/workos/constants.ts @@ -16,45 +16,14 @@ export const STORE_KEY_PREFIXES = { radarIpList: 'radar_ip_list', } as const; -/** All WorkOS webhook event names */ -export const EVENTS = { - userCreated: 'user.created', - userUpdated: 'user.updated', - userDeleted: 'user.deleted', - organizationCreated: 'organization.created', - organizationUpdated: 'organization.updated', - organizationDeleted: 'organization.deleted', - organizationDomainCreated: 'organization_domain.created', - organizationDomainVerified: 'organization_domain.verified', - organizationDomainUpdated: 'organization_domain.updated', - organizationDomainDeleted: 'organization_domain.deleted', - organizationMembershipCreated: 'organization_membership.created', - organizationMembershipUpdated: 'organization_membership.updated', - organizationMembershipDeleted: 'organization_membership.deleted', - connectionCreated: 'connection.created', - connectionUpdated: 'connection.updated', - connectionDeleted: 'connection.deleted', - sessionCreated: 'session.created', - sessionRevoked: 'session.revoked', - invitationCreated: 'invitation.created', - invitationAccepted: 'invitation.accepted', - invitationRevoked: 'invitation.revoked', - invitationResent: 'invitation.resent', - roleCreated: 'role.created', - roleUpdated: 'role.updated', - roleDeleted: 'role.deleted', - permissionCreated: 'permission.created', - permissionUpdated: 'permission.updated', - permissionDeleted: 'permission.deleted', - directoryCreated: 'directory.created', - directoryUpdated: 'directory.updated', - directoryDeleted: 'directory.deleted', - directoryUserCreated: 'directory_user.created', - directoryUserUpdated: 'directory_user.updated', - directoryUserDeleted: 'directory_user.deleted', - directoryGroupCreated: 'directory_group.created', - directoryGroupUpdated: 'directory_group.updated', - directoryGroupDeleted: 'directory_group.deleted', -} as const; - -export type WorkOSEventName = (typeof EVENTS)[keyof typeof EVENTS]; +/** + * WorkOS event catalog, generated from the OpenAPI spec. + * Regenerate with: npm run gen:events -- path/to/open-api-spec.yaml + */ +export { + EVENTS, + SUBSCRIBABLE_EVENTS, + EVENT_DATA_REQUIREMENTS, + type WorkOSEventName, + type AuthenticationEventData, +} from './generated/events.js'; diff --git a/src/workos/entities.ts b/src/workos/entities.ts index 2171d07..8355377 100644 --- a/src/workos/entities.ts +++ b/src/workos/entities.ts @@ -49,6 +49,10 @@ export interface WorkOSSession extends Entity { organization_id: string | null; ip_address: string | null; user_agent: string | null; + auth_method: string; + status: 'active' | 'expired' | 'revoked'; + expires_at: string; + ended_at: string | null; } export interface WorkOSEmailVerification extends Entity { diff --git a/src/workos/generated/events.ts b/src/workos/generated/events.ts new file mode 100644 index 0000000..6aad81e --- /dev/null +++ b/src/workos/generated/events.ts @@ -0,0 +1,858 @@ +/** + * Generated by scripts/gen-events.ts — do not edit by hand. + * Source: the @workos/openapi-spec package. Regenerate with: + * npm run gen:events + */ + +/** All WorkOS event names defined in the OpenAPI spec. */ +export const EVENTS = { + actionAuthenticationDenied: 'action.authentication.denied', + actionUserRegistrationDenied: 'action.user_registration.denied', + apiKeyCreated: 'api_key.created', + apiKeyRevoked: 'api_key.revoked', + apiKeyUpdated: 'api_key.updated', + authenticationEmailVerificationFailed: 'authentication.email_verification_failed', + authenticationEmailVerificationSucceeded: 'authentication.email_verification_succeeded', + authenticationMagicAuthFailed: 'authentication.magic_auth_failed', + authenticationMagicAuthSucceeded: 'authentication.magic_auth_succeeded', + authenticationMfaFailed: 'authentication.mfa_failed', + authenticationMfaSucceeded: 'authentication.mfa_succeeded', + authenticationOauthFailed: 'authentication.oauth_failed', + authenticationOauthSucceeded: 'authentication.oauth_succeeded', + authenticationPasskeyFailed: 'authentication.passkey_failed', + authenticationPasskeySucceeded: 'authentication.passkey_succeeded', + authenticationPasswordFailed: 'authentication.password_failed', + authenticationPasswordSucceeded: 'authentication.password_succeeded', + authenticationRadarRiskDetected: 'authentication.radar_risk_detected', + authenticationSsoFailed: 'authentication.sso_failed', + authenticationSsoStarted: 'authentication.sso_started', + authenticationSsoSucceeded: 'authentication.sso_succeeded', + authenticationSsoTimedOut: 'authentication.sso_timed_out', + connectionActivated: 'connection.activated', + connectionDeactivated: 'connection.deactivated', + connectionDeleted: 'connection.deleted', + connectionSamlCertificateRenewalRequired: 'connection.saml_certificate_renewal_required', + connectionSamlCertificateRenewed: 'connection.saml_certificate_renewed', + dsyncActivated: 'dsync.activated', + dsyncDeleted: 'dsync.deleted', + dsyncGroupCreated: 'dsync.group.created', + dsyncGroupDeleted: 'dsync.group.deleted', + dsyncGroupUpdated: 'dsync.group.updated', + dsyncGroupUserAdded: 'dsync.group.user_added', + dsyncGroupUserRemoved: 'dsync.group.user_removed', + dsyncTokenCreated: 'dsync.token.created', + dsyncTokenRevoked: 'dsync.token.revoked', + dsyncUserCreated: 'dsync.user.created', + dsyncUserDeleted: 'dsync.user.deleted', + dsyncUserUpdated: 'dsync.user.updated', + emailVerificationCreated: 'email_verification.created', + flagCreated: 'flag.created', + flagDeleted: 'flag.deleted', + flagRuleUpdated: 'flag.rule_updated', + flagUpdated: 'flag.updated', + groupCreated: 'group.created', + groupDeleted: 'group.deleted', + groupMemberAdded: 'group.member_added', + groupMemberRemoved: 'group.member_removed', + groupUpdated: 'group.updated', + invitationAccepted: 'invitation.accepted', + invitationCreated: 'invitation.created', + invitationResent: 'invitation.resent', + invitationRevoked: 'invitation.revoked', + magicAuthCreated: 'magic_auth.created', + organizationCreated: 'organization.created', + organizationDeleted: 'organization.deleted', + organizationUpdated: 'organization.updated', + organizationDomainCreated: 'organization_domain.created', + organizationDomainDeleted: 'organization_domain.deleted', + organizationDomainUpdated: 'organization_domain.updated', + organizationDomainVerificationFailed: 'organization_domain.verification_failed', + organizationDomainVerified: 'organization_domain.verified', + organizationMembershipCreated: 'organization_membership.created', + organizationMembershipDeleted: 'organization_membership.deleted', + organizationMembershipUpdated: 'organization_membership.updated', + organizationRoleCreated: 'organization_role.created', + organizationRoleDeleted: 'organization_role.deleted', + organizationRoleUpdated: 'organization_role.updated', + passwordResetCreated: 'password_reset.created', + passwordResetSucceeded: 'password_reset.succeeded', + permissionCreated: 'permission.created', + permissionDeleted: 'permission.deleted', + permissionUpdated: 'permission.updated', + pipesConnectedAccountConnected: 'pipes.connected_account.connected', + pipesConnectedAccountDisconnected: 'pipes.connected_account.disconnected', + pipesConnectedAccountReauthorizationNeeded: 'pipes.connected_account.reauthorization_needed', + roleCreated: 'role.created', + roleDeleted: 'role.deleted', + roleUpdated: 'role.updated', + sessionCreated: 'session.created', + sessionRevoked: 'session.revoked', + userCreated: 'user.created', + userDeleted: 'user.deleted', + userUpdated: 'user.updated', + vaultByokKeyDeleted: 'vault.byok_key.deleted', + vaultByokKeyVerificationCompleted: 'vault.byok_key.verification_completed', + vaultDataCreated: 'vault.data.created', + vaultDataDeleted: 'vault.data.deleted', + vaultDataRead: 'vault.data.read', + vaultDataUpdated: 'vault.data.updated', + vaultDekDecrypted: 'vault.dek.decrypted', + vaultDekRead: 'vault.dek.read', + vaultKekCreated: 'vault.kek.created', + vaultMetadataRead: 'vault.metadata.read', + vaultNamesListed: 'vault.names.listed', + waitlistUserApproved: 'waitlist_user.approved', + waitlistUserCreated: 'waitlist_user.created', + waitlistUserDenied: 'waitlist_user.denied', +} as const; + +export type WorkOSEventName = (typeof EVENTS)[keyof typeof EVENTS]; + +/** Event names subscribable via webhook endpoints (CreateWebhookEndpointDto). */ +export const SUBSCRIBABLE_EVENTS: readonly WorkOSEventName[] = [ + 'api_key.created', + 'api_key.revoked', + 'api_key.updated', + 'authentication.email_verification_succeeded', + 'authentication.magic_auth_failed', + 'authentication.magic_auth_succeeded', + 'authentication.mfa_succeeded', + 'authentication.oauth_failed', + 'authentication.oauth_succeeded', + 'authentication.passkey_failed', + 'authentication.passkey_succeeded', + 'authentication.password_failed', + 'authentication.password_succeeded', + 'authentication.radar_risk_detected', + 'authentication.sso_failed', + 'authentication.sso_started', + 'authentication.sso_succeeded', + 'authentication.sso_timed_out', + 'connection.activated', + 'connection.deactivated', + 'connection.deleted', + 'connection.saml_certificate_renewal_required', + 'connection.saml_certificate_renewed', + 'dsync.activated', + 'dsync.deleted', + 'dsync.group.created', + 'dsync.group.deleted', + 'dsync.group.updated', + 'dsync.group.user_added', + 'dsync.group.user_removed', + 'dsync.user.created', + 'dsync.user.deleted', + 'dsync.user.updated', + 'email_verification.created', + 'flag.created', + 'flag.deleted', + 'flag.rule_updated', + 'flag.updated', + 'group.created', + 'group.deleted', + 'group.member_added', + 'group.member_removed', + 'group.updated', + 'invitation.accepted', + 'invitation.created', + 'invitation.resent', + 'invitation.revoked', + 'magic_auth.created', + 'organization.created', + 'organization.deleted', + 'organization.updated', + 'organization_domain.created', + 'organization_domain.deleted', + 'organization_domain.updated', + 'organization_domain.verification_failed', + 'organization_domain.verified', + 'organization_membership.created', + 'organization_membership.deleted', + 'organization_membership.updated', + 'organization_role.created', + 'organization_role.deleted', + 'organization_role.updated', + 'password_reset.created', + 'password_reset.succeeded', + 'permission.created', + 'permission.deleted', + 'permission.updated', + 'pipes.connected_account.connected', + 'pipes.connected_account.disconnected', + 'pipes.connected_account.reauthorization_needed', + 'role.created', + 'role.deleted', + 'role.updated', + 'session.created', + 'session.revoked', + 'user.created', + 'user.deleted', + 'user.updated', + 'waitlist_user.approved', + 'waitlist_user.created', + 'waitlist_user.denied', +]; + +/** Payload shape shared by authentication.*_succeeded / *_failed events. */ +export interface AuthenticationEventData { + type: 'email_verification' | 'magic_auth' | 'mfa' | 'oauth' | 'passkey' | 'password' | 'sso'; + status: 'failed' | 'succeeded'; + ip_address: string | null; + user_agent: string | null; + user_id: string | null; + email: string | null; + error?: { code: string; message: string }; + sso?: { organization_id: string | null; connection_id: string | null; session_id: string | null }; +} + +/** Per-event payload requirements from the spec, for test assertions. */ +export const EVENT_DATA_REQUIREMENTS: Record = + { + 'action.authentication.denied': { + type: 'authentication', + required: [ + 'action_endpoint_id', + 'action_execution_id', + 'type', + 'verdict', + 'user_id', + 'organization_id', + 'email', + 'ip_address', + 'user_agent', + ], + }, + 'action.user_registration.denied': { + type: 'user_registration', + required: [ + 'action_endpoint_id', + 'action_execution_id', + 'type', + 'verdict', + 'organization_id', + 'email', + 'ip_address', + 'user_agent', + ], + }, + 'api_key.created': { + required: [ + 'object', + 'id', + 'owner', + 'name', + 'obfuscated_value', + 'last_used_at', + 'expires_at', + 'permissions', + 'created_at', + 'updated_at', + ], + }, + 'api_key.revoked': { + required: [ + 'object', + 'id', + 'owner', + 'name', + 'obfuscated_value', + 'last_used_at', + 'expires_at', + 'permissions', + 'created_at', + 'updated_at', + ], + }, + 'api_key.updated': { + required: [ + 'object', + 'id', + 'owner', + 'name', + 'obfuscated_value', + 'last_used_at', + 'expires_at', + 'permissions', + 'created_at', + 'updated_at', + 'previous_attributes', + ], + }, + 'authentication.email_verification_failed': { + type: 'email_verification', + status: 'failed', + required: ['type', 'status', 'ip_address', 'user_agent', 'user_id', 'email', 'error'], + }, + 'authentication.email_verification_succeeded': { + type: 'email_verification', + status: 'succeeded', + required: ['type', 'status', 'ip_address', 'user_agent', 'user_id', 'email'], + }, + 'authentication.magic_auth_failed': { + type: 'magic_auth', + status: 'failed', + required: ['type', 'status', 'ip_address', 'user_agent', 'user_id', 'email', 'error'], + }, + 'authentication.magic_auth_succeeded': { + type: 'magic_auth', + status: 'succeeded', + required: ['type', 'status', 'ip_address', 'user_agent', 'user_id', 'email'], + }, + 'authentication.mfa_failed': { + type: 'mfa', + status: 'failed', + required: ['type', 'status', 'ip_address', 'user_agent', 'user_id', 'email', 'error'], + }, + 'authentication.mfa_succeeded': { + type: 'mfa', + status: 'succeeded', + required: ['type', 'status', 'ip_address', 'user_agent', 'user_id', 'email'], + }, + 'authentication.oauth_failed': { + type: 'oauth', + status: 'failed', + required: ['type', 'status', 'ip_address', 'user_agent', 'user_id', 'email', 'error'], + }, + 'authentication.oauth_succeeded': { + type: 'oauth', + status: 'succeeded', + required: ['type', 'status', 'ip_address', 'user_agent', 'user_id', 'email'], + }, + 'authentication.passkey_failed': { + type: 'passkey', + status: 'failed', + required: ['type', 'status', 'ip_address', 'user_agent', 'user_id', 'email', 'error'], + }, + 'authentication.passkey_succeeded': { + type: 'passkey', + status: 'succeeded', + required: ['type', 'status', 'ip_address', 'user_agent', 'user_id', 'email'], + }, + 'authentication.password_failed': { + type: 'password', + status: 'failed', + required: ['type', 'status', 'ip_address', 'user_agent', 'user_id', 'email', 'error'], + }, + 'authentication.password_succeeded': { + type: 'password', + status: 'succeeded', + required: ['type', 'status', 'ip_address', 'user_agent', 'user_id', 'email'], + }, + 'authentication.radar_risk_detected': { + required: ['auth_method', 'action', 'control', 'blocklist_type', 'ip_address', 'user_agent', 'user_id', 'email'], + }, + 'authentication.sso_failed': { + type: 'sso', + status: 'failed', + required: ['type', 'status', 'ip_address', 'user_agent', 'user_id', 'email', 'sso', 'error'], + }, + 'authentication.sso_started': { + type: 'sso', + status: 'started', + required: ['type', 'status', 'ip_address', 'user_agent', 'user_id', 'email', 'sso'], + }, + 'authentication.sso_succeeded': { + type: 'sso', + status: 'succeeded', + required: ['type', 'status', 'ip_address', 'user_agent', 'user_id', 'email', 'sso'], + }, + 'authentication.sso_timed_out': { + type: 'sso', + status: 'timed_out', + required: ['type', 'status', 'ip_address', 'user_agent', 'user_id', 'email', 'sso', 'error'], + }, + 'connection.activated': { + required: [ + 'object', + 'id', + 'state', + 'name', + 'connection_type', + 'created_at', + 'updated_at', + 'external_key', + 'status', + 'domains', + ], + }, + 'connection.deactivated': { + required: [ + 'object', + 'id', + 'state', + 'name', + 'connection_type', + 'created_at', + 'updated_at', + 'external_key', + 'status', + 'domains', + ], + }, + 'connection.deleted': { + required: ['object', 'id', 'state', 'name', 'connection_type', 'created_at', 'updated_at'], + }, + 'connection.saml_certificate_renewal_required': { required: ['connection', 'certificate', 'days_until_expiry'] }, + 'connection.saml_certificate_renewed': { required: ['connection', 'certificate', 'renewed_at'] }, + 'dsync.activated': { + required: ['object', 'id', 'type', 'state', 'name', 'created_at', 'updated_at', 'external_key', 'domains'], + }, + 'dsync.deleted': { required: ['object', 'id', 'type', 'state', 'name', 'created_at', 'updated_at'] }, + 'dsync.group.created': { + required: ['object', 'id', 'idp_id', 'directory_id', 'organization_id', 'name', 'created_at', 'updated_at'], + }, + 'dsync.group.deleted': { + required: ['object', 'id', 'idp_id', 'directory_id', 'organization_id', 'name', 'created_at', 'updated_at'], + }, + 'dsync.group.updated': { + required: ['object', 'id', 'idp_id', 'directory_id', 'organization_id', 'name', 'created_at', 'updated_at'], + }, + 'dsync.group.user_added': { required: ['directory_id', 'user', 'group'] }, + 'dsync.group.user_removed': { required: ['directory_id', 'user', 'group'] }, + 'dsync.token.created': { required: ['object', 'id', 'directory_id', 'token_suffix', 'created_at'] }, + 'dsync.token.revoked': { required: ['object', 'id', 'directory_id', 'token_suffix', 'created_at'] }, + 'dsync.user.created': { + required: [ + 'object', + 'id', + 'directory_id', + 'organization_id', + 'idp_id', + 'email', + 'state', + 'raw_attributes', + 'custom_attributes', + 'created_at', + 'updated_at', + ], + }, + 'dsync.user.deleted': { + required: [ + 'object', + 'id', + 'directory_id', + 'organization_id', + 'idp_id', + 'email', + 'state', + 'raw_attributes', + 'custom_attributes', + 'created_at', + 'updated_at', + ], + }, + 'dsync.user.updated': { + required: [ + 'object', + 'id', + 'directory_id', + 'organization_id', + 'idp_id', + 'email', + 'state', + 'raw_attributes', + 'custom_attributes', + 'created_at', + 'updated_at', + ], + }, + 'email_verification.created': { + required: ['object', 'id', 'user_id', 'email', 'expires_at', 'created_at', 'updated_at'], + }, + 'flag.created': { + required: [ + 'object', + 'id', + 'environment_id', + 'slug', + 'name', + 'description', + 'owner', + 'tags', + 'enabled', + 'default_value', + 'created_at', + 'updated_at', + ], + }, + 'flag.deleted': { + required: [ + 'object', + 'id', + 'environment_id', + 'slug', + 'name', + 'description', + 'owner', + 'tags', + 'enabled', + 'default_value', + 'created_at', + 'updated_at', + ], + }, + 'flag.rule_updated': { + required: [ + 'object', + 'id', + 'environment_id', + 'slug', + 'name', + 'description', + 'owner', + 'tags', + 'enabled', + 'default_value', + 'created_at', + 'updated_at', + ], + }, + 'flag.updated': { + required: [ + 'object', + 'id', + 'environment_id', + 'slug', + 'name', + 'description', + 'owner', + 'tags', + 'enabled', + 'default_value', + 'created_at', + 'updated_at', + ], + }, + 'group.created': { + required: ['object', 'id', 'organization_id', 'name', 'description', 'created_at', 'updated_at'], + }, + 'group.deleted': { + required: ['object', 'id', 'organization_id', 'name', 'description', 'created_at', 'updated_at'], + }, + 'group.member_added': { required: ['group_id', 'organization_membership_id'] }, + 'group.member_removed': { required: ['group_id', 'organization_membership_id'] }, + 'group.updated': { + required: ['object', 'id', 'organization_id', 'name', 'description', 'created_at', 'updated_at'], + }, + 'invitation.accepted': { + required: [ + 'object', + 'id', + 'email', + 'state', + 'accepted_at', + 'revoked_at', + 'expires_at', + 'organization_id', + 'inviter_user_id', + 'accepted_user_id', + 'role_slug', + 'created_at', + 'updated_at', + ], + }, + 'invitation.created': { + required: [ + 'object', + 'id', + 'email', + 'state', + 'accepted_at', + 'revoked_at', + 'expires_at', + 'organization_id', + 'inviter_user_id', + 'accepted_user_id', + 'role_slug', + 'created_at', + 'updated_at', + ], + }, + 'invitation.resent': { + required: [ + 'object', + 'id', + 'email', + 'state', + 'accepted_at', + 'revoked_at', + 'expires_at', + 'organization_id', + 'inviter_user_id', + 'accepted_user_id', + 'role_slug', + 'created_at', + 'updated_at', + ], + }, + 'invitation.revoked': { + required: [ + 'object', + 'id', + 'email', + 'state', + 'accepted_at', + 'revoked_at', + 'expires_at', + 'organization_id', + 'inviter_user_id', + 'accepted_user_id', + 'role_slug', + 'created_at', + 'updated_at', + ], + }, + 'magic_auth.created': { required: ['object', 'id', 'user_id', 'email', 'expires_at', 'created_at', 'updated_at'] }, + 'organization_domain.created': { + required: ['object', 'id', 'organization_id', 'domain', 'created_at', 'updated_at'], + }, + 'organization_domain.deleted': { + required: ['object', 'id', 'organization_id', 'domain', 'created_at', 'updated_at'], + }, + 'organization_domain.updated': { + required: ['object', 'id', 'organization_id', 'domain', 'created_at', 'updated_at'], + }, + 'organization_domain.verification_failed': { required: ['reason', 'organization_domain'] }, + 'organization_domain.verified': { + required: ['object', 'id', 'organization_id', 'domain', 'created_at', 'updated_at'], + }, + 'organization_membership.created': { + required: [ + 'object', + 'id', + 'user_id', + 'organization_id', + 'status', + 'role', + 'custom_attributes', + 'directory_managed', + 'created_at', + 'updated_at', + ], + }, + 'organization_membership.deleted': { + required: [ + 'object', + 'id', + 'user_id', + 'organization_id', + 'status', + 'role', + 'custom_attributes', + 'directory_managed', + 'created_at', + 'updated_at', + ], + }, + 'organization_membership.updated': { + required: [ + 'object', + 'id', + 'user_id', + 'organization_id', + 'status', + 'role', + 'custom_attributes', + 'directory_managed', + 'created_at', + 'updated_at', + ], + }, + 'organization_role.created': { + required: [ + 'object', + 'organization_id', + 'slug', + 'name', + 'description', + 'resource_type_slug', + 'permissions', + 'created_at', + 'updated_at', + ], + }, + 'organization_role.deleted': { + required: [ + 'object', + 'organization_id', + 'slug', + 'name', + 'description', + 'resource_type_slug', + 'permissions', + 'created_at', + 'updated_at', + ], + }, + 'organization_role.updated': { + required: [ + 'object', + 'organization_id', + 'slug', + 'name', + 'description', + 'resource_type_slug', + 'permissions', + 'created_at', + 'updated_at', + ], + }, + 'organization.created': { + required: ['object', 'id', 'name', 'domains', 'metadata', 'external_id', 'created_at', 'updated_at'], + }, + 'organization.deleted': { + required: ['object', 'id', 'name', 'domains', 'metadata', 'external_id', 'created_at', 'updated_at'], + }, + 'organization.updated': { + required: ['object', 'id', 'name', 'domains', 'metadata', 'external_id', 'created_at', 'updated_at'], + }, + 'password_reset.created': { required: ['object', 'id', 'user_id', 'email', 'expires_at', 'created_at'] }, + 'password_reset.succeeded': { required: ['object', 'id', 'user_id', 'email', 'expires_at', 'created_at'] }, + 'permission.created': { + required: ['object', 'id', 'slug', 'name', 'description', 'system', 'created_at', 'updated_at'], + }, + 'permission.deleted': { + required: ['object', 'id', 'slug', 'name', 'description', 'system', 'created_at', 'updated_at'], + }, + 'permission.updated': { + required: ['object', 'id', 'slug', 'name', 'description', 'system', 'created_at', 'updated_at'], + }, + 'pipes.connected_account.connected': { + required: [ + 'object', + 'id', + 'data_integration_id', + 'provider_slug', + 'user_id', + 'organization_id', + 'scopes', + 'state', + 'created_at', + 'updated_at', + ], + }, + 'pipes.connected_account.disconnected': { + required: [ + 'object', + 'id', + 'data_integration_id', + 'provider_slug', + 'user_id', + 'organization_id', + 'scopes', + 'state', + 'created_at', + 'updated_at', + ], + }, + 'pipes.connected_account.reauthorization_needed': { + required: [ + 'object', + 'id', + 'data_integration_id', + 'provider_slug', + 'user_id', + 'organization_id', + 'scopes', + 'state', + 'created_at', + 'updated_at', + ], + }, + 'role.created': { required: ['object', 'slug', 'resource_type_slug', 'created_at', 'updated_at'] }, + 'role.deleted': { required: ['object', 'slug', 'resource_type_slug', 'created_at', 'updated_at'] }, + 'role.updated': { required: ['object', 'slug', 'resource_type_slug', 'created_at', 'updated_at'] }, + 'session.created': { + required: [ + 'object', + 'id', + 'ip_address', + 'user_agent', + 'user_id', + 'auth_method', + 'status', + 'expires_at', + 'ended_at', + 'created_at', + 'updated_at', + ], + }, + 'session.revoked': { + required: [ + 'object', + 'id', + 'ip_address', + 'user_agent', + 'user_id', + 'auth_method', + 'status', + 'expires_at', + 'ended_at', + 'created_at', + 'updated_at', + ], + }, + 'user.created': { + required: [ + 'object', + 'id', + 'first_name', + 'last_name', + 'profile_picture_url', + 'email', + 'email_verified', + 'external_id', + 'last_sign_in_at', + 'created_at', + 'updated_at', + ], + }, + 'user.deleted': { + required: [ + 'object', + 'id', + 'first_name', + 'last_name', + 'profile_picture_url', + 'email', + 'email_verified', + 'external_id', + 'last_sign_in_at', + 'created_at', + 'updated_at', + ], + }, + 'user.updated': { + required: [ + 'object', + 'id', + 'first_name', + 'last_name', + 'profile_picture_url', + 'email', + 'email_verified', + 'external_id', + 'last_sign_in_at', + 'created_at', + 'updated_at', + ], + }, + 'vault.byok_key.deleted': { required: ['organization_id', 'key_provider'] }, + 'vault.byok_key.verification_completed': { required: ['organization_id', 'key_provider', 'verified'] }, + 'vault.data.created': { required: ['actor_id', 'actor_source', 'actor_name', 'kv_name', 'key_id', 'key_context'] }, + 'vault.data.deleted': { required: ['actor_id', 'actor_source', 'actor_name', 'kv_name'] }, + 'vault.data.read': { required: ['actor_id', 'actor_source', 'actor_name', 'kv_name', 'key_id'] }, + 'vault.data.updated': { required: ['actor_id', 'actor_source', 'actor_name', 'kv_name', 'key_id', 'key_context'] }, + 'vault.dek.decrypted': { required: ['actor_id', 'actor_source', 'actor_name', 'key_id'] }, + 'vault.dek.read': { required: ['actor_id', 'actor_source', 'actor_name', 'key_ids', 'key_context'] }, + 'vault.kek.created': { required: ['actor_id', 'actor_source', 'actor_name', 'key_name', 'key_id'] }, + 'vault.metadata.read': { required: ['actor_id', 'actor_source', 'actor_name', 'kv_name'] }, + 'vault.names.listed': { required: ['actor_id', 'actor_source', 'actor_name'] }, + 'waitlist_user.approved': { + required: ['object', 'id', 'email', 'state', 'approved_at', 'created_at', 'updated_at'], + }, + 'waitlist_user.created': { + required: ['object', 'id', 'email', 'state', 'approved_at', 'created_at', 'updated_at'], + }, + 'waitlist_user.denied': { required: ['object', 'id', 'email', 'state', 'approved_at', 'created_at', 'updated_at'] }, + }; diff --git a/src/workos/helpers.ts b/src/workos/helpers.ts index 5b665c0..1b50ebf 100644 --- a/src/workos/helpers.ts +++ b/src/workos/helpers.ts @@ -1,5 +1,6 @@ import { randomBytes, createHash, createCipheriv } from 'node:crypto'; import { WorkOSApiError, type CursorPaginatedResult, type Entity } from '../core/index.js'; +import { EVENTS, type AuthenticationEventData, type WorkOSEventName } from './constants.js'; import type { WorkOSStore } from './store.js'; import type { WorkOSOrganization, @@ -102,6 +103,71 @@ export function formatSession(s: WorkOSSession): Record { return formatEntity(s); } +/** Maps the emulator's PascalCase authentication_method values to the spec's snake_case event `type`. */ +export const AUTH_METHOD_EVENT_TYPES: Record = { + OAuth: 'oauth', + Password: 'password', + MagicAuth: 'magic_auth', + EmailVerification: 'email_verification', + MFA: 'mfa', + SSO: 'sso', +}; + +/** + * Maps authentication_method values to the session `auth_method` enum (note: magic_code, not magic_auth). + * + * The spec's session auth_method enum (cross_app_auth, external_auth, impersonation, magic_code, + * migrated_session, oauth, passkey, password, sso, unknown) has no value for MFA or email + * verification. An MFA completion normally records its *primary* factor instead (e.g. 'password'), + * resolved from the pending-auth token via sessionAuthMethod in the authenticate handler. The MFA + * and EmailVerification entries here are fallbacks: when no primary method is known they resolve to + * 'unknown' — a valid enum member, so consumers that validate the field still pass. + */ +export const AUTH_METHOD_SESSION_VALUES: Record = { + OAuth: 'oauth', + Password: 'password', + MagicAuth: 'magic_code', + SSO: 'sso', + MFA: 'unknown', + EmailVerification: 'unknown', +}; + +/** authentication.* event names per method, resolved from the spec-generated catalog. */ +export const AUTH_EVENTS: Record = { + OAuth: { succeeded: EVENTS.authenticationOauthSucceeded, failed: EVENTS.authenticationOauthFailed }, + Password: { succeeded: EVENTS.authenticationPasswordSucceeded, failed: EVENTS.authenticationPasswordFailed }, + MagicAuth: { succeeded: EVENTS.authenticationMagicAuthSucceeded, failed: EVENTS.authenticationMagicAuthFailed }, + EmailVerification: { + succeeded: EVENTS.authenticationEmailVerificationSucceeded, + failed: EVENTS.authenticationEmailVerificationFailed, + }, + MFA: { succeeded: EVENTS.authenticationMfaSucceeded, failed: EVENTS.authenticationMfaFailed }, + SSO: { succeeded: EVENTS.authenticationSsoSucceeded, failed: EVENTS.authenticationSsoFailed }, +}; + +export function buildAuthenticationEventData(opts: { + status: 'succeeded' | 'failed'; + method: string; + userId?: string | null; + email?: string | null; + ipAddress?: string | null; + userAgent?: string | null; + error?: { code: string; message: string }; + sso?: { organization_id: string | null; connection_id: string | null; session_id: string | null }; +}): Record { + const data: AuthenticationEventData = { + type: (AUTH_METHOD_EVENT_TYPES[opts.method] ?? opts.method.toLowerCase()) as AuthenticationEventData['type'], + status: opts.status, + user_id: opts.userId ?? null, + email: opts.email ?? null, + ip_address: opts.ipAddress ?? null, + user_agent: opts.userAgent ?? null, + ...(opts.error ? { error: opts.error } : {}), + ...(opts.sso ? { sso: opts.sso } : {}), + }; + return { ...data }; +} + export function formatEmailVerification(ev: WorkOSEmailVerification): Record { return formatEntity(ev); } diff --git a/src/workos/index.ts b/src/workos/index.ts index a5541c3..3dcebf5 100644 --- a/src/workos/index.ts +++ b/src/workos/index.ts @@ -1,7 +1,7 @@ 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 { getWorkOSStore } from './store.js'; import { organizationRoutes } from './routes/organizations.js'; import { organizationDomainRoutes } from './routes/organization-domains.js'; import { membershipRoutes } from './routes/memberships.js'; @@ -54,6 +54,11 @@ import { formatDirectoryUser, formatDirectoryGroup, formatDomain, + formatEmailVerification, + formatMagicAuth, + formatPasswordReset, + formatApiKeyRecord, + formatFeatureFlag, } from './helpers.js'; import type { WorkOSConnectionType, PipeProvider, PipeConnectionStatus } from './entities.js'; @@ -402,8 +407,18 @@ export const workosPlugin: ServicePlugin = { onDelete: (m) => eventBus.emit({ event: EVENTS.organizationMembershipDeleted, data: formatMembership(m) }), }); ws.connections.setHooks({ - onInsert: (c) => eventBus.emit({ event: EVENTS.connectionCreated, data: formatConnection(c) }), - onUpdate: (c) => eventBus.emit({ event: EVENTS.connectionUpdated, data: formatConnection(c) }), + // The spec has no connection.created/updated — only activation state transitions + onInsert: (c) => { + if (c.state === 'active') eventBus.emit({ event: EVENTS.connectionActivated, data: formatConnection(c) }); + }, + onUpdate: (c, prev) => { + if (c.state === prev.state) return; + if (c.state === 'active') { + eventBus.emit({ event: EVENTS.connectionActivated, data: formatConnection(c) }); + } else if (c.state === 'inactive') { + eventBus.emit({ event: EVENTS.connectionDeactivated, data: formatConnection(c) }); + } + }, onDelete: (c) => eventBus.emit({ event: EVENTS.connectionDeleted, data: formatConnection(c) }), }); ws.sessions.setHooks({ @@ -413,10 +428,34 @@ export const workosPlugin: ServicePlugin = { ws.invitations.setHooks({ onInsert: (i) => eventBus.emit({ event: EVENTS.invitationCreated, data: formatInvitation(i) }), }); + // Lifecycle resources created during login flows. No delete hooks: codes are + // deleted when consumed, and the spec has no events for that. + ws.emailVerifications.setHooks({ + onInsert: (ev) => eventBus.emit({ event: EVENTS.emailVerificationCreated, data: formatEmailVerification(ev) }), + }); + ws.magicAuths.setHooks({ + onInsert: (ma) => eventBus.emit({ event: EVENTS.magicAuthCreated, data: formatMagicAuth(ma) }), + }); + ws.passwordResets.setHooks({ + onInsert: (pr) => eventBus.emit({ event: EVENTS.passwordResetCreated, data: formatPasswordReset(pr) }), + }); + // Organization-scoped roles share the roles collection but have their own spec events ws.roles.setHooks({ - onInsert: (r) => eventBus.emit({ event: EVENTS.roleCreated, data: formatRole(r) }), - onUpdate: (r) => eventBus.emit({ event: EVENTS.roleUpdated, data: formatRole(r) }), - onDelete: (r) => eventBus.emit({ event: EVENTS.roleDeleted, data: formatRole(r) }), + onInsert: (r) => + eventBus.emit({ + event: r.type === 'OrganizationRole' ? EVENTS.organizationRoleCreated : EVENTS.roleCreated, + data: formatRole(r), + }), + onUpdate: (r) => + eventBus.emit({ + event: r.type === 'OrganizationRole' ? EVENTS.organizationRoleUpdated : EVENTS.roleUpdated, + data: formatRole(r), + }), + onDelete: (r) => + eventBus.emit({ + event: r.type === 'OrganizationRole' ? EVENTS.organizationRoleDeleted : EVENTS.roleDeleted, + data: formatRole(r), + }), }); ws.permissions.setHooks({ onInsert: (p) => eventBus.emit({ event: EVENTS.permissionCreated, data: formatPermission(p) }), @@ -424,19 +463,29 @@ export const workosPlugin: ServicePlugin = { onDelete: (p) => eventBus.emit({ event: EVENTS.permissionDeleted, data: formatPermission(p) }), }); ws.directories.setHooks({ - onInsert: (d) => eventBus.emit({ event: EVENTS.directoryCreated, data: formatDirectory(d) }), - onUpdate: (d) => eventBus.emit({ event: EVENTS.directoryUpdated, data: formatDirectory(d) }), - onDelete: (d) => eventBus.emit({ event: EVENTS.directoryDeleted, data: formatDirectory(d) }), + // The spec has no dsync.updated — only activation and deletion + onInsert: (d) => eventBus.emit({ event: EVENTS.dsyncActivated, data: formatDirectory(d) }), + onDelete: (d) => eventBus.emit({ event: EVENTS.dsyncDeleted, data: formatDirectory(d) }), }); ws.directoryUsers.setHooks({ - onInsert: (u) => eventBus.emit({ event: EVENTS.directoryUserCreated, data: formatDirectoryUser(u) }), - onUpdate: (u) => eventBus.emit({ event: EVENTS.directoryUserUpdated, data: formatDirectoryUser(u) }), - onDelete: (u) => eventBus.emit({ event: EVENTS.directoryUserDeleted, data: formatDirectoryUser(u) }), + onInsert: (u) => eventBus.emit({ event: EVENTS.dsyncUserCreated, data: formatDirectoryUser(u) }), + onUpdate: (u) => eventBus.emit({ event: EVENTS.dsyncUserUpdated, data: formatDirectoryUser(u) }), + onDelete: (u) => eventBus.emit({ event: EVENTS.dsyncUserDeleted, data: formatDirectoryUser(u) }), }); ws.directoryGroups.setHooks({ - onInsert: (g) => eventBus.emit({ event: EVENTS.directoryGroupCreated, data: formatDirectoryGroup(g) }), - onUpdate: (g) => eventBus.emit({ event: EVENTS.directoryGroupUpdated, data: formatDirectoryGroup(g) }), - onDelete: (g) => eventBus.emit({ event: EVENTS.directoryGroupDeleted, data: formatDirectoryGroup(g) }), + onInsert: (g) => eventBus.emit({ event: EVENTS.dsyncGroupCreated, data: formatDirectoryGroup(g) }), + onUpdate: (g) => eventBus.emit({ event: EVENTS.dsyncGroupUpdated, data: formatDirectoryGroup(g) }), + onDelete: (g) => eventBus.emit({ event: EVENTS.dsyncGroupDeleted, data: formatDirectoryGroup(g) }), + }); + ws.apiKeyRecords.setHooks({ + onInsert: (k) => eventBus.emit({ event: EVENTS.apiKeyCreated, data: formatApiKeyRecord(k) }), + onUpdate: (k) => eventBus.emit({ event: EVENTS.apiKeyUpdated, data: formatApiKeyRecord(k) }), + onDelete: (k) => eventBus.emit({ event: EVENTS.apiKeyRevoked, data: formatApiKeyRecord(k) }), + }); + ws.featureFlags.setHooks({ + onInsert: (f) => eventBus.emit({ event: EVENTS.flagCreated, data: formatFeatureFlag(f) }), + onUpdate: (f) => eventBus.emit({ event: EVENTS.flagUpdated, data: formatFeatureFlag(f) }), + onDelete: (f) => eventBus.emit({ event: EVENTS.flagDeleted, data: formatFeatureFlag(f) }), }); ws.webhookEndpoints.setHooks({ onInsert: () => eventBus.rebuildIndex(), diff --git a/src/workos/routes/auth.spec.ts b/src/workos/routes/auth.spec.ts index 3c050f8..0c32aea 100644 --- a/src/workos/routes/auth.spec.ts +++ b/src/workos/routes/auth.spec.ts @@ -488,7 +488,6 @@ describe('AuthKit interactive auth', () => { store.setData(STORE_KEYS.interactiveAuth, true); }); - const req = (path: string, init?: RequestInit) => app.request(path, { headers, ...init }); const json = (res: Response) => res.json() as Promise; it('GET /user_management/authorize returns HTML login page', async () => { @@ -508,9 +507,7 @@ describe('AuthKit interactive auth', () => { impersonator: null, }); - const res = await app.request( - '/user_management/authorize?redirect_uri=http://localhost:3000/callback&state=abc', - ); + const res = await app.request('/user_management/authorize?redirect_uri=http://localhost:3000/callback&state=abc'); expect(res.status).toBe(200); const html = await res.text(); expect(html).toContain('Sign In'); @@ -602,3 +599,316 @@ describe('AuthKit interactive auth', () => { expect(body.access_token).toBeDefined(); }); }); + +describe('authentication events (spec-named, spec-shaped)', () => { + 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; + + const eventsNamed = (name: string) => + getWorkOSStore(store) + .events.all() + .filter((e) => e.event === name); + + async function registerUser(email: string, password: string) { + const res = await req('/user_management/users', { + method: 'POST', + body: JSON.stringify({ email, password }), + }); + return json(res); + } + + it('emits authentication.password_succeeded with the spec payload', async () => { + const user = await registerUser('evt-pass@test.com', 'secret'); + + await app.request('/user_management/authenticate', { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'User-Agent': 'spec-agent' }, + body: JSON.stringify({ grant_type: 'password', email: 'evt-pass@test.com', password: 'secret' }), + }); + + const [event] = eventsNamed('authentication.password_succeeded'); + expect(event).toBeDefined(); + expect(event.data).toMatchObject({ + type: 'password', + status: 'succeeded', + user_id: user.id, + email: 'evt-pass@test.com', + user_agent: 'spec-agent', + }); + expect(event.data).toHaveProperty('ip_address'); + }); + + it('emits authentication.password_failed with a required error object', async () => { + await registerUser('evt-fail@test.com', 'secret'); + + const res = await app.request('/user_management/authenticate', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ grant_type: 'password', email: 'evt-fail@test.com', password: 'wrong' }), + }); + expect(res.status).toBe(401); + + const [event] = eventsNamed('authentication.password_failed'); + expect(event).toBeDefined(); + expect(event.data).toMatchObject({ + type: 'password', + status: 'failed', + email: 'evt-fail@test.com', + error: { code: 'invalid_credentials', message: 'Invalid credentials' }, + }); + }); + + it('emits authentication.oauth_succeeded for the authorization code flow', async () => { + await registerUser('evt-oauth@test.com', 'secret'); + + const authRes = await app.request( + '/user_management/authorize?redirect_uri=http://localhost:3000/callback&response_type=code', + ); + const code = new URL(authRes.headers.get('location')!).searchParams.get('code')!; + + await app.request('/user_management/authenticate', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ grant_type: 'authorization_code', code }), + }); + + const [event] = eventsNamed('authentication.oauth_succeeded'); + expect(event).toBeDefined(); + expect(event.data).toMatchObject({ type: 'oauth', status: 'succeeded' }); + }); + + it('emits authentication.oauth_failed for an invalid code', async () => { + const res = await app.request('/user_management/authenticate', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ grant_type: 'authorization_code', code: 'bogus' }), + }); + expect(res.status).toBe(400); + + const [event] = eventsNamed('authentication.oauth_failed'); + expect(event).toBeDefined(); + expect(event.data).toMatchObject({ + type: 'oauth', + status: 'failed', + error: { code: 'invalid_code', message: 'Invalid code' }, + }); + }); + + it('emits magic_auth.created on code request and magic_auth_succeeded on exchange', async () => { + const user = await registerUser('evt-magic@test.com', 'secret'); + + await req('/user_management/magic_auth', { + method: 'POST', + body: JSON.stringify({ email: 'evt-magic@test.com' }), + }); + + const [created] = eventsNamed('magic_auth.created'); + expect(created).toBeDefined(); + expect(created.data).toMatchObject({ user_id: user.id, email: 'evt-magic@test.com' }); + const code = created.data.code as string; + + 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, + email: 'evt-magic@test.com', + }), + }); + + const [succeeded] = eventsNamed('authentication.magic_auth_succeeded'); + expect(succeeded).toBeDefined(); + expect(succeeded.data).toMatchObject({ type: 'magic_auth', status: 'succeeded', user_id: user.id }); + }); + + it('emits email_verification.created and email_verification_succeeded', async () => { + const user = await registerUser('evt-verify@test.com', 'secret'); + + const sendRes = await req(`/user_management/users/${user.id}/email_verification/send`, { method: 'POST' }); + const verification = await json(sendRes); + + const [created] = eventsNamed('email_verification.created'); + expect(created).toBeDefined(); + expect(created.data).toMatchObject({ user_id: user.id, email: 'evt-verify@test.com' }); + + await app.request('/user_management/authenticate', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + grant_type: 'urn:workos:oauth:grant-type:email-verification:code', + code: verification.code, + user_id: user.id, + }), + }); + + const [succeeded] = eventsNamed('authentication.email_verification_succeeded'); + expect(succeeded).toBeDefined(); + expect(succeeded.data).toMatchObject({ type: 'email_verification', status: 'succeeded', user_id: user.id }); + }); + + it('creates sessions with spec-required fields (auth_method, status, expires_at)', async () => { + await registerUser('evt-session@test.com', 'secret'); + + await app.request('/user_management/authenticate', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ grant_type: 'password', email: 'evt-session@test.com', password: 'secret' }), + }); + + const [event] = eventsNamed('session.created'); + expect(event).toBeDefined(); + expect(event.data).toMatchObject({ auth_method: 'password', status: 'active', ended_at: null }); + expect(event.data.expires_at).toBeTruthy(); + }); + + it('MFA session falls back to auth_method: unknown when the pending token records no mapped primary', async () => { + const user = await registerUser('evt-mfa@test.com', 'secret'); + 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_evt_mfa'; + store.setData(`pending_auth:${pendingToken}`, { user_id: user.id, organization_id: null, auth_method: 'MFA' }); + + 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, + }), + }); + + // The pending token here records only 'MFA' (not a primary factor), so the session falls + // back to the valid 'unknown' rather than an out-of-enum value like 'mfa'. + const [session] = eventsNamed('session.created'); + expect(session).toBeDefined(); + expect(session.data).toMatchObject({ auth_method: 'unknown' }); + }); + + it('email-verification sessions report auth_method: unknown (no spec enum value)', async () => { + const user = await registerUser('evt-verify-session@test.com', 'secret'); + + const sendRes = await req(`/user_management/users/${user.id}/email_verification/send`, { method: 'POST' }); + const verification = await json(sendRes); + + await app.request('/user_management/authenticate', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + grant_type: 'urn:workos:oauth:grant-type:email-verification:code', + code: verification.code, + user_id: user.id, + }), + }); + + const [session] = eventsNamed('session.created'); + expect(session).toBeDefined(); + expect(session.data).toMatchObject({ auth_method: 'unknown' }); + }); + + it('token refresh rotates tokens without emitting login or session events', async () => { + await registerUser('evt-refresh@test.com', 'secret'); + + const loginRes = await app.request('/user_management/authenticate', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ grant_type: 'password', email: 'evt-refresh@test.com', password: 'secret' }), + }); + const { refresh_token } = await json(loginRes); + + const authEventsAfterLogin = getWorkOSStore(store) + .events.all() + .filter((e) => e.event.startsWith('authentication.')).length; + + const refreshRes = await app.request('/user_management/authenticate', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ grant_type: 'refresh_token', refresh_token }), + }); + expect(refreshRes.status).toBe(200); + // Rotation still hands back fresh tokens. + expect((await json(refreshRes)).refresh_token).toBeTruthy(); + + // A rotation is not a fresh login, so it must add no authentication.* event... + const authEventsAfterRefresh = getWorkOSStore(store) + .events.all() + .filter((e) => e.event.startsWith('authentication.')).length; + expect(authEventsAfterRefresh).toBe(authEventsAfterLogin); + // ...no spurious oauth_succeeded, which the OAuth authMethod would otherwise fire... + expect(eventsNamed('authentication.oauth_succeeded')).toHaveLength(0); + // ...and it reuses the existing session rather than minting a new one. + expect(eventsNamed('session.created')).toHaveLength(1); + expect(getWorkOSStore(store).sessions.all()).toHaveLength(1); + }); + + it('password login for an MFA-enrolled user challenges, then keys the session to the primary factor', async () => { + const user = await registerUser('evt-mfa-flow@test.com', 'secret'); + const ws = getWorkOSStore(store); + ws.authFactors.insert({ + object: 'authentication_factor', + user_id: user.id, + type: 'totp', + totp: { issuer: 'Test', user: user.email, uri: 'otpauth://...' }, + }); + + // First factor: password returns an mfa_challenge carrying a pending token + challenge, + // and creates neither a session nor a login event. + const challengeRes = await app.request('/user_management/authenticate', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ grant_type: 'password', email: 'evt-mfa-flow@test.com', password: 'secret' }), + }); + expect(challengeRes.status).toBe(403); + const challengeBody = await json(challengeRes); + expect(challengeBody.code).toBe('mfa_challenge'); + expect(challengeBody.pending_authentication_token).toBeTruthy(); + expect(challengeBody.authentication_challenge.id).toBeTruthy(); + expect(eventsNamed('session.created')).toHaveLength(0); + expect(eventsNamed('authentication.password_succeeded')).toHaveLength(0); + + // Second factor: completing mfa-totp issues the session (code read from the store, since + // the spec excludes it from the challenge response). + const challengeId = challengeBody.authentication_challenge.id as string; + const code = ws.authChallenges.get(challengeId)!.code!; + const mfaRes = 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, + pending_authentication_token: challengeBody.pending_authentication_token, + authentication_challenge_id: challengeId, + }), + }); + expect(mfaRes.status).toBe(200); + + // The event is mfa_succeeded, but the session records the primary factor (password). + expect(eventsNamed('authentication.mfa_succeeded')).toHaveLength(1); + const [session] = eventsNamed('session.created'); + expect(session.data).toMatchObject({ auth_method: 'password' }); + }); +}); diff --git a/src/workos/routes/auth.ts b/src/workos/routes/auth.ts index 3c23cb5..0b72a27 100644 --- a/src/workos/routes/auth.ts +++ b/src/workos/routes/auth.ts @@ -9,6 +9,11 @@ import { expiresIn, assertLocalRedirectUri, sealSession, + AUTH_EVENTS, + AUTH_METHOD_SESSION_VALUES, + buildAuthenticationEventData, + generateCode, + formatAuthChallenge, } from '../helpers.js'; import type { EventBus } from '../event-bus.js'; import { STORE_KEYS, STORE_KEY_PREFIXES } from '../constants.js'; @@ -158,9 +163,83 @@ export function authRoutes(ctx: RouteContext): void { throw new WorkOSApiError(400, 'grant_type is required', 'invalid_request'); } + const requestIp = c.req.header('x-forwarded-for') ?? null; + const requestUserAgent = c.req.header('user-agent') ?? null; + + /** Emit the spec's authentication.*_failed event for a credential failure, then throw. */ + const failAuth: ( + method: string, + info: { email?: string | null; userId?: string | null }, + error: WorkOSApiError, + ) => never = (method, info, error) => { + const eventBus = store.getData(STORE_KEYS.eventBus); + const failedEvent = AUTH_EVENTS[method]?.failed; + if (eventBus && failedEvent) { + eventBus.emit({ + event: failedEvent, + data: buildAuthenticationEventData({ + status: 'failed', + method, + userId: info.userId, + email: info.email, + ipAddress: requestIp, + userAgent: requestUserAgent, + error: { code: error.code, message: error.message }, + }), + }); + } + throw error; + }; + + /** + * Initiate the MFA second factor. Records the primary method on a pending-auth token so + * the eventual session reports it (not 'unknown'), creates a challenge for the factor, and + * returns the spec's `mfa_challenge` code plus the fields a client needs to complete the + * urn:workos:oauth:grant-type:mfa-totp grant. (The spec documents the mfa_challenge code but + * not this response body; the pending_authentication_token/challenge fields mirror WorkOS.) + */ + const issueMfaChallenge = ( + mfaUser: { id: string }, + orgId: string | null, + primaryMethod: string, + factor: { id: string }, + ) => { + const pendingToken = generateId('pending'); + store.setData(`${STORE_KEY_PREFIXES.pendingAuth}${pendingToken}`, { + user_id: mfaUser.id, + organization_id: orgId, + auth_method: primaryMethod, + }); + const challenge = ws.authChallenges.insert({ + object: 'authentication_challenge', + user_id: mfaUser.id, + factor_id: factor.id, + expires_at: expiresIn(10), + code: generateCode(), + }); + return c.json( + { + code: 'mfa_challenge', + message: 'Multi-factor authentication is required to continue.', + pending_authentication_token: pendingToken, + authentication_challenge: formatAuthChallenge(challenge), + }, + 403, + ); + }; + let user; let organizationId: string | null = null; let authMethod: string; + // The session's auth_method can differ from the event method: an MFA completion emits + // authentication.mfa_succeeded but the session records the primary factor that was + // challenged (e.g. 'password'). Left undefined, the session falls back to authMethod. + let sessionAuthMethod: string | undefined; + // A token refresh rotates credentials for an existing session; it is not a fresh login, + // so it creates no new session and emits no authentication.*_succeeded event. Genuine + // authentications leave this true; refresh_token flips it off and sets refreshSessionId. + let isFreshLogin = true; + let refreshSessionId: string | null = null; switch (grantType) { case 'authorization_code': { @@ -168,9 +247,13 @@ export function authRoutes(ctx: RouteContext): void { if (!code) throw new WorkOSApiError(400, 'code is required', 'invalid_request'); const authCode = ws.authCodes.findOneBy('code', code); - if (!authCode) throw new WorkOSApiError(400, 'Invalid code', 'invalid_code'); + if (!authCode) failAuth('OAuth', {}, new WorkOSApiError(400, 'Invalid code', 'invalid_code')); if (isExpired(authCode.expires_at)) { - throw new WorkOSApiError(400, 'Code has expired', 'expired_code'); + failAuth( + 'OAuth', + { userId: authCode.user_id, email: ws.users.get(authCode.user_id)?.email }, + new WorkOSApiError(400, 'Code has expired', 'expired_code'), + ); } if (authCode.code_challenge) { @@ -186,7 +269,11 @@ export function authRoutes(ctx: RouteContext): void { challenge = codeVerifier; } if (challenge !== authCode.code_challenge) { - throw new WorkOSApiError(400, 'Invalid code_verifier', 'invalid_code_verifier'); + failAuth( + 'OAuth', + { userId: authCode.user_id, email: ws.users.get(authCode.user_id)?.email }, + new WorkOSApiError(400, 'Invalid code_verifier', 'invalid_code_verifier'), + ); } } @@ -206,9 +293,20 @@ export function authRoutes(ctx: RouteContext): void { user = ws.users.findOneBy('email', email); if (!user || !user.password_hash || !verifyPassword(password, user.password_hash)) { - throw new WorkOSApiError(401, 'Invalid credentials', 'invalid_credentials'); + failAuth( + 'Password', + { email, userId: user?.id }, + new WorkOSApiError(401, 'Invalid credentials', 'invalid_credentials'), + ); } authMethod = 'Password'; + + // A user with enrolled factors must clear a second factor before a session is issued: + // hand back a pending token (recording 'Password' as the primary method) and a challenge. + const passwordFactors = ws.authFactors.findBy('user_id', user.id); + if (passwordFactors.length > 0) { + return issueMfaChallenge(user, organizationId, 'Password', passwordFactors[0]); + } break; } @@ -223,10 +321,14 @@ export function authRoutes(ctx: RouteContext): void { const magicAuth = ws.magicAuths.all().find((ma) => ma.code === code && ma.email === email); if (!magicAuth) { - throw new WorkOSApiError(400, 'Invalid code', 'invalid_code'); + failAuth('MagicAuth', { email }, new WorkOSApiError(400, 'Invalid code', 'invalid_code')); } if (isExpired(magicAuth.expires_at)) { - throw new WorkOSApiError(400, 'Code has expired', 'expired_code'); + failAuth( + 'MagicAuth', + { email: magicAuth.email, userId: magicAuth.user_id }, + new WorkOSApiError(400, 'Code has expired', 'expired_code'), + ); } user = ws.users.get(magicAuth.user_id); @@ -246,10 +348,18 @@ export function authRoutes(ctx: RouteContext): void { const ev = ws.emailVerifications.findBy('user_id', userId).find((v) => v.code === code); if (!ev) { - throw new WorkOSApiError(400, 'Invalid code', 'invalid_code'); + failAuth( + 'EmailVerification', + { userId, email: ws.users.get(userId)?.email }, + new WorkOSApiError(400, 'Invalid code', 'invalid_code'), + ); } if (isExpired(ev.expires_at)) { - throw new WorkOSApiError(400, 'Code has expired', 'expired_code'); + failAuth( + 'EmailVerification', + { email: ev.email, userId: ev.user_id }, + new WorkOSApiError(400, 'Code has expired', 'expired_code'), + ); } ws.users.update(userId, { email_verified: true }); @@ -278,9 +388,12 @@ export function authRoutes(ctx: RouteContext): void { // Allow body.organization_id to switch org context (switchToOrganization) organizationId = (body.organization_id as string) ?? refreshToken.organization_id; - // Rotate: delete old, issue new below + // Rotate within the existing session: capture it for reuse, delete the old token, + // and issue a new one below — no new session, no authentication event. + refreshSessionId = refreshToken.session_id; ws.refreshTokens.delete(refreshToken.id); authMethod = 'OAuth'; + isFreshLogin = false; break; } @@ -308,12 +421,20 @@ export function authRoutes(ctx: RouteContext): void { } if (isExpired(challenge.expires_at)) { ws.authChallenges.delete(challenge.id); - throw new WorkOSApiError(400, 'Challenge has expired', 'expired_challenge'); + failAuth( + 'MFA', + { userId: pending.user_id, email: ws.users.get(pending.user_id)?.email }, + 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'); + failAuth( + 'MFA', + { userId: pending.user_id, email: ws.users.get(pending.user_id)?.email }, + new WorkOSApiError(400, 'Invalid one-time code', 'invalid_one_time_code'), + ); } ws.authChallenges.delete(challenge.id); @@ -321,7 +442,10 @@ export function authRoutes(ctx: RouteContext): void { user = ws.users.get(pending.user_id); organizationId = pending.organization_id; + // Event is authentication.mfa_succeeded; the session records the primary factor the + // pending token was issued for (MFA is a second factor, not a session auth method). authMethod = 'MFA'; + sessionAuthMethod = pending.auth_method; break; } @@ -383,17 +507,29 @@ export function authRoutes(ctx: RouteContext): void { if (!user) throw notFound('User'); - ws.users.update(user.id, { last_sign_in_at: new Date().toISOString() }); + // A fresh login creates a new session (firing session.created); a refresh_token rotation + // reuses the existing session, so it emits neither session.created nor an auth event. + let session; + if (isFreshLogin) { + ws.users.update(user.id, { last_sign_in_at: new Date().toISOString() }); + session = ws.sessions.insert({ + object: 'session', + user_id: user.id, + organization_id: organizationId, + ip_address: requestIp, + user_agent: requestUserAgent, + auth_method: AUTH_METHOD_SESSION_VALUES[sessionAuthMethod ?? authMethod] ?? 'unknown', + status: 'active', + expires_at: expiresIn(30 * 24 * 60), // matches refresh token lifetime + ended_at: null, + }); + } else { + const existing = refreshSessionId ? ws.sessions.get(refreshSessionId) : undefined; + if (!existing) throw new WorkOSApiError(400, 'Invalid refresh token', 'invalid_grant'); + session = existing; + } 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; @@ -449,11 +585,18 @@ export function authRoutes(ctx: RouteContext): void { // Emit authentication event (hybrid Option B for action-specific events) const eventBus = store.getData(STORE_KEYS.eventBus); - if (eventBus) { - const authEventType = `authentication.${authMethod.toLowerCase()}_succeeded`; + const succeededEvent = AUTH_EVENTS[authMethod]?.succeeded; + if (eventBus && succeededEvent && isFreshLogin) { eventBus.emit({ - event: authEventType, - data: { user_id: user.id, email: updatedUser.email, method: authMethod, ip_address: session.ip_address }, + event: succeededEvent, + data: buildAuthenticationEventData({ + status: 'succeeded', + method: authMethod, + userId: user.id, + email: updatedUser.email, + ipAddress: session.ip_address, + userAgent: session.user_agent, + }), }); } diff --git a/src/workos/routes/password-reset.spec.ts b/src/workos/routes/password-reset.spec.ts new file mode 100644 index 0000000..9dbbe62 --- /dev/null +++ b/src/workos/routes/password-reset.spec.ts @@ -0,0 +1,87 @@ +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_pwreset: { environment: 'test' } }; +const headers = { Authorization: 'Bearer sk_test_pwreset', 'Content-Type': 'application/json' }; + +function createTestApp() { + return createServer(workosPlugin, { port: 0, baseUrl: 'http://localhost:0', apiKeys }); +} + +describe('Password reset 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; + + const eventsNamed = (name: string) => + getWorkOSStore(store) + .events.all() + .filter((e) => e.event === name); + + async function createUserAndRequestReset() { + const user = await json( + await req('/user_management/users', { + method: 'POST', + body: JSON.stringify({ email: 'reset@test.com', password: 'oldpassword' }), + }), + ); + const reset = await json( + await req('/user_management/password_reset', { + method: 'POST', + body: JSON.stringify({ email: 'reset@test.com' }), + }), + ); + return { user, reset }; + } + + it('emits password_reset.created when a reset is requested', async () => { + const { user } = await createUserAndRequestReset(); + + const [event] = eventsNamed('password_reset.created'); + expect(event).toBeDefined(); + expect(event.data).toMatchObject({ user_id: user.id, email: 'reset@test.com' }); + }); + + it('emits password_reset.succeeded on confirm and the new password works', async () => { + const { reset } = await createUserAndRequestReset(); + + const confirmRes = await req('/user_management/password_reset/confirm', { + method: 'POST', + body: JSON.stringify({ token: reset.token, new_password: 'newpassword' }), + }); + expect(confirmRes.status).toBe(200); + + const [event] = eventsNamed('password_reset.succeeded'); + expect(event).toBeDefined(); + expect(event.data).toMatchObject({ email: 'reset@test.com' }); + + const authRes = await app.request('/user_management/authenticate', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ grant_type: 'password', email: 'reset@test.com', password: 'newpassword' }), + }); + expect(authRes.status).toBe(200); + }); + + it('rejects an invalid token without emitting password_reset.succeeded', async () => { + await createUserAndRequestReset(); + + const confirmRes = await req('/user_management/password_reset/confirm', { + method: 'POST', + body: JSON.stringify({ token: 'bogus', new_password: 'newpassword' }), + }); + expect(confirmRes.status).toBe(400); + expect(eventsNamed('password_reset.succeeded')).toHaveLength(0); + }); +}); diff --git a/src/workos/routes/password-reset.ts b/src/workos/routes/password-reset.ts index 4295b03..8bc7ec9 100644 --- a/src/workos/routes/password-reset.ts +++ b/src/workos/routes/password-reset.ts @@ -1,6 +1,8 @@ import { type RouteContext, notFound, parseJsonBody, WorkOSApiError } from '../../core/index.js'; import { getWorkOSStore } from '../store.js'; import { formatPasswordReset, generateVerificationToken, hashPassword, expiresIn, isExpired } from '../helpers.js'; +import { STORE_KEYS, EVENTS } from '../constants.js'; +import type { EventBus } from '../event-bus.js'; export function passwordResetRoutes(ctx: RouteContext): void { const { app, store } = ctx; @@ -65,6 +67,10 @@ export function passwordResetRoutes(ctx: RouteContext): void { }); ws.passwordResets.delete(pr.id); + // Manual emit: the spec event fires on confirmation, not on a collection insert + const eventBus = store.getData(STORE_KEYS.eventBus); + eventBus?.emit({ event: EVENTS.passwordResetSucceeded, data: formatPasswordReset(pr) }); + return c.json({ user: { object: 'user', id: user.id, email: user.email } }); }); } diff --git a/src/workos/routes/sessions.ts b/src/workos/routes/sessions.ts index bacd1ae..cf571f5 100644 --- a/src/workos/routes/sessions.ts +++ b/src/workos/routes/sessions.ts @@ -28,6 +28,8 @@ export function sessionRoutes(ctx: RouteContext): void { const session = ws.sessions.get(sessionId); if (!session) throw notFound('Session'); + // Sessions have no onUpdate hook, so this only shapes the session.revoked payload + ws.sessions.update(session.id, { status: 'revoked', ended_at: new Date().toISOString() }); ws.sessions.delete(session.id); return c.json({ success: true }); }); @@ -43,7 +45,10 @@ export function sessionRoutes(ctx: RouteContext): void { } const session = ws.sessions.get(sessionId); - if (session) ws.sessions.delete(session.id); + if (session) { + ws.sessions.update(session.id, { status: 'revoked', ended_at: new Date().toISOString() }); + ws.sessions.delete(session.id); + } if (returnTo) { assertLocalRedirectUri(returnTo); diff --git a/src/workos/routes/sso.spec.ts b/src/workos/routes/sso.spec.ts index ff0159d..678c8a2 100644 --- a/src/workos/routes/sso.spec.ts +++ b/src/workos/routes/sso.spec.ts @@ -1,6 +1,7 @@ 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 { STORE_KEYS } from '../constants.js'; import type { Store } from '../../core/index.js'; @@ -15,7 +16,8 @@ describe('SSO routes', () => { let app: ReturnType['app']; beforeEach(() => { - app = createTestApp().app; + const server = createTestApp(); + app = server.app; }); const req = (path: string, init?: RequestInit) => app.request(path, { headers, ...init }); @@ -216,3 +218,85 @@ describe('SSO interactive auth', () => { expect(body.profile.email).toBe('alice@sso.example.com'); }); }); + +describe('SSO authentication events', () => { + 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; + + const eventsNamed = (name: string) => + getWorkOSStore(store) + .events.all() + .filter((e) => e.event === name); + + async function createOrgWithConnection() { + const org = await json( + await req('/organizations', { + method: 'POST', + body: JSON.stringify({ name: 'SSO Events Org' }), + }), + ); + const conn = await json( + await req('/connections', { + method: 'POST', + body: JSON.stringify({ + name: 'Events SSO', + organization_id: org.id, + connection_type: 'GenericSAML', + domains: ['sso-events.example.com'], + }), + }), + ); + return { org, conn }; + } + + it('emits authentication.sso_succeeded with the spec sso object on token exchange', async () => { + const { org, conn } = await createOrgWithConnection(); + + const authRes = await app.request( + `/sso/authorize?connection=${conn.id}&redirect_uri=http://localhost:3000/callback`, + ); + const code = new URL(authRes.headers.get('location')!).searchParams.get('code')!; + + await app.request('/sso/token', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ grant_type: 'authorization_code', code }), + }); + + const [event] = eventsNamed('authentication.sso_succeeded'); + expect(event).toBeDefined(); + expect(event.data).toMatchObject({ + type: 'sso', + status: 'succeeded', + sso: { organization_id: org.id, connection_id: conn.id, session_id: null }, + }); + expect(event.data).toHaveProperty('user_id'); + expect(event.data).toHaveProperty('email'); + }); + + it('emits authentication.sso_failed with an error object for an invalid code', async () => { + const res = await app.request('/sso/token', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ grant_type: 'authorization_code', code: 'sso_bogus' }), + }); + expect(res.status).toBe(400); + + const [event] = eventsNamed('authentication.sso_failed'); + expect(event).toBeDefined(); + expect(event.data).toMatchObject({ + type: 'sso', + status: 'failed', + error: { code: 'invalid_code', message: 'Invalid authorization code' }, + }); + }); +}); diff --git a/src/workos/routes/sso.ts b/src/workos/routes/sso.ts index 6610db0..2f87276 100644 --- a/src/workos/routes/sso.ts +++ b/src/workos/routes/sso.ts @@ -1,7 +1,15 @@ import { type RouteContext, parseJsonBody, WorkOSApiError, generateId } from '../../core/index.js'; import { getWorkOSStore } from '../store.js'; -import { formatSSOProfile, expiresIn, isExpired, assertLocalRedirectUri } from '../helpers.js'; +import { + formatSSOProfile, + expiresIn, + isExpired, + assertLocalRedirectUri, + AUTH_EVENTS, + buildAuthenticationEventData, +} from '../helpers.js'; import type { WorkOSConnection } from '../entities.js'; +import type { EventBus } from '../event-bus.js'; import { STORE_KEY_PREFIXES, STORE_KEYS } from '../constants.js'; import { renderLoginPage } from '../login-page.js'; @@ -136,6 +144,37 @@ export function ssoRoutes(ctx: RouteContext): void { const grantType = body.grant_type as string; const code = body.code as string; + const emitSsoEvent = ( + status: 'succeeded' | 'failed', + info: { + email?: string | null; + userId?: string | null; + organizationId?: string | null; + connectionId?: string | null; + }, + error?: WorkOSApiError, + ): void => { + const eventBus = store.getData(STORE_KEYS.eventBus); + if (!eventBus) return; + eventBus.emit({ + event: status === 'succeeded' ? AUTH_EVENTS.SSO.succeeded : AUTH_EVENTS.SSO.failed, + data: buildAuthenticationEventData({ + status, + method: 'SSO', + userId: info.userId, + email: info.email, + ipAddress: c.req.header('x-forwarded-for') ?? null, + userAgent: c.req.header('user-agent') ?? null, + ...(error ? { error: { code: error.code, message: error.message } } : {}), + sso: { + organization_id: info.organizationId ?? null, + connection_id: info.connectionId ?? null, + session_id: null, + }, + }), + }); + }; + if (grantType !== 'authorization_code') { throw new WorkOSApiError(400, 'Unsupported grant_type', 'invalid_request'); } @@ -145,11 +184,24 @@ export function ssoRoutes(ctx: RouteContext): void { const auth = ws.ssoAuthorizations.findOneBy('code', code); if (!auth) { - throw new WorkOSApiError(400, 'Invalid authorization code', 'invalid_code'); + const error = new WorkOSApiError(400, 'Invalid authorization code', 'invalid_code'); + emitSsoEvent('failed', {}, error); + throw error; } if (isExpired(auth.expires_at)) { ws.ssoAuthorizations.delete(auth.id); - throw new WorkOSApiError(400, 'Authorization code has expired', 'expired_code'); + const expiredProfile = ws.ssoProfiles.get(auth.profile_id); + const error = new WorkOSApiError(400, 'Authorization code has expired', 'expired_code'); + emitSsoEvent( + 'failed', + { + email: expiredProfile?.email, + organizationId: auth.organization_id, + connectionId: expiredProfile?.connection_id, + }, + error, + ); + throw error; } const profile = ws.ssoProfiles.get(auth.profile_id); @@ -167,6 +219,14 @@ export function ssoRoutes(ctx: RouteContext): void { store.setData(`${STORE_KEY_PREFIXES.ssoToken}${accessToken}`, profile.id); + // SSO is profile-based; a user-management user may not exist for this email + emitSsoEvent('succeeded', { + email: profile.email, + userId: ws.users.findOneBy('email', profile.email)?.id ?? null, + organizationId: auth.organization_id ?? profile.organization_id, + connectionId: profile.connection_id, + }); + return c.json({ profile: formatSSOProfile(profile), access_token: accessToken,