diff --git a/.changeset/config.json b/.changeset/config.json new file mode 100644 index 0000000..a15a92a --- /dev/null +++ b/.changeset/config.json @@ -0,0 +1,15 @@ +{ + "$schema": "https://unpkg.com/@changesets/config@3.1.1/schema.json", + "changelog": "@changesets/cli/changelog", + "commit": false, + "fixed": [], + "linked": [], + "access": "public", + "baseBranch": "main", + "updateInternalDependencies": "patch", + "snapshot": { + "useCalculatedVersion": true, + "prereleaseTemplate": "{tag}.{datetime}" + }, + "ignore": [] +} diff --git a/.env.example b/.env.example index 6e8c2f2..cffc410 100644 --- a/.env.example +++ b/.env.example @@ -65,6 +65,8 @@ ORIGINS=http://localhost:5173,http://localhost:5174 # JSON array of provider config. Provider client secrets stay in env vars referenced by # clientSecretEnv, never in system_config or this JSON value. OAUTH_PROVIDERS=[] +# Dedicated signing secret for OAuth state. If unset, production falls back to API_SERVICE_TOKEN. +OAUTH_STATE_SECRET= # ADMIN BOOTSTRAP SEAMLESS_BOOTSTRAP_ENABLED=true diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fe23c03..b86d9d7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -41,3 +41,6 @@ jobs: run: npm run typecheck - name: Build run: npm run build + + - name: Verify package contents + run: npm run check-npm-build diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..590b1f6 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,68 @@ +name: Release + +on: + push: + branches: + - main + workflow_dispatch: + +permissions: + contents: write + issues: write + pull-requests: write + id-token: write + +concurrency: + group: release-${{ github.ref }} + cancel-in-progress: false + +jobs: + release: + if: github.repository == 'fells-code/seamless-auth-api' + name: Version or Publish Package + runs-on: ubuntu-latest + env: + HUSKY: 0 + + steps: + - name: Checkout repo + uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version: 24.10.0 + cache: npm + registry-url: https://registry.npmjs.org/ + + - name: Install dependencies + run: npm ci + + - name: Lint + run: npm run lint + + - name: Test + run: npm run test:run + + - name: Typecheck + run: npm run typecheck + + - name: Build + run: npm run build + + - name: Verify package contents + run: npm run check-npm-build + + - name: Version or publish package + uses: changesets/action@v1 + with: + version: npm run version-packages + publish: npm run release:stable + createGithubReleases: true + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + NPM_TOKEN: ${{ secrets.NPM_TOKEN }} + NPM_CONFIG_PROVENANCE: true diff --git a/.npmignore b/.npmignore index d87ea5b..51116d3 100644 --- a/.npmignore +++ b/.npmignore @@ -1,4 +1,12 @@ tests src +coverage +logs +keys +.github +.husky +.env +.env.* +!.env.example vitest.config.ts -tsconfig.json \ No newline at end of file +tsconfig.json diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..db32107 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,5 @@ +# seamless-auth-api + +## 0.2.0 + +- Current release baseline before Changesets-managed releases. diff --git a/README.md b/README.md index 249998f..64c705e 100644 --- a/README.md +++ b/README.md @@ -44,7 +44,9 @@ The following are **intentionally out of scope** and are part of the managed Sea - Hosted metrics, analytics, or usage dashboards - Managed secrets storage or key rotation services - Automated upgrades, backups, or restore tooling -- Email or SMS services to service the OTP requests +- Managed email or SMS services. The API can send directly through configured provider adapters or + return external-delivery payloads to a trusted server adapter, but SeamlessAuth Managed handles the + operated delivery service. - Support SLAs or operational monitoring Self-hosted users are free to implement any of the above on their own, but they are not required to use SeamlessAuth Managed Service. @@ -61,7 +63,8 @@ This repository does **not** assume any specific cloud provider, billing system, - Bearer/JSON auth API with opaque refresh tokens and signed access tokens - WebAuthn / passkeys support - Optional WebAuthn PRF support for products that need browser-local key material -- Token and JWKS support for service-to-service auth +- JWKS publication for access-token verification and separate service-token guards for trusted + server adapters - Built for inspection, auditability, and self-hosting This repository contains **only the auth server**. The admin portal, billing system, and hosted control plane are proprietary and offered as a managed service. @@ -82,9 +85,21 @@ If you want hosted auth with a full control plane and operational support, use t - SeamlessAuth server SDK (recommended) - Direct HTTP APIs (advanced) +## Bearer Token Contract + +Seamless Auth API returns JSON tokens instead of browser auth cookies. + +- Pre-auth flows return an ephemeral `token`; send it as `Authorization: Bearer ` to routes + marked as ephemeral-authenticated, such as OTP, magic-link, and WebAuthn continuation routes. +- Completed login, registration, OAuth, TOTP, passkey, and refresh flows return an access `token`; + send it as `Authorization: Bearer ` to access-authenticated routes. +- Refresh uses the opaque `refreshToken` value, not the access token. +- Internal service tokens remain separate. They are used only by explicitly service-token-protected + paths or headers such as external delivery support, not as user access or ephemeral bearer tokens. + --- -## Local development quickstart +## Local Development Quickstart ### Prerequisites @@ -93,10 +108,39 @@ If you want hosted auth with a full control plane and operational support, use t ### Configuration -Copy the `.env.example` to an `.env` file and populate empty values. +Copy the `.env.example` to an `.env` file and populate values for your local environment. Never commit real secrets. Use `.env.example` for documentation. +For a default local Postgres instance, `.env.example` expects: + +```text +DB_HOST=localhost +DB_PORT=5432 +DB_NAME=seamless_auth +DB_USER=myuser +DB_PASSWORD=mypassword +``` + +### Run Locally + +```bash +npm install +npm run migrate:up +npm run dev +``` + +The server starts on `http://localhost:5312` by default. + +Verify it: + +```bash +curl http://localhost:5312/health +``` + +In development, OpenAPI is available at `http://localhost:5312/openapi.json` and Swagger UI is +available at `http://localhost:5312/docs`. + ### WebAuthn PRF SeamlessAuth can request PRF-capable passkeys and PRF assertions without ever receiving PRF output. @@ -239,6 +283,10 @@ external delivery also requires a valid `x-seamless-service-token` from a truste Development-only bootstrap token details require `x-seamless-auth-include-sensitive: true` and are never enabled in production. +Admin and user endpoints use explicit minimized response schemas. They do not return WebAuthn +public keys, refresh-token hashes/lookups, challenge context, verification tokens, PRF output, TOTP +secrets, or provider tokens. + ### Scoped Roles Global roles may be plain names such as `admin` or scoped names such as `admin:read` and @@ -255,18 +303,9 @@ Admin routes are split by intent: - write routes accept `admin` or `admin:write` - plain `admin` checks remain exact for backwards compatibility -### Install & run - -``` -npm install -npm run dev -``` - -The server should start on `http://localhost:5312` (or your configured port). - --- -# Docker Quickstart (5 minutes) +# Docker Quickstart This is the fastest way to run **Seamless Auth API** locally using Docker. @@ -310,8 +349,11 @@ cp .env.example .env If you do not already have Postgres running: ```bash +docker network create seamless-auth-local + docker run -d \ --name seamless-auth-postgres \ + --network seamless-auth-local \ -e POSTGRES_USER=myuser \ -e POSTGRES_PASSWORD=mypassword \ -e POSTGRES_DB=seamless_auth \ @@ -319,13 +361,28 @@ docker run -d \ postgres:16 ``` -Update DB env values accordingly. +The one-off migration command and API container below override `DB_HOST` to the Docker network name. + +## 4. Run database migrations + +Run migrations before the first boot and after upgrades that include migrations: + +```bash +docker run --rm \ + --env-file .env \ + --network seamless-auth-local \ + -e DB_HOST=seamless-auth-postgres \ + ghcr.io/fells-code/seamless-auth-api:latest \ + npm run migrate:up +``` -## 4. Run Seamless Auth Server +## 5. Run Seamless Auth Server ```bash docker run --rm \ --env-file .env \ + --network seamless-auth-local \ + -e DB_HOST=seamless-auth-postgres \ -p 5312:5312 \ ghcr.io/fells-code/seamless-auth-api:latest ``` @@ -336,7 +393,7 @@ The server will: - Start on port `5312` - Expose health and authentication endpoints -## 5. Verify it is running +## 6. Verify it is running ```bash curl http://localhost:5312/health @@ -347,7 +404,8 @@ You should receive a healthy response. ## Notes for self-hosting - Secrets are provided via environment variables -- Keys are generated or mounted at runtime as needed +- Development keys can be generated automatically; production signing keys should be generated, + rotated, and mounted or provided through environment-backed secret management - This image contains only the open-source authentication server - No admin portal, billing, or managed infrastructure is included @@ -358,6 +416,7 @@ For production deployments: - Rotate signing keys - Back up your database - Monitor authentication failures +- Treat `system_config` values as runtime configuration, not a secret store See [docs/production-operations.md](./docs/production-operations.md) for key, secret, rotation, lockout, and deployment guidance. @@ -373,11 +432,14 @@ Authentication infrastructure is security-sensitive. For production deployments: - Use HTTPS end-to-end -- Keep access and refresh tokens out of browser-readable storage +- Keep access and refresh tokens out of browser-readable storage. This API does not set or read + browser auth cookies; browser-facing apps should integrate through a trusted server adapter or + backend. - Restrict CORS origins - Rotate signing keys and secrets regularly - Enable database backups and test restores - Monitor auth failures and suspicious behavior +- Treat `system_config` values as runtime configuration, not a secret store ## Contributing diff --git a/docs/architecture.md b/docs/architecture.md index 090b1ff..6486f5a 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -5,7 +5,7 @@ Seamless Auth API is an Express and TypeScript authentication service backed by ## Runtime Components - Express app: global middleware, CORS, rate limits, OpenAPI metadata, and route loading. -- Routes: thin endpoint declarations with request/response schemas. +- Routes: thin endpoint declarations with request and response schemas. - Controllers: request handling and response shaping. - Services: reusable auth, session, messaging, organization, OAuth, lockout, redaction, and step-up logic. - Models: Sequelize models for users, credentials, sessions, system config, auth events, organizations, TOTP credentials, and OAuth identities. @@ -17,7 +17,7 @@ Seamless Auth API is an Express and TypeScript authentication service backed by 2. Route declarations validate params, query, and body schemas. 3. Auth middleware validates access or ephemeral tokens where required. 4. Controllers call service/model layers. -5. Response schemas validate JSON responses when configured. +5. Response schemas validate JSON responses and strip undocumented fields from successful payloads. 6. Auth events are logged with sensitive metadata redacted. ## Token Model @@ -30,6 +30,14 @@ Seamless Auth API uses three token states: Access tokens are signed with configured JWKS signing keys. Refresh tokens are rotated and stored in the `sessions` table as non-raw values. +Routes that declare access or ephemeral auth validate SeamlessAuth-issued bearer JWTs. Internal +service tokens are intentionally separate and are accepted only by service-token-specific middleware +or headers. + +The API does not set or read browser auth cookies. Browser-facing applications should keep token +custody in a trusted server adapter or backend and forward bearer tokens to the API from that trusted +layer. + ## Authentication Methods Supported login methods are controlled by `login_methods` system config: @@ -48,6 +56,12 @@ Runtime configuration lives in the `system_config` table and is bootstrapped fro Use environment variables for raw secrets. Do not store raw secrets in `system_config`. +## OpenAPI and Response Contracts + +Route modules use the `schemas` option so request validation, runtime response validation, and +OpenAPI generation stay aligned. Every route should declare an explicit response schema. Admin/user +responses are intentionally minimized and should return only fields the route contract names. + ## Operational Boundaries This repository contains the auth server only. It does not include billing, hosted tenant lifecycle, managed observability, managed secret storage, or the hosted control plane. Self-hosted deployments can integrate their own infrastructure for those responsibilities. diff --git a/docs/oauth.md b/docs/oauth.md index d142f66..5b3cc5c 100644 --- a/docs/oauth.md +++ b/docs/oauth.md @@ -9,6 +9,8 @@ Provider access tokens are used only during callback handling to fetch profile d 1. Add `oauth` to `LOGIN_METHODS`. 2. Configure one or more providers in `oauth_providers` system config or the `OAUTH_PROVIDERS` environment variable. 3. Store provider client secrets in environment variables referenced by `clientSecretEnv`. +4. Set `OAUTH_STATE_SECRET` in production, or ensure `API_SERVICE_TOKEN` is stable and secret so it + can be used as the OAuth state-signing fallback. Example provider: @@ -62,3 +64,6 @@ Use `requireEmailVerified: true` for providers that expose a reliable email veri ## OIDC Notes When provider scopes include `openid`, Seamless Auth includes a nonce bound into signed state. PKCE support depends on provider and client flow requirements; keep provider callback handling server-side and avoid exposing provider tokens to browsers. + +OAuth callback responses return the normal Seamless Auth JSON token payload. Provider tokens remain +server-side and must not be forwarded to browser clients. diff --git a/docs/production-operations.md b/docs/production-operations.md index f8862b2..2351b65 100644 --- a/docs/production-operations.md +++ b/docs/production-operations.md @@ -7,6 +7,8 @@ Authentication infrastructure is security-sensitive. This guide covers public de - Use HTTPS end to end. - Restrict CORS and WebAuthn origins to exact trusted origins. - Keep access and refresh tokens out of browser-readable storage. +- Do not depend on browser auth cookies. Seamless Auth API uses a JSON/bearer token contract; put + browser-facing token custody behind a trusted server adapter or backend. - Store raw secrets in environment variables, a secret manager, or a user-supplied secret store. - Back up Postgres and test restores. - Monitor authentication failures and suspicious events. @@ -19,6 +21,7 @@ Production deployments should define: - `API_SERVICE_TOKEN` - `REFRESH_TOKEN_LOOKUP_SECRET` - `TOTP_SECRET_ENCRYPTION_KEY` +- `OAUTH_STATE_SECRET` - `SEAMLESS_JWKS_ACTIVE_KID` - `SEAMLESS_JWKS_KEY__PRIVATE` - `JWKS_PUBLIC_KEYS` @@ -61,3 +64,7 @@ Direct email/SMS delivery requires provider credentials. If you use an external ## Redaction Logs and auth events redact sensitive metadata by default. Sensitive values include tokens, OTPs, magic-link URLs, OAuth state/codes, PRF salts and output, TOTP secrets, email/phone snapshots, private keys, and configured provider secrets. + +Public admin/user responses are also minimized. They should not expose WebAuthn public keys, +refresh-token hashes/lookups, challenge context, verification tokens, PRF output, TOTP secrets, or +provider tokens. diff --git a/package-lock.json b/package-lock.json index 260fad9..fa75131 100644 --- a/package-lock.json +++ b/package-lock.json @@ -37,6 +37,7 @@ "zod": "^4.3.6" }, "devDependencies": { + "@changesets/cli": "^2.31.0", "@commitlint/cli": "^20.5.0", "@commitlint/config-conventional": "^20.5.0", "@eslint/js": "^9.25.1", @@ -849,6 +850,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.0", "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", @@ -873,6 +884,490 @@ "node": ">=18" } }, + "node_modules/@changesets/apply-release-plan": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/@changesets/apply-release-plan/-/apply-release-plan-7.1.1.tgz", + "integrity": "sha512-9qPCm/rLx/xoOFXIHGB229+4GOL76S4MC+7tyOuTsR6+1jYlfFDQORdvwR5hDA6y4FL2BPt3qpbcQIS+dW85LA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@changesets/config": "^3.1.4", + "@changesets/get-version-range-type": "^0.4.0", + "@changesets/git": "^3.0.4", + "@changesets/should-skip-package": "^0.1.2", + "@changesets/types": "^6.1.0", + "@manypkg/get-packages": "^1.1.3", + "detect-indent": "^6.0.0", + "fs-extra": "^7.0.1", + "lodash.startcase": "^4.4.0", + "outdent": "^0.5.0", + "prettier": "^2.7.1", + "resolve-from": "^5.0.0", + "semver": "^7.5.3" + } + }, + "node_modules/@changesets/apply-release-plan/node_modules/fs-extra": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-7.0.1.tgz", + "integrity": "sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.1.2", + "jsonfile": "^4.0.0", + "universalify": "^0.1.0" + }, + "engines": { + "node": ">=6 <7 || >=8" + } + }, + "node_modules/@changesets/apply-release-plan/node_modules/jsonfile": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz", + "integrity": "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==", + "dev": true, + "license": "MIT", + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/@changesets/apply-release-plan/node_modules/prettier": { + "version": "2.8.8", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-2.8.8.tgz", + "integrity": "sha512-tdN8qQGvNjw4CHbY+XXk0JgCXn9QiF21a55rBe5LJAU+kDyC4WQn4+awm2Xfk2lQMk5fKup9XgzTZtGkjBdP9Q==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin-prettier.js" + }, + "engines": { + "node": ">=10.13.0" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, + "node_modules/@changesets/apply-release-plan/node_modules/universalify": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz", + "integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4.0.0" + } + }, + "node_modules/@changesets/assemble-release-plan": { + "version": "6.0.10", + "resolved": "https://registry.npmjs.org/@changesets/assemble-release-plan/-/assemble-release-plan-6.0.10.tgz", + "integrity": "sha512-rSDcqdJ9KbVyjpBIuCidhvZNIiVt1XaIYp73ycVQRIA5n/j6wQaEk0ChRLMUQ1vkxZe51PTQ9OIhbg6HQMW45A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@changesets/errors": "^0.2.0", + "@changesets/get-dependents-graph": "^2.1.4", + "@changesets/should-skip-package": "^0.1.2", + "@changesets/types": "^6.1.0", + "@manypkg/get-packages": "^1.1.3", + "semver": "^7.5.3" + } + }, + "node_modules/@changesets/changelog-git": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/@changesets/changelog-git/-/changelog-git-0.2.1.tgz", + "integrity": "sha512-x/xEleCFLH28c3bQeQIyeZf8lFXyDFVn1SgcBiR2Tw/r4IAWlk1fzxCEZ6NxQAjF2Nwtczoen3OA2qR+UawQ8Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@changesets/types": "^6.1.0" + } + }, + "node_modules/@changesets/cli": { + "version": "2.31.0", + "resolved": "https://registry.npmjs.org/@changesets/cli/-/cli-2.31.0.tgz", + "integrity": "sha512-AhI4enNTgHu2IZr6K4WZyf0EPch4XVMn1yOMFmCD9gsfBGqMYaHXls5HyDv6/CL5axVQABz68eG30eCtbr2wFg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@changesets/apply-release-plan": "^7.1.1", + "@changesets/assemble-release-plan": "^6.0.10", + "@changesets/changelog-git": "^0.2.1", + "@changesets/config": "^3.1.4", + "@changesets/errors": "^0.2.0", + "@changesets/get-dependents-graph": "^2.1.4", + "@changesets/get-release-plan": "^4.0.16", + "@changesets/git": "^3.0.4", + "@changesets/logger": "^0.1.1", + "@changesets/pre": "^2.0.2", + "@changesets/read": "^0.6.7", + "@changesets/should-skip-package": "^0.1.2", + "@changesets/types": "^6.1.0", + "@changesets/write": "^0.4.0", + "@inquirer/external-editor": "^1.0.2", + "@manypkg/get-packages": "^1.1.3", + "ansi-colors": "^4.1.3", + "enquirer": "^2.4.1", + "fs-extra": "^7.0.1", + "mri": "^1.2.0", + "package-manager-detector": "^0.2.0", + "picocolors": "^1.1.0", + "resolve-from": "^5.0.0", + "semver": "^7.5.3", + "spawndamnit": "^3.0.1", + "term-size": "^2.1.0" + }, + "bin": { + "changeset": "bin.js" + } + }, + "node_modules/@changesets/cli/node_modules/fs-extra": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-7.0.1.tgz", + "integrity": "sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.1.2", + "jsonfile": "^4.0.0", + "universalify": "^0.1.0" + }, + "engines": { + "node": ">=6 <7 || >=8" + } + }, + "node_modules/@changesets/cli/node_modules/jsonfile": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz", + "integrity": "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==", + "dev": true, + "license": "MIT", + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/@changesets/cli/node_modules/universalify": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz", + "integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4.0.0" + } + }, + "node_modules/@changesets/config": { + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/@changesets/config/-/config-3.1.4.tgz", + "integrity": "sha512-pf0bvD/v6WI2cRlZ6hzpjtZdSlXDXMAJ+Iz7xfFzV4ZxJ8OGGAON+1qYc99ZPrijnt4xp3VGG7eNvAOGS24V1Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@changesets/errors": "^0.2.0", + "@changesets/get-dependents-graph": "^2.1.4", + "@changesets/logger": "^0.1.1", + "@changesets/should-skip-package": "^0.1.2", + "@changesets/types": "^6.1.0", + "@manypkg/get-packages": "^1.1.3", + "fs-extra": "^7.0.1", + "micromatch": "^4.0.8" + } + }, + "node_modules/@changesets/config/node_modules/fs-extra": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-7.0.1.tgz", + "integrity": "sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.1.2", + "jsonfile": "^4.0.0", + "universalify": "^0.1.0" + }, + "engines": { + "node": ">=6 <7 || >=8" + } + }, + "node_modules/@changesets/config/node_modules/jsonfile": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz", + "integrity": "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==", + "dev": true, + "license": "MIT", + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/@changesets/config/node_modules/universalify": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz", + "integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4.0.0" + } + }, + "node_modules/@changesets/errors": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/@changesets/errors/-/errors-0.2.0.tgz", + "integrity": "sha512-6BLOQUscTpZeGljvyQXlWOItQyU71kCdGz7Pi8H8zdw6BI0g3m43iL4xKUVPWtG+qrrL9DTjpdn8eYuCQSRpow==", + "dev": true, + "license": "MIT", + "dependencies": { + "extendable-error": "^0.1.5" + } + }, + "node_modules/@changesets/get-dependents-graph": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@changesets/get-dependents-graph/-/get-dependents-graph-2.1.4.tgz", + "integrity": "sha512-ZsS00x6WvmHq3sQv8oCMwL0f/z3wbXCVuSVTJwCnnmbC/iBdNJGFx1EcbMG4PC6sXRyH69liM4A2WKXzn/kRPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@changesets/types": "^6.1.0", + "@manypkg/get-packages": "^1.1.3", + "picocolors": "^1.1.0", + "semver": "^7.5.3" + } + }, + "node_modules/@changesets/get-release-plan": { + "version": "4.0.16", + "resolved": "https://registry.npmjs.org/@changesets/get-release-plan/-/get-release-plan-4.0.16.tgz", + "integrity": "sha512-2K5Om6CrMPm45rtvckfzWo7e9jOVCKLCnXia5eUPaURH7/LWzri7pK1TycdzAuAtehLkW7VPbWLCSExTHmiI6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@changesets/assemble-release-plan": "^6.0.10", + "@changesets/config": "^3.1.4", + "@changesets/pre": "^2.0.2", + "@changesets/read": "^0.6.7", + "@changesets/types": "^6.1.0", + "@manypkg/get-packages": "^1.1.3" + } + }, + "node_modules/@changesets/get-version-range-type": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@changesets/get-version-range-type/-/get-version-range-type-0.4.0.tgz", + "integrity": "sha512-hwawtob9DryoGTpixy1D3ZXbGgJu1Rhr+ySH2PvTLHvkZuQ7sRT4oQwMh0hbqZH1weAooedEjRsbrWcGLCeyVQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@changesets/git": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@changesets/git/-/git-3.0.4.tgz", + "integrity": "sha512-BXANzRFkX+XcC1q/d27NKvlJ1yf7PSAgi8JG6dt8EfbHFHi4neau7mufcSca5zRhwOL8j9s6EqsxmT+s+/E6Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@changesets/errors": "^0.2.0", + "@manypkg/get-packages": "^1.1.3", + "is-subdir": "^1.1.1", + "micromatch": "^4.0.8", + "spawndamnit": "^3.0.1" + } + }, + "node_modules/@changesets/logger": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/@changesets/logger/-/logger-0.1.1.tgz", + "integrity": "sha512-OQtR36ZlnuTxKqoW4Sv6x5YIhOmClRd5pWsjZsddYxpWs517R0HkyiefQPIytCVh4ZcC5x9XaG8KTdd5iRQUfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "picocolors": "^1.1.0" + } + }, + "node_modules/@changesets/parse": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@changesets/parse/-/parse-0.4.3.tgz", + "integrity": "sha512-ZDmNc53+dXdWEv7fqIUSgRQOLYoUom5Z40gmLgmATmYR9NbL6FJJHwakcCpzaeCy+1D0m0n7mT4jj2B/MQPl7A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@changesets/types": "^6.1.0", + "js-yaml": "^4.1.1" + } + }, + "node_modules/@changesets/pre": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@changesets/pre/-/pre-2.0.2.tgz", + "integrity": "sha512-HaL/gEyFVvkf9KFg6484wR9s0qjAXlZ8qWPDkTyKF6+zqjBe/I2mygg3MbpZ++hdi0ToqNUF8cjj7fBy0dg8Ug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@changesets/errors": "^0.2.0", + "@changesets/types": "^6.1.0", + "@manypkg/get-packages": "^1.1.3", + "fs-extra": "^7.0.1" + } + }, + "node_modules/@changesets/pre/node_modules/fs-extra": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-7.0.1.tgz", + "integrity": "sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.1.2", + "jsonfile": "^4.0.0", + "universalify": "^0.1.0" + }, + "engines": { + "node": ">=6 <7 || >=8" + } + }, + "node_modules/@changesets/pre/node_modules/jsonfile": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz", + "integrity": "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==", + "dev": true, + "license": "MIT", + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/@changesets/pre/node_modules/universalify": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz", + "integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4.0.0" + } + }, + "node_modules/@changesets/read": { + "version": "0.6.7", + "resolved": "https://registry.npmjs.org/@changesets/read/-/read-0.6.7.tgz", + "integrity": "sha512-D1G4AUYGrBEk8vj8MGwf75k9GpN6XL3wg8i42P2jZZwFLXnlr2Pn7r9yuQNbaMCarP7ZQWNJbV6XLeysAIMhTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@changesets/git": "^3.0.4", + "@changesets/logger": "^0.1.1", + "@changesets/parse": "^0.4.3", + "@changesets/types": "^6.1.0", + "fs-extra": "^7.0.1", + "p-filter": "^2.1.0", + "picocolors": "^1.1.0" + } + }, + "node_modules/@changesets/read/node_modules/fs-extra": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-7.0.1.tgz", + "integrity": "sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.1.2", + "jsonfile": "^4.0.0", + "universalify": "^0.1.0" + }, + "engines": { + "node": ">=6 <7 || >=8" + } + }, + "node_modules/@changesets/read/node_modules/jsonfile": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz", + "integrity": "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==", + "dev": true, + "license": "MIT", + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/@changesets/read/node_modules/universalify": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz", + "integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4.0.0" + } + }, + "node_modules/@changesets/should-skip-package": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/@changesets/should-skip-package/-/should-skip-package-0.1.2.tgz", + "integrity": "sha512-qAK/WrqWLNCP22UDdBTMPH5f41elVDlsNyat180A33dWxuUDyNpg6fPi/FyTZwRriVjg0L8gnjJn2F9XAoF0qw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@changesets/types": "^6.1.0", + "@manypkg/get-packages": "^1.1.3" + } + }, + "node_modules/@changesets/types": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/@changesets/types/-/types-6.1.0.tgz", + "integrity": "sha512-rKQcJ+o1nKNgeoYRHKOS07tAMNd3YSN0uHaJOZYjBAgxfV7TUE7JE+z4BzZdQwb5hKaYbayKN5KrYV7ODb2rAA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@changesets/write": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@changesets/write/-/write-0.4.0.tgz", + "integrity": "sha512-CdTLvIOPiCNuH71pyDu3rA+Q0n65cmAbXnwWH84rKGiFumFzkmHNT8KHTMEchcxN+Kl8I54xGUhJ7l3E7X396Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@changesets/types": "^6.1.0", + "fs-extra": "^7.0.1", + "human-id": "^4.1.1", + "prettier": "^2.7.1" + } + }, + "node_modules/@changesets/write/node_modules/fs-extra": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-7.0.1.tgz", + "integrity": "sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.1.2", + "jsonfile": "^4.0.0", + "universalify": "^0.1.0" + }, + "engines": { + "node": ">=6 <7 || >=8" + } + }, + "node_modules/@changesets/write/node_modules/jsonfile": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz", + "integrity": "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==", + "dev": true, + "license": "MIT", + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/@changesets/write/node_modules/prettier": { + "version": "2.8.8", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-2.8.8.tgz", + "integrity": "sha512-tdN8qQGvNjw4CHbY+XXk0JgCXn9QiF21a55rBe5LJAU+kDyC4WQn4+awm2Xfk2lQMk5fKup9XgzTZtGkjBdP9Q==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin-prettier.js" + }, + "engines": { + "node": ">=10.13.0" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, + "node_modules/@changesets/write/node_modules/universalify": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz", + "integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4.0.0" + } + }, "node_modules/@colors/colors": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/@colors/colors/-/colors-1.6.0.tgz", @@ -1865,6 +2360,45 @@ "url": "https://github.com/sponsors/nzakas" } }, + "node_modules/@inquirer/external-editor": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@inquirer/external-editor/-/external-editor-1.0.3.tgz", + "integrity": "sha512-RWbSrDiYmO4LbejWY7ttpxczuwQyZLBUyygsA9Nsv95hpzUWwnNTVQmAq3xuh7vNwCp07UTmE5i11XAEExx4RA==", + "dev": true, + "license": "MIT", + "dependencies": { + "chardet": "^2.1.1", + "iconv-lite": "^0.7.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/external-editor/node_modules/iconv-lite": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", + "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/@isaacs/cliui": { "version": "8.0.2", "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", @@ -1969,6 +2503,174 @@ "integrity": "sha512-llBRm4dT4Z89aRsm6u2oEZ8tfwL/2l6BwpZ7JcyieouniDECM5AqNgr/y08zalEIvW3RSK4upYyybDcmjXqAow==", "license": "MIT" }, + "node_modules/@manypkg/find-root": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@manypkg/find-root/-/find-root-1.1.0.tgz", + "integrity": "sha512-mki5uBvhHzO8kYYix/WRy2WX8S3B5wdVSc9D6KcU5lQNglP2yt58/VfLuAK49glRXChosY8ap2oJ1qgma3GUVA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.5.5", + "@types/node": "^12.7.1", + "find-up": "^4.1.0", + "fs-extra": "^8.1.0" + } + }, + "node_modules/@manypkg/find-root/node_modules/@types/node": { + "version": "12.20.55", + "resolved": "https://registry.npmjs.org/@types/node/-/node-12.20.55.tgz", + "integrity": "sha512-J8xLz7q2OFulZ2cyGTLE1TbbZcjpno7FaN6zdJNrgAdrJ+DZzh/uFR6YrTb4C+nXakvud8Q4+rbhoIWlYQbUFQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@manypkg/find-root/node_modules/find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@manypkg/find-root/node_modules/fs-extra": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-8.1.0.tgz", + "integrity": "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^4.0.0", + "universalify": "^0.1.0" + }, + "engines": { + "node": ">=6 <7 || >=8" + } + }, + "node_modules/@manypkg/find-root/node_modules/jsonfile": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz", + "integrity": "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==", + "dev": true, + "license": "MIT", + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/@manypkg/find-root/node_modules/locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^4.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@manypkg/find-root/node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@manypkg/find-root/node_modules/p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^2.2.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@manypkg/find-root/node_modules/universalify": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz", + "integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4.0.0" + } + }, + "node_modules/@manypkg/get-packages": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@manypkg/get-packages/-/get-packages-1.1.3.tgz", + "integrity": "sha512-fo+QhuU3qE/2TQMQmbVMqaQ6EWbMhi4ABWP+O4AM1NqPBuy0OrApV5LO6BrrgnhtAHS2NH6RrVk9OL181tTi8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.5.5", + "@changesets/types": "^4.0.1", + "@manypkg/find-root": "^1.1.0", + "fs-extra": "^8.1.0", + "globby": "^11.0.0", + "read-yaml-file": "^1.1.0" + } + }, + "node_modules/@manypkg/get-packages/node_modules/@changesets/types": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@changesets/types/-/types-4.1.0.tgz", + "integrity": "sha512-LDQvVDv5Kb50ny2s25Fhm3d9QSZimsoUGBsUioj6MC3qbMUCuC8GPIvk/M6IvXx3lYhAs0lwWUQLb+VIEUCECw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@manypkg/get-packages/node_modules/fs-extra": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-8.1.0.tgz", + "integrity": "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^4.0.0", + "universalify": "^0.1.0" + }, + "engines": { + "node": ">=6 <7 || >=8" + } + }, + "node_modules/@manypkg/get-packages/node_modules/jsonfile": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz", + "integrity": "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==", + "dev": true, + "license": "MIT", + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/@manypkg/get-packages/node_modules/universalify": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz", + "integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4.0.0" + } + }, "node_modules/@napi-rs/wasm-runtime": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.2.tgz", @@ -4181,6 +4883,16 @@ "url": "https://github.com/sponsors/epoberezkin" } }, + "node_modules/ansi-colors": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-4.1.3.tgz", + "integrity": "sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/ansi-escapes": { "version": "7.3.0", "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-7.3.0.tgz", @@ -4257,6 +4969,16 @@ "dev": true, "license": "MIT" }, + "node_modules/array-union": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/array-union/-/array-union-2.1.0.tgz", + "integrity": "sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/asap": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/asap/-/asap-2.0.6.tgz", @@ -4381,6 +5103,19 @@ "node": ">=20" } }, + "node_modules/better-path-resolve": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/better-path-resolve/-/better-path-resolve-1.0.0.tgz", + "integrity": "sha512-pbnl5XzGBdrFU/wT4jqmJVPn2B6UHPBOhzMQkY/SPUPB6QtUXtmBHBIwCbXJol93mOpGMnQyP/+BB19q04xj7g==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-windows": "^1.0.0" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/bindings": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz", @@ -4645,6 +5380,13 @@ "node": ">=18" } }, + "node_modules/chardet": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/chardet/-/chardet-2.1.1.tgz", + "integrity": "sha512-PsezH1rqdV9VvyNhxxOW32/d75r01NY7TQCmOqomRo15ZSOKbpTFVsfjghxo6JloQUCGnH4k1LGu0R4yCLlWQQ==", + "dev": true, + "license": "MIT" + }, "node_modules/chownr": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz", @@ -5177,6 +5919,16 @@ "npm": "1.2.8000 || >= 1.4.16" } }, + "node_modules/detect-indent": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/detect-indent/-/detect-indent-6.1.0.tgz", + "integrity": "sha512-reYkTUJAZb9gUuZ2RvVCNhVHdg62RHnJ7WJl8ftMi4diZ6NWlciOzQN88pUhSELEwflJht4oQDv0F0BMlwaYtA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/detect-libc": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", @@ -5208,6 +5960,19 @@ "node": ">=0.3.1" } }, + "node_modules/dir-glob": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz", + "integrity": "sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-type": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/dot-prop": { "version": "5.3.0", "resolved": "https://registry.npmjs.org/dot-prop/-/dot-prop-5.3.0.tgz", @@ -5350,6 +6115,43 @@ "once": "^1.4.0" } }, + "node_modules/enquirer": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/enquirer/-/enquirer-2.4.1.tgz", + "integrity": "sha512-rRqJg/6gd538VHvR3PSrdRBb/1Vy2YfzHqzvbhGIQpDRKIa4FgV/54b5Q1xYSxOOwKvjXweS26E0Q+nAMwp2pQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-colors": "^4.1.1", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/enquirer/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/enquirer/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/env-cmd": { "version": "11.0.0", "resolved": "https://registry.npmjs.org/env-cmd/-/env-cmd-11.0.0.tgz", @@ -5759,6 +6561,20 @@ "url": "https://opencollective.com/eslint" } }, + "node_modules/esprima": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", + "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", + "dev": true, + "license": "BSD-2-Clause", + "bin": { + "esparse": "bin/esparse.js", + "esvalidate": "bin/esvalidate.js" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/esquery": { "version": "1.7.0", "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", @@ -5950,6 +6766,13 @@ "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", "license": "MIT" }, + "node_modules/extendable-error": { + "version": "0.1.7", + "resolved": "https://registry.npmjs.org/extendable-error/-/extendable-error-0.1.7.tgz", + "integrity": "sha512-UOiS2in6/Q0FK0R0q6UY9vYpQ21mr/Qn1KOnte7vsACuNJf514WvCCUHSRCPcgjPT2bAhNIJdlE6bVap1GKmeg==", + "dev": true, + "license": "MIT" + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -6531,6 +7354,37 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/globby": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/globby/-/globby-11.1.0.tgz", + "integrity": "sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-union": "^2.1.0", + "dir-glob": "^3.0.1", + "fast-glob": "^3.2.9", + "ignore": "^5.2.0", + "merge2": "^1.4.1", + "slash": "^3.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/globby/node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, "node_modules/gopd": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", @@ -6672,6 +7526,16 @@ "node": ">= 14" } }, + "node_modules/human-id": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/human-id/-/human-id-4.1.3.tgz", + "integrity": "sha512-tsYlhAYpjCKa//8rXZ9DqKEawhPoSytweBC2eNvcaDK+57RZLHGqNs3PZTQO6yekLFSuvA6AlnAfrw1uBvtb+Q==", + "dev": true, + "license": "MIT", + "bin": { + "human-id": "dist/cli.js" + } + }, "node_modules/husky": { "version": "9.1.7", "resolved": "https://registry.npmjs.org/husky/-/husky-9.1.7.tgz", @@ -6927,6 +7791,29 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/is-subdir": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/is-subdir/-/is-subdir-1.2.0.tgz", + "integrity": "sha512-2AT6j+gXe/1ueqbW6fLZJiIw3F8iXGJtt0yDrZaBhAZEG1raiTxKWU+IPqMCzQAXOUCKdA4UDMgacKH25XG2Cw==", + "dev": true, + "license": "MIT", + "dependencies": { + "better-path-resolve": "1.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/is-windows": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-windows/-/is-windows-1.0.2.tgz", + "integrity": "sha512-eXK1UInq2bPmjyX6e3VHIzMLobc4J94i4AWn+Hpq3OU5KkrRC96OAcR3PRJ/pGu6m8TRnBHP9dkXQVsT/COVIA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", @@ -8109,6 +8996,16 @@ "node": "*" } }, + "node_modules/mri": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz", + "integrity": "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -8389,6 +9286,36 @@ "node": ">= 0.8.0" } }, + "node_modules/outdent": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/outdent/-/outdent-0.5.0.tgz", + "integrity": "sha512-/jHxFIzoMXdqPzTaCpFzAAWhpkSjZPF4Vsn6jAfNpmbH/ymsmd7Qc6VE9BGn0L6YMj6uwpQLxCECpus4ukKS9Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/p-filter": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/p-filter/-/p-filter-2.1.0.tgz", + "integrity": "sha512-ZBxxZ5sL2HghephhpGAQdoskxplTwr7ICaehZwLIlfL6acuVgZPm8yBNuRAFBGEqtD/hmUeq9eqLg2ys9Xr/yw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-map": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/p-filter/node_modules/p-map": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/p-map/-/p-map-2.1.0.tgz", + "integrity": "sha512-y3b8Kpd8OAN444hxfBbFfj1FY/RjtTd8tzYwhUqNYXx0fXx2iX4maP4Qr6qhIKbQXI02wTLAda4fYUbDagTUFw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/p-limit": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", @@ -8435,12 +9362,32 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/p-try": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", + "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/package-json-from-dist": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", "license": "BlueOak-1.0.0" }, + "node_modules/package-manager-detector": { + "version": "0.2.11", + "resolved": "https://registry.npmjs.org/package-manager-detector/-/package-manager-detector-0.2.11.tgz", + "integrity": "sha512-BEnLolu+yuz22S56CU1SUKq3XC3PkwD5wv4ikR4MfGvnRVcmzXR9DwSlW2fEamyTPyXHomBJRzgapeuBvRNzJQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "quansync": "^0.2.7" + } + }, "node_modules/parent-module": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", @@ -8544,6 +9491,16 @@ "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==", "license": "MIT" }, + "node_modules/path-type": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", + "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/pathe": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", @@ -8737,6 +9694,16 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/pify": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/pify/-/pify-4.0.1.tgz", + "integrity": "sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/postcss": { "version": "8.5.8", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", @@ -8971,6 +9938,23 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/quansync": { + "version": "0.2.11", + "resolved": "https://registry.npmjs.org/quansync/-/quansync-0.2.11.tgz", + "integrity": "sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/antfu" + }, + { + "type": "individual", + "url": "https://github.com/sponsors/sxzz" + } + ], + "license": "MIT" + }, "node_modules/queue-microtask": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", @@ -9038,6 +10022,46 @@ "dev": true, "license": "ISC" }, + "node_modules/read-yaml-file": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/read-yaml-file/-/read-yaml-file-1.1.0.tgz", + "integrity": "sha512-VIMnQi/Z4HT2Fxuwg5KrY174U1VdUIASQVWXXyqtNRtxSr9IYkn1rsI6Tb6HsrHCmB7gVpNwX6JxPTHcH6IoTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.1.5", + "js-yaml": "^3.6.1", + "pify": "^4.0.1", + "strip-bom": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/read-yaml-file/node_modules/argparse": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", + "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", + "dev": true, + "license": "MIT", + "dependencies": { + "sprintf-js": "~1.0.2" + } + }, + "node_modules/read-yaml-file/node_modules/js-yaml": { + "version": "3.14.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.2.tgz", + "integrity": "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^1.0.7", + "esprima": "^4.0.0" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, "node_modules/readable-stream": { "version": "3.6.2", "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", @@ -9749,6 +10773,16 @@ "simple-concat": "^1.0.0" } }, + "node_modules/slash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", + "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/slice-ansi": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-8.0.0.tgz", @@ -9820,6 +10854,17 @@ "node": ">=0.10.0" } }, + "node_modules/spawndamnit": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/spawndamnit/-/spawndamnit-3.0.1.tgz", + "integrity": "sha512-MmnduQUuHCoFckZoWnXsTg7JaiLBJrKFj9UI2MbRPGaJeVpsLcVBu6P/IGZovziM/YBsellCmsprgNA+w0CzVg==", + "dev": true, + "license": "SEE LICENSE IN LICENSE", + "dependencies": { + "cross-spawn": "^7.0.5", + "signal-exit": "^4.0.1" + } + }, "node_modules/split2": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", @@ -9829,6 +10874,13 @@ "node": ">= 10.x" } }, + "node_modules/sprintf-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", + "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==", + "dev": true, + "license": "BSD-3-Clause" + }, "node_modules/sqlite3": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/sqlite3/-/sqlite3-6.0.1.tgz", @@ -10021,6 +11073,16 @@ "node": ">=8" } }, + "node_modules/strip-bom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz", + "integrity": "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/strip-json-comments": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", @@ -10221,6 +11283,19 @@ "node": ">=6" } }, + "node_modules/term-size": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/term-size/-/term-size-2.2.1.tgz", + "integrity": "sha512-wK0Ri4fOGjv/XPy8SBHZChl8CM7uMc5VML7SqiQ0zG7+J5Vr+RMQDoHa2CNT6KHUnTGIXH34UDMkPzAUyapBZg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/text-hex": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/text-hex/-/text-hex-1.0.0.tgz", diff --git a/package.json b/package.json index 8c9f59e..23150ca 100644 --- a/package.json +++ b/package.json @@ -2,8 +2,17 @@ "name": "seamless-auth-api", "version": "0.2.0", "description": "Seamless Auth API - A web application server for supporting a Seamless Auth server instance.", - "main": "index.js", + "main": "dist/server.js", "type": "module", + "files": [ + "dist", + "src/migrations", + ".sequelizerc", + "docs", + "README.md", + "CHANGELOG.md", + "LICENSE" + ], "scripts": { "dev": "tsx watch --env-file .env src/server.ts", "start": "node dist/server.js", @@ -17,6 +26,10 @@ "test:e2e": "CI=false vitest", "lint": "eslint . --ext .ts", "format": "prettier --write .", + "changeset": "changeset", + "version-packages": "changeset version", + "release:stable": "npm run build && changeset publish", + "check-npm-build": "npm pack --dry-run --ignore-scripts", "db:create": "env-cmd -f .env sequelize-cli db:create || sequelize-cli db:create", "db:drop": "env-cmd -f .env sequelize-cli db:drop || sequelize-cli db:drop", "db:refresh": "env-cmd -f .env sequelize-cli db:drop && sequelize-cli db:create", @@ -36,6 +49,11 @@ "url": "https://github.com/fells-code/seamless-auth-api/issues" }, "homepage": "https://github.com/fells-code/seamless-auth-api#readme", + "publishConfig": { + "access": "public", + "registry": "https://registry.npmjs.org/", + "provenance": true + }, "dependencies": { "@asteasolutions/zod-to-openapi": "^8.4.3", "@seamless-auth/messaging": "^0.1.0", @@ -65,6 +83,7 @@ "zod": "^4.3.6" }, "devDependencies": { + "@changesets/cli": "^2.31.0", "@commitlint/cli": "^20.5.0", "@commitlint/config-conventional": "^20.5.0", "@eslint/js": "^9.25.1", diff --git a/resources/coverage-badge.svg b/resources/coverage-badge.svg index 8466b2a..ab2d251 100644 --- a/resources/coverage-badge.svg +++ b/resources/coverage-badge.svg @@ -1,5 +1,5 @@ - - coverage: 81.5% + + coverage: 81.8% @@ -17,7 +17,7 @@ coverage coverage - 81.5% - 81.5% + 81.8% + 81.8% diff --git a/src/controllers/admin.ts b/src/controllers/admin.ts index 162c2a0..d1ef980 100644 --- a/src/controllers/admin.ts +++ b/src/controllers/admin.ts @@ -19,6 +19,11 @@ import { UpdateUserSchema, } from '../schemas/admin.requests.js'; import { AuthEventQuerySchema } from '../schemas/internal.query.js'; +import { + serializeApiUser, + serializeCredential, + serializeSession, +} from '../services/apiResponseSerializers.js'; import { serializeAuthEvents } from '../services/authEventSerialization.js'; import { AuthEventService } from '../services/authEventService.js'; import { hardRevokeSession } from '../services/sessionService.js'; @@ -63,7 +68,7 @@ export const getUsers = async (req: ServiceRequest, res: Response) => { ]); return res.json({ - users: users ?? [], + users: (users ?? []).map(serializeApiUser), total, }); }; @@ -93,7 +98,7 @@ export const createUser = async (req: Request, res: Response) => { roles: roles ?? [], }); - return res.status(201).json({ user }); + return res.status(201).json({ user: serializeApiUser(user) }); } catch (err) { logger.error(`Failed to create user. Reason: ${err}`); return res.status(500).json({ error: 'Failed to create user' }); @@ -118,14 +123,14 @@ export const deleteUser = async (req: ServiceRequest, res: Response) => { if (user) { user.destroy(); - logger.info(`User ${user.email} deleted from database through the seamless auth portal.`); + logger.info('User deleted from database through the seamless auth portal.'); } else { logger.error(`Failed to destory a seemingly valid user via the portal`); } return res.status(200).json({ message: 'Success' }); } catch (error: unknown) { - logger.error(`Failed to delete user: ${userId}. Error: ${error}`); + logger.error(`Failed to delete user. Error: ${error}`); return res.status(500).json({ error: 'Failed' }); } } catch (error) { @@ -179,7 +184,7 @@ export const updateUser = async (req: ServiceRequest, res: Response) => { return; } - res.status(200).json({ user }); + res.status(200).json({ user: serializeApiUser(user) }); return; } catch { logger.error('Failed to find user'); @@ -220,9 +225,9 @@ export const getUserDetail = async (req: ServiceRequest, res: Response) => { }); return res.json({ - user, - sessions, - credentials, + user: serializeApiUser(user), + sessions: sessions.map((session) => serializeSession(session)), + credentials: credentials.map(serializeCredential), events: serializeAuthEvents(events), }); }; @@ -284,15 +289,7 @@ export const listUserSessions = async (req: Request, res: Response) => { }); return res.json({ - sessions: sessions.map((s) => ({ - id: s.id, - deviceName: s.deviceName, - ipAddress: s.ipAddress, - userAgent: s.userAgent, - lastUsedAt: s.lastUsedAt.toISOString(), - expiresAt: s.expiresAt.toISOString(), - current: false, - })), + sessions: sessions.map((session) => serializeSession(session)), total: sessions.length, }); } catch (err) { @@ -316,7 +313,7 @@ export const revokeAllUserSessions = async (req: Request, res: Response) => { await hardRevokeSession(session, 'admin_revoke_all'); } - logger.info(`All sessions revoked for user ${userId}`); + logger.info('All sessions revoked for user'); return res.json({ message: 'Success' }); } catch (err) { @@ -468,15 +465,7 @@ export const listAllSessions = async (req: Request, res: Response) => { Session.count({ where }), ]); - const response = sessions.map((session) => ({ - id: session.id, - deviceName: session.deviceName, - ipAddress: session.ipAddress, - userAgent: session.userAgent, - lastUsedAt: session.lastUsedAt.toISOString(), - expiresAt: session.expiresAt.toISOString(), - current: false, - })); + const response = sessions.map((session) => serializeSession(session)); return res.json({ sessions: response, total }); }; diff --git a/src/controllers/authentication.ts b/src/controllers/authentication.ts index 2b86e20..6a45a95 100644 --- a/src/controllers/authentication.ts +++ b/src/controllers/authentication.ts @@ -14,7 +14,6 @@ import { signAccessToken, signEphemeralToken, } from '../lib/token.js'; -import { AuthEvent } from '../models/authEvents.js'; import { Credential } from '../models/credentials.js'; import { Session } from '../models/sessions.js'; import { User } from '../models/users.js'; @@ -49,17 +48,16 @@ export const login = async (req: Request, res: Response) => { if (!identifier) { logger.warn('No pre authenticated identifier found'); - await AuthEvent.create({ - user_id: null, + await AuthEventService.log({ + userId: null, type: 'login_failed', - ip_address: req.ip, - user_agent: req.headers['user-agent'], + req, metadata: { reason: 'No identifier supplied' }, }); return res.status(403).json({ error: 'Not allowed' }); } - logger.info(`Login attempt with ${identifier}`); + logger.info('Login attempt with identifier'); try { if (isValidEmail(identifier)) { @@ -70,12 +68,11 @@ export const login = async (req: Request, res: Response) => { identifierType = 'email'; } catch { logger.error('Failed to find user'); - await AuthEvent.create({ - user_id: null, + await AuthEventService.log({ + userId: null, type: 'login_failed', - ip_address: req.ip, - user_agent: req.headers['user-agent'], - metadata: { reason: `No user found for identifer: ${identifier}` }, + req, + metadata: { reason: 'No user found for identifier' }, }); return res.status(401).json({ message: 'Not allowed' }); } @@ -87,47 +84,43 @@ export const login = async (req: Request, res: Response) => { identifierType = 'phone'; } catch { logger.error('Failed to find user'); - await AuthEvent.create({ - user_id: null, + await AuthEventService.log({ + userId: null, type: 'login_failed', - ip_address: req.ip, - user_agent: req.headers['user-agent'], - metadata: { reason: `No user found for identifer: ${identifier}` }, + req, + metadata: { reason: 'No user found for identifier' }, }); return res.status(403).json({ error: 'Not allowed' }); } } else { - logger.error(`Invalid identifier: ${identifier}`); - await AuthEvent.create({ - user_id: null, + logger.error('Invalid login identifier'); + await AuthEventService.log({ + userId: null, type: 'login_failed', - ip_address: req.ip, - user_agent: req.headers['user-agent'], - metadata: { reason: `No user found for identifer: ${identifier}` }, + req, + metadata: { reason: 'Invalid identifier' }, }); return res.status(400).json({ error: 'Invalid data' }); } } catch (error) { logger.error(`Failed to find a user with valid Identifier: ${error}`); - await AuthEvent.create({ - user_id: null, + await AuthEventService.log({ + userId: null, type: 'login_failed', - ip_address: req.ip, - user_agent: req.headers['user-agent'], - metadata: { reason: `No user found for identifer: ${identifier}` }, + req, + metadata: { reason: 'No user found for identifier' }, }); return res.status(500).json({ error: 'Internal server error' }); } try { if (!user) { - logger.error(`Login attempt failed for non-existent identity: ${identifier}`); - await AuthEvent.create({ - user_id: null, + logger.error('Login attempt failed for non-existent identity'); + await AuthEventService.log({ + userId: null, type: 'login_failed', - ip_address: req.ip, - user_agent: req.headers['user-agent'], - metadata: { reason: `No user found for identifer: ${identifier}` }, + req, + metadata: { reason: 'No user found for identifier' }, }); return res.status(401).json({ error: 'Not Allowed' }); } @@ -140,13 +133,12 @@ export const login = async (req: Request, res: Response) => { const token = await signEphemeralToken(user.id); if (!user.verified) { - logger.warn(`Login attempt for unverified account: ${identifier}`); - await AuthEvent.create({ - user_id: user.id, + logger.warn('Login attempt for unverified account'); + await AuthEventService.log({ + userId: user.id, type: 'login_failed', - ip_address: req.ip, - user_agent: req.headers['user-agent'], - metadata: { reason: `Unverified but valid user` }, + req, + metadata: { reason: 'Unverified but valid user' }, }); return res.status(401).json({ error: 'Login failed. Need to verify.' }); @@ -164,23 +156,21 @@ export const login = async (req: Request, res: Response) => { }); if (loginMethods.length === 0) { - logger.error(`Login attempt had no allowed continuation methods. ${identifier}`); - await AuthEvent.create({ - user_id: user.id, + logger.error('Login attempt had no allowed continuation methods'); + await AuthEventService.log({ + userId: user.id, type: 'login_failed', - ip_address: req.ip, - user_agent: req.headers['user-agent'], + req, metadata: { reason: 'No allowed login methods available' }, }); return res.status(401).json({ error: 'No available login methods' }); } if (token) { - await AuthEvent.create({ - user_id: user.id, + await AuthEventService.log({ + userId: user.id, type: 'login_success', - ip_address: req.ip, - user_agent: req.headers['user-agent'], + req, metadata: {}, }); @@ -197,37 +187,76 @@ export const login = async (req: Request, res: Response) => { return res.status(401).json({ error: 'Login failed.' }); } catch (error: unknown) { if (error instanceof Error) { - logger.error(`Error during login for email ${error.message}`); + logger.error(`Error during login: ${error.message}`); } else { logger.error(`Failed to login - ${String(error)}`); } - await AuthEvent.create({ - user_id: null, + await AuthEventService.log({ + userId: null, type: 'login_failed', - ip_address: req.ip, - user_agent: req.headers['user-agent'], + req, metadata: { reason: 'Catch all error' }, }); return res.status(500).json({ error: 'Server error' }); } }; -export const logout = async (req: Request, res: Response) => { +export const logoutCurrentSession = async (req: Request, res: Response) => { const authReq = req as AuthenticatedRequest; const authUser = authReq.user; - logger.info(`${authUser?.email} logged out.`); + const sessionId = authReq.sessionId; + logger.info('User logged out current session'); try { - const sessions = await Session.findAll({ where: { userId: authUser.id } }); + if (!sessionId) { + await AuthEventService.log({ + userId: authUser.id, + type: 'logout_suspicious', + req, + metadata: { reason: 'Access token did not include a session id' }, + }); + return res.status(401).json({ error: 'unauthorized' }); + } - sessions.forEach(async (session) => { - if (!session.revokedAt) { - await hardRevokeSession(session, 'user_logout'); - } + const session = await Session.findOne({ + where: { id: sessionId, userId: authUser.id, revokedAt: null }, + }); + + if (session) { + await hardRevokeSession(session, 'user_logout'); + } + + await AuthEventService.log({ + userId: authUser.id, + type: 'logout_success', + req, + metadata: { scope: 'current_session' }, }); + } catch (error) { + logger.error(`Error during logout: ${error}`); + await AuthEventService.log({ userId: authUser.id, type: 'logout_failed', req }); + } + + return res.json({ message: 'Success' }); +}; + +export const logoutAllSessions = async (req: Request, res: Response) => { + const authReq = req as AuthenticatedRequest; + const authUser = authReq.user; + logger.info('User logged out all sessions'); + + try { + const sessions = await Session.findAll({ where: { userId: authUser.id, revokedAt: null } }); + + await Promise.all(sessions.map((session) => hardRevokeSession(session, 'user_logout_all'))); - await AuthEventService.log({ userId: authUser.id, type: 'logout_success', req }); + await AuthEventService.log({ + userId: authUser.id, + type: 'logout_success', + req, + metadata: { scope: 'all_sessions', revokedSessions: sessions.length }, + }); } catch (error) { logger.error(`Error during logout: ${error}`); await AuthEventService.log({ userId: authUser.id, type: 'logout_failed', req }); @@ -236,6 +265,8 @@ export const logout = async (req: Request, res: Response) => { return res.json({ message: 'Success' }); }; +export const logout = logoutAllSessions; + export const refreshSession = async (req: Request, res: Response) => { logger.info(`Refreshing user token`); diff --git a/src/controllers/magicLinks.ts b/src/controllers/magicLinks.ts index 9716121..5b155fc 100644 --- a/src/controllers/magicLinks.ts +++ b/src/controllers/magicLinks.ts @@ -10,7 +10,6 @@ import { Op } from 'sequelize'; import { getSystemConfig } from '../config/getSystemConfig.js'; import { canReturnExternalDelivery } from '../lib/externalDelivery.js'; -import { AuthEvent } from '../models/authEvents.js'; import { MagicLinkToken } from '../models/magicLinks.js'; import { User } from '../models/users.js'; import { AuthEventService } from '../services/authEventService.js'; @@ -243,14 +242,13 @@ export async function pollMagicLinkConfirmation(req: Request, res: Response) { }); if (bootstrapResult.promoted) { - logger.info(`Bootstrap admin granted to ${user.email}`); + logger.info('Bootstrap admin granted'); } - await AuthEvent.create({ - user_id: user.id, + await AuthEventService.log({ + userId: user.id, type: 'registration_success', - ip_address: req.ip, - user_agent: req.headers['user-agent'], + req, metadata: {}, }); diff --git a/src/controllers/otp.ts b/src/controllers/otp.ts index 72f0c3b..7bb17a9 100644 --- a/src/controllers/otp.ts +++ b/src/controllers/otp.ts @@ -70,11 +70,11 @@ export const sendPhoneOTP = async (req: Request, res: Response) => { return res.status(400).json({ error: 'Invalid data' }); } - logger.info(`Sending OTP to phone number: ${phone}`); + logger.info('Sending phone OTP'); try { if (!isValidPhoneNumber(phone) || !normalizedPhone) { - logger.warn(`Invalid phone provided: ${phone}`); + logger.warn('Invalid phone provided'); AuthEventService.log({ userId: null, type: 'otp_suspicious', @@ -85,7 +85,7 @@ export const sendPhoneOTP = async (req: Request, res: Response) => { } if (!user) { - logger.error(`Attempted to send OTP to an unknown user: ${phone}`); + logger.error('Attempted to send OTP to an unknown user'); AuthEventService.log({ userId: null, type: 'otp_suspicious', @@ -95,7 +95,7 @@ export const sendPhoneOTP = async (req: Request, res: Response) => { return res.status(400).json({ error: 'Invalid data' }); } - logger.info(`${phone} requested a phone OTP`); + logger.info('User requested a phone OTP'); const generatedToken = await generatePhoneOTP(user, { sendMessage: !useExternalDelivery, }); @@ -140,7 +140,7 @@ export const sendEmailOTP = async (req: Request, res: Response) => { try { if (!user) { - logger.warn(`Attempted to send OTP to an unknown user: ${email}`); + logger.warn('Attempted to send OTP to an unknown user'); AuthEventService.log({ userId: null, type: 'otp_suspicious', @@ -161,10 +161,10 @@ export const sendEmailOTP = async (req: Request, res: Response) => { return res.status(400).json({ error: 'Invalid data.' }); } - logger.info(`Sending OTP to email: ${email}`); + logger.info('Sending email OTP'); if (!isValidEmail(email)) { - logger.error(`Invalid email provided: ${email}`); + logger.error('Invalid email provided'); AuthEventService.log({ userId: null, type: 'otp_suspicious', @@ -174,7 +174,7 @@ export const sendEmailOTP = async (req: Request, res: Response) => { return res.status(400).json({ error: 'Invalid data.' }); } - logger.info(`${email} requested an email OTP`); + logger.info('User requested an email OTP'); const generatedToken = await generateEmailOTP(user, { sendMessage: !useExternalDelivery, }); @@ -234,7 +234,7 @@ export const verifyPhoneNumber = async (req: Request, res: Response) => { const email = user.email; const phone = user.phone; - logger.info(`Verifying phone number: ${phone}`); + logger.info('Verifying phone number'); if (!user || !user.phoneVerificationTokenExpiry || !user.phoneVerificationToken) { logger.warn('Failed to find a user for this phone verification token'); @@ -265,7 +265,7 @@ export const verifyPhoneNumber = async (req: Request, res: Response) => { const verified = verificationResult.verified; if (verified) { - logger.info(`${phone} verifed their phone number`); + logger.info('User verified their phone number'); await AuthEventService.log({ userId: user.id, type: 'verify_otp_success', @@ -274,7 +274,7 @@ export const verifyPhoneNumber = async (req: Request, res: Response) => { }); if (user.phoneVerified && user.emailVerified && user.verified) { - logger.info(`${phone} is fully verified. Logging in...`); + logger.info('User is fully verified. Logging in...'); await AuthEventService.log({ userId: user.id, type: 'verify_otp_success', @@ -302,7 +302,7 @@ export const verifyEmail = async (req: Request, res: Response) => { const email = user.email; const phone = user.phone; - logger.info(`Verifying email: ${email}`); + logger.info('Verifying email'); if (!user || !user.emailVerificationTokenExpiry || !user.emailVerificationToken) { logger.warn(`Failed to find a user for this email verification token`); @@ -343,7 +343,7 @@ export const verifyEmail = async (req: Request, res: Response) => { const verified = verificationResult.verified; if (verified) { - logger.info(`${email} verifed their email`); + logger.info('User verified their email'); await AuthEventService.log({ userId: user.id, type: 'verify_otp_success', @@ -352,7 +352,7 @@ export const verifyEmail = async (req: Request, res: Response) => { }); if (user.phoneVerified && user.emailVerified && user.verified) { - logger.info(`${email} is fully verified. Logging in...`); + logger.info('User is fully verified. Logging in...'); await AuthEventService.log({ userId: user.id, @@ -401,7 +401,7 @@ export const verifyLoginPhoneNumber = async (req: Request, res: Response) => { return; } - logger.info(`Verifying login phone number: ${phone}`); + logger.info('Verifying login phone number'); if (!user || !user.phoneVerificationTokenExpiry || !user.phoneVerificationToken) { logger.warn('Failed to find a user for this phone verification token'); @@ -432,7 +432,7 @@ export const verifyLoginPhoneNumber = async (req: Request, res: Response) => { const verified = verificationResult.verified; if (verified) { - logger.info(`${phone} is verified for login.`); + logger.info('User phone is verified for login.'); await AuthEventService.log({ userId: user.id, type: 'verify_otp_success', @@ -440,7 +440,7 @@ export const verifyLoginPhoneNumber = async (req: Request, res: Response) => { }); if (user.phoneVerified && user.emailVerified && user.verified) { - logger.info(`${email} is fully verified. Logging in...`); + logger.info('User is fully verified. Logging in...'); await AuthEventService.log({ userId: user.id, @@ -498,7 +498,7 @@ export const verifyLoginEmail = async (req: Request, res: Response) => { return; } - logger.info(`Verifying login email: ${email}`); + logger.info('Verifying login email'); if (!user || !user.emailVerificationTokenExpiry || !user.emailVerificationToken) { logger.warn(`Failed to find a user for this email verification token`); @@ -539,7 +539,7 @@ export const verifyLoginEmail = async (req: Request, res: Response) => { const verified = verificationResult.verified; if (verified) { - logger.info(`${email} is verified for login.`); + logger.info('User email is verified for login.'); await AuthEventService.log({ userId: user.id, type: 'verify_otp_success', @@ -547,7 +547,7 @@ export const verifyLoginEmail = async (req: Request, res: Response) => { }); if (user.phoneVerified && user.emailVerified && user.verified) { - logger.info(`${email} is fully verified. Logging in...`); + logger.info('User is fully verified. Logging in...'); await AuthEventService.log({ userId: user.id, diff --git a/src/controllers/registration.ts b/src/controllers/registration.ts index 886465e..1e7df83 100644 --- a/src/controllers/registration.ts +++ b/src/controllers/registration.ts @@ -9,7 +9,6 @@ import { Request, Response } from 'express'; import { getSystemConfig } from '../config/getSystemConfig.js'; import { canReturnExternalDelivery } from '../lib/externalDelivery.js'; import { signEphemeralToken } from '../lib/token.js'; -import { AuthEvent } from '../models/authEvents.js'; import { User } from '../models/users.js'; import { AuthEventService } from '../services/authEventService.js'; import { @@ -37,7 +36,7 @@ export const register = async (req: Request, res: Response) => { try { if (!isValidEmail(email) || !isValidPhoneNumber(phone) || !normalizedPhone) { - logger.error(`Invalid email or phone provided: ${email} - ${phone}`); + logger.error('Invalid email or phone provided during registration'); await AuthEventService.log({ userId: null, type: 'registration_suspicious', @@ -61,7 +60,7 @@ export const register = async (req: Request, res: Response) => { (existingEmailUser && existingPhoneUser && existingEmailUser.id !== existingPhoneUser.id); if (hasIdentifierConflict) { - logger.warn(`Registration conflict for email ${normalizedEmail} and phone ${phone}`); + logger.warn('Registration conflict for supplied identifiers'); await AuthEventService.log({ userId: existingEmailUser?.id ?? existingPhoneUser?.id ?? null, type: 'registration_suspicious', @@ -139,7 +138,7 @@ export const register = async (req: Request, res: Response) => { reason: 'Owner notified of new user registration', }); - logger.info(`Sending phone OTP to ${normalizedPhone}`); + logger.info('Sending phone OTP for registration'); phoneOtp = await generatePhoneOTP(user, { sendMessage: !useExternalDelivery, }); @@ -170,16 +169,15 @@ export const register = async (req: Request, res: Response) => { }); } catch (error: unknown) { if (error instanceof Error) { - logger.error(`Error during registration for email ${email}: ${error}`); + logger.error(`Error during registration: ${error}`); } else { logger.error(`Error during registration: ${String(error)}`); } - await AuthEvent.create({ - user_id: null, + await AuthEventService.log({ + userId: null, type: 'registration_failed', - ip_address: req.ip, - user_agent: req.headers['user-agent'], + req, metadata: { reason: 'Catch all error' }, }); return res.status(500).json({ error: 'Internal server error' }); diff --git a/src/controllers/sessions.ts b/src/controllers/sessions.ts index 6114b15..baff6a1 100644 --- a/src/controllers/sessions.ts +++ b/src/controllers/sessions.ts @@ -7,6 +7,7 @@ import { Request, Response } from 'express'; import { Session } from '../models/sessions.js'; +import { serializeSession } from '../services/apiResponseSerializers.js'; import { hardRevokeSession } from '../services/sessionService.js'; import { AuthenticatedRequest } from '../types/types.js'; import getLogger from '../utils/logger.js'; @@ -30,15 +31,7 @@ export const listSessions = async (req: Request, res: Response) => { const currentSessionId = authReq.sessionId; - const response = sessions.map((session) => ({ - id: session.id, - deviceName: session.deviceName, - ipAddress: session.ipAddress, - userAgent: session.userAgent, - lastUsedAt: session.lastUsedAt.toISOString(), - expiresAt: session.expiresAt.toISOString(), - current: session.id === currentSessionId, - })); + const response = sessions.map((session) => serializeSession(session, currentSessionId)); return res.json({ sessions: response, total: response.length }); }; diff --git a/src/controllers/user.ts b/src/controllers/user.ts index 49e8a7d..9aabb17 100644 --- a/src/controllers/user.ts +++ b/src/controllers/user.ts @@ -6,9 +6,9 @@ import { Request, Response } from 'express'; -import { AuthEvent } from '../models/authEvents.js'; import { Credential } from '../models/credentials.js'; import { User } from '../models/users.js'; +import { serializeCredential } from '../services/apiResponseSerializers.js'; import { AuthEventService } from '../services/authEventService.js'; import { listOrganizationsForUser } from '../services/organizationService.js'; import { AuthenticatedRequest } from '../types/types.js'; @@ -54,7 +54,7 @@ export const getUser = async (req: Request, res: Response) => { lastLogin: authUser.lastLogin, activeOrganizationId, }, - credentials, + credentials: credentials.map(serializeCredential), organizations, activeOrganization: organizations.find((organization) => organization.id === activeOrganizationId) ?? null, @@ -84,7 +84,7 @@ export const deleteUser = async (req: Request, res: Response) => { return res.status(404).json({ message: 'User not found.' }); } - logger.info(`${authUser.email} trigger the deletion of their account`); + logger.info('Authenticated user triggered account deletion'); try { const user = await User.findOne({ @@ -95,40 +95,38 @@ export const deleteUser = async (req: Request, res: Response) => { }); if (user) { - logger.info(`Deleting all users credentials for ${user.email}.`); + logger.info('Deleting all user credentials'); const creds = await Credential.findAll({ where: { userId: user.id } }); creds.forEach((cred) => { cred.destroy(); }); - await AuthEvent.create({ - user_id: user.id || null, + await AuthEventService.log({ + userId: user.id || null, type: 'credentials_deleted', - ip_address: req.ip, - user_agent: req.headers['user-agent'], + req, metadata: { reason: 'User deleted account' }, }); logger.info(`All credentials deleted for ${user.id}.`); user.destroy(); - logger.info(`User ${user.email} deleted.`); + logger.info('User deleted'); - await AuthEvent.create({ - user_id: user?.id || null, + await AuthEventService.log({ + userId: user?.id || null, type: 'user_deleted', - ip_address: req.ip, - user_agent: req.headers['user-agent'], + req, metadata: { reason: 'User deleted account' }, }); } else { - logger.error(`Failed to destory a seemingly valid user ${authUser.email}`); + logger.error('Failed to destroy a seemingly valid user'); } return res.status(200).json({ message: 'Success' }); } catch (error: unknown) { - logger.error(`Failed to delete user: ${authUser.email}${error}`); + logger.error(`Failed to delete user: ${error}`); return res.status(500).json({ message: 'Failed' }); } } catch (error) { @@ -161,7 +159,7 @@ export const updateCredential = async (req: Request, res: Response) => { friendlyName: friendlyName ?? cred.friendlyName, }); - return res.json({ message: 'Credential updated', credential: cred }); + return res.json({ message: 'Credential updated', credential: serializeCredential(cred) }); } catch (err) { logger.error(`Failed to update credential: ${err}`); return res.status(500).json({ error: 'Failed to update credential' }); diff --git a/src/controllers/webauthn.ts b/src/controllers/webauthn.ts index 307e32d..3997dec 100644 --- a/src/controllers/webauthn.ts +++ b/src/controllers/webauthn.ts @@ -22,7 +22,6 @@ import { containsPrfOutput, getRegistrationPrfCapable, } from '../lib/webauthnPrf.js'; -import { AuthEvent } from '../models/authEvents.js'; import { Credential } from '../models/credentials.js'; import { User } from '../models/users.js'; import { AuthEventService } from '../services/authEventService.js'; @@ -77,7 +76,7 @@ const registerWebAuthn = async (req: Request, res: Response) => { requirePrf?: boolean; }; const prfRequested = requestPrf || requirePrf; - logger.info(`Registering passwordless mechanism for ${authReq.user?.email}`); + logger.info('Registering passwordless mechanism'); if (!verifiedUser) { logger.error('Invalid registration user attempt'); @@ -92,7 +91,7 @@ const registerWebAuthn = async (req: Request, res: Response) => { } if (!verifiedUser.id || !verifiedUser.email) { - logger.error(`Invalid registration user attempt ${verifiedUser}`); + logger.error('Invalid registration user attempt'); await AuthEventService.log({ userId: null, type: 'webauthn_registration_suspicious', @@ -137,7 +136,7 @@ const registerWebAuthn = async (req: Request, res: Response) => { }, }); - logger.info(`Generated registration options for user ${verifiedUser.email}`); + logger.info('Generated registration options for user'); await AuthEventService.log({ userId: verifiedUser.id, @@ -162,17 +161,16 @@ const verifyWebAuthnRegistration = async (req: Request, res: Response) => { const authReq = req as AuthenticatedRequest; const verifiedUser = authReq.user; - logger.info(`Verifiying registration of passwordless mechanism for ${authReq.user?.email}`); + logger.info('Verifying registration of passwordless mechanism'); try { const { attestationResponse, metadata = {} } = req.body; if (!verifiedUser) { logger.warn('Missing attestation response for WebAuthn registration'); - await AuthEvent.create({ - user_id: null, + await AuthEventService.log({ + userId: null, type: 'registration_failed', - ip_address: req.ip, - user_agent: req.headers['user-agent'], + req, metadata: { reason: 'No verified user' }, }); return res.status(403).json({ message: 'Not allowed' }); @@ -180,11 +178,10 @@ const verifyWebAuthnRegistration = async (req: Request, res: Response) => { if (!verifiedUser.email || !attestationResponse) { logger.warn('Missing verified user email or attestation response'); - await AuthEvent.create({ - user_id: null, + await AuthEventService.log({ + userId: null, type: 'registration_failed', - ip_address: req.ip, - user_agent: req.headers['user-agent'], + req, metadata: { reason: 'No verified user' }, }); return res.status(403).json({ message: 'Not allowed' }); @@ -195,12 +192,11 @@ const verifyWebAuthnRegistration = async (req: Request, res: Response) => { }); if (!user) { - logger.error(`Verification attempt for unknown user: ${verifiedUser.email}`); - await AuthEvent.create({ - user_id: null, + logger.error('Verification attempt for unknown user'); + await AuthEventService.log({ + userId: null, type: 'registration_suspicious', - ip_address: req.ip, - user_agent: req.headers['user-agent'], + req, metadata: { reason: 'Verified user with no user record' }, }); return res.status(403).json({ message: 'Not allowed' }); @@ -230,11 +226,10 @@ const verifyWebAuthnRegistration = async (req: Request, res: Response) => { }); } catch (error) { logger.error(`Error perfroming webAuthn verification ${error}`); - await AuthEvent.create({ - user_id: user.id, + await AuthEventService.log({ + userId: user.id, type: 'registration_failed', - ip_address: req.ip, - user_agent: req.headers['user-agent'], + req, metadata: { reason: 'Verification failed' }, }); return res.status(500).json({ message: 'An error occured will verifying. Try again' }); @@ -243,12 +238,11 @@ const verifyWebAuthnRegistration = async (req: Request, res: Response) => { const { verified, registrationInfo } = verification; if (!verified || !registrationInfo) { - logger.error(`Failed registration verification for user: ${verifiedUser.email}`); - await AuthEvent.create({ - user_id: user.id, + logger.error('Failed registration verification for user'); + await AuthEventService.log({ + userId: user.id, type: 'registration_failed', - ip_address: req.ip, - user_agent: req.headers['user-agent'], + req, metadata: { reason: 'Verification failed' }, }); return res.status(403).json({ message: 'Registration failed verification' }); @@ -304,14 +298,13 @@ const verifyWebAuthnRegistration = async (req: Request, res: Response) => { }); if (bootstrapResult.promoted) { - logger.info(`Bootstrap admin granted to ${user.email}`); + logger.info('Bootstrap admin granted'); } - await AuthEvent.create({ - user_id: user.id, + await AuthEventService.log({ + userId: user.id, type: 'registration_success', - ip_address: req.ip, - user_agent: req.headers['user-agent'], + req, metadata: {}, }); @@ -342,7 +335,7 @@ const generateWebAuthn = async (req: Request, res: Response) => { const verifiedUser = authReq.user; const { credentialId, prf } = req.body ?? {}; - logger.info(`Generating passwordless login for ${verifiedUser.email}`); + logger.info('Generating passwordless login'); const email = verifiedUser.email; const phone = verifiedUser.phone; let user = verifiedUser; @@ -350,11 +343,10 @@ const generateWebAuthn = async (req: Request, res: Response) => { if (!phone && !email) { logger.warn('No pre authenticated identifier found'); - await AuthEvent.create({ - user_id: null, + await AuthEventService.log({ + userId: null, type: 'login_failed', - ip_address: req.ip, - user_agent: req.headers['user-agent'], + req, metadata: { reason: 'No identifier' }, }); return res.status(403).json({ message: 'Not allowed' }); @@ -362,11 +354,10 @@ const generateWebAuthn = async (req: Request, res: Response) => { if (!user) { logger.warn('Failed to find a user for generating passkey challenge during auth'); - await AuthEvent.create({ - user_id: null, + await AuthEventService.log({ + userId: null, type: 'login_failed', - ip_address: req.ip, - user_agent: req.headers['user-agent'], + req, metadata: { reason: 'No user' }, }); return res.status(401).send('Not allowed'); @@ -381,11 +372,10 @@ const generateWebAuthn = async (req: Request, res: Response) => { }); if (!assertionCredentials || assertionCredentials.length === 0) { - await AuthEvent.create({ - user_id: user.id, + await AuthEventService.log({ + userId: user.id, type: 'login_failed', - ip_address: req.ip, - user_agent: req.headers['user-agent'], + req, metadata: { reason: prf ? 'No PRF-capable credentials' : 'No credentials' }, }); logger.error('Valid user with no credentials'); @@ -411,11 +401,10 @@ const generateWebAuthn = async (req: Request, res: Response) => { challenge: options.challenge, }); - await AuthEvent.create({ - user_id: null, + await AuthEventService.log({ + userId: null, type: 'login_challenge', - ip_address: req.ip, - user_agent: req.headers['user-agent'], + req, metadata: { reason: '' }, }); return res.json(options); @@ -423,11 +412,10 @@ const generateWebAuthn = async (req: Request, res: Response) => { if (error instanceof Error) { logger.error('Failed to generate options for login stack trace redacted'); } - await AuthEvent.create({ - user_id: null, + await AuthEventService.log({ + userId: null, type: 'login_failed', - ip_address: req.ip, - user_agent: req.headers['user-agent'], + req, metadata: { reason: 'Catch all error' }, }); return res.status(500).json({ message: 'Internal server error' }); @@ -438,7 +426,7 @@ const verifyWebAuthn = async (req: Request, res: Response) => { const authReq = req as AuthenticatedRequest; const verifiedUser = authReq.user; - logger.info(`Verifying passwordless login for ${verifiedUser.email}`); + logger.info('Verifying passwordless login'); try { const { assertionResponse } = req.body; @@ -463,11 +451,10 @@ const verifyWebAuthn = async (req: Request, res: Response) => { if (!phone && !email) { logger.error('No pre authenticated Identifier found'); - await AuthEvent.create({ - user_id: null, + await AuthEventService.log({ + userId: null, type: 'login_failed', - ip_address: req.ip, - user_agent: req.headers['user-agent'], + req, metadata: { reason: 'No identifier' }, }); return res.status(403).json({ message: 'Not allowed' }); @@ -490,7 +477,7 @@ const verifyWebAuthn = async (req: Request, res: Response) => { }); if (!cred) { - logger.error(`Failed to find the credental for the user ${assertionResponse.id}`); + logger.error('Failed to find the credential for the user'); await AuthEventService.log({ userId: user.id, @@ -522,10 +509,6 @@ const verifyWebAuthn = async (req: Request, res: Response) => { }); } catch (error) { logger.error(`Verification failed in webAuthn for login: ${error}`); - - if (error instanceof Error) { - logger.error(`Verification failed error stack: ${error.stack}`); - } await AuthEventService.log({ userId: user.id, type: 'webauthn_login_failed', @@ -568,11 +551,10 @@ const verifyWebAuthn = async (req: Request, res: Response) => { } } catch (error) { logger.error(`Error occured validating passkey on login: ${error}`); - await AuthEvent.create({ - user_id: null, + await AuthEventService.log({ + userId: null, type: 'login_failed', - ip_address: req.ip, - user_agent: req.headers['user-agent'], + req, metadata: { reason: 'Catch all error' }, }); res.status(500).json({ error: 'Internal Server error' }); diff --git a/src/middleware/attachAuthMiddleware.ts b/src/middleware/attachAuthMiddleware.ts index f66e692..2b0a220 100644 --- a/src/middleware/attachAuthMiddleware.ts +++ b/src/middleware/attachAuthMiddleware.ts @@ -18,7 +18,8 @@ export function getSecuritySchemeName(_authType: AuthTokenType): string { } export function attachAuthMiddleware(authType: AuthTokenType = 'access') { - const handler = verifyBearerAuth as AuthAwareRequestHandler; + const handler = ((req, res, next) => + verifyBearerAuth(req, res, next, authType)) as AuthAwareRequestHandler; handler.seamlessAuthType = authType; diff --git a/src/middleware/authenticateServiceToken.ts b/src/middleware/authenticateServiceToken.ts index 950cb89..d1d7c86 100644 --- a/src/middleware/authenticateServiceToken.ts +++ b/src/middleware/authenticateServiceToken.ts @@ -46,7 +46,7 @@ export async function verifyServiceToken(req: ServiceRequest, res: Response, nex const token = authHeader.replace('Bearer ', ''); if (!token) { - logger.error(`Call to internal endpoints missing bearer token. Headers: ${req.headers}`); + logger.error('Call to internal endpoints missing bearer token.'); return res.status(401).json({ error: 'No token provided' }); } diff --git a/src/middleware/rateLimit.ts b/src/middleware/rateLimit.ts index 9dbbadf..2f4130f 100644 --- a/src/middleware/rateLimit.ts +++ b/src/middleware/rateLimit.ts @@ -8,19 +8,38 @@ import { NextFunction, Request, Response } from 'express'; import rateLimit from 'express-rate-limit'; import { getSystemConfig } from '../config/getSystemConfig.js'; +import { AuthenticatedRequest } from '../types/types.js'; + +let dynamicLimiter: ReturnType | null = null; +let dynamicLimit: number | null = null; +let magicLinkIpCachedLimiter: ReturnType | null = null; +let magicLinkIdentityCachedLimiter: ReturnType | null = null; + +function getMagicLinkIdentityKey(req: Request) { + const authReq = req as AuthenticatedRequest; + const body = req.body as { email?: unknown } | undefined; + const query = req.query as { email?: unknown } | undefined; + const email = + authReq.user?.email ?? + (typeof body?.email === 'string' ? body.email : undefined) ?? + (typeof query?.email === 'string' ? query.email : undefined); + + if (email) { + return `email:${email.toLowerCase()}`; + } -let cachedLimiter: ReturnType | null = null; -let cachedLimit: number | null = null; + return `ip:${req.ip ?? req.socket.remoteAddress ?? 'unknown'}`; +} export async function dynamicRateLimit(req: Request, res: Response, next: NextFunction) { const { rate_limit } = await getSystemConfig(); const limit = rate_limit ?? 50; - if (!cachedLimiter || cachedLimit !== limit) { - cachedLimit = limit; + if (!dynamicLimiter || dynamicLimit !== limit) { + dynamicLimit = limit; - cachedLimiter = rateLimit({ + dynamicLimiter = rateLimit({ windowMs: 1 * 60 * 1000, max: limit, standardHeaders: true, @@ -29,18 +48,12 @@ export async function dynamicRateLimit(req: Request, res: Response, next: NextFu }); } - return cachedLimiter(req, res, next); + return dynamicLimiter(req, res, next); } export async function magicLinkIpLimiter(req: Request, res: Response, next: NextFunction) { - const { rate_limit } = await getSystemConfig(); - - const limit = rate_limit ?? 50; - - if (!cachedLimiter || cachedLimit !== limit) { - cachedLimit = limit; - - cachedLimiter = rateLimit({ + if (!magicLinkIpCachedLimiter) { + magicLinkIpCachedLimiter = rateLimit({ windowMs: 15 * 60 * 1000, max: 20, standardHeaders: true, @@ -48,23 +61,19 @@ export async function magicLinkIpLimiter(req: Request, res: Response, next: Next }); } - return cachedLimiter(req, res, next); + return magicLinkIpCachedLimiter(req, res, next); } export async function magicLinkEmailLimiter(req: Request, res: Response, next: NextFunction) { - const { rate_limit } = await getSystemConfig(); - - const limit = rate_limit ?? 50; - - if (!cachedLimiter || cachedLimit !== limit) { - cachedLimit = limit; - - cachedLimiter = rateLimit({ + if (!magicLinkIdentityCachedLimiter) { + magicLinkIdentityCachedLimiter = rateLimit({ windowMs: 15 * 60 * 1000, max: 5, - keyGenerator: (req) => req.body.email ?? req.ip, + keyGenerator: getMagicLinkIdentityKey, + standardHeaders: true, + legacyHeaders: false, }); } - return cachedLimiter(req, res, next); + return magicLinkIdentityCachedLimiter(req, res, next); } diff --git a/src/middleware/verifyBearerAuth.ts b/src/middleware/verifyBearerAuth.ts index cc60bc0..36dac90 100644 --- a/src/middleware/verifyBearerAuth.ts +++ b/src/middleware/verifyBearerAuth.ts @@ -6,13 +6,18 @@ import { NextFunction, Request, Response } from 'express'; -import { validateBearerToken } from '../services/sessionService.js'; +import { AuthTokenType, validateBearerToken } from '../services/sessionService.js'; import { AuthenticatedRequest } from '../types/types.js'; import getLogger from '../utils/logger.js'; const logger = getLogger('verifyBearerAuth'); -export async function verifyBearerAuth(req: Request, res: Response, next: NextFunction) { +export async function verifyBearerAuth( + req: Request, + res: Response, + next: NextFunction, + authType: AuthTokenType = 'access', +) { const auth = req.headers.authorization; if (!auth?.startsWith('Bearer ')) { logger.error('Missing bearer token for authentication request'); @@ -21,9 +26,9 @@ export async function verifyBearerAuth(req: Request, res: Response, next: NextFu const token = auth.slice(7); try { - const result = await validateBearerToken(token); + const result = await validateBearerToken(token, authType); if (!result) { - logger.error('No user found for service bearer token'); + logger.error(`Invalid ${authType} bearer token`); return res.status(401).json({ error: 'unauthorized' }); } (req as AuthenticatedRequest).user = result.user; diff --git a/src/models/authEvents.ts b/src/models/authEvents.ts index 3dba06d..580eb2b 100644 --- a/src/models/authEvents.ts +++ b/src/models/authEvents.ts @@ -45,6 +45,10 @@ export class AuthEvent } } +function redactAuthEventMetadata(event: AuthEvent) { + event.metadata = redactMetadata(event.metadata); +} + const initializeAuthEventModel = (sequelize: Sequelize) => { AuthEvent.init( { @@ -88,12 +92,13 @@ const initializeAuthEventModel = (sequelize: Sequelize) => { tableName: 'auth_events', underscored: true, hooks: { - beforeValidate(event) { - event.metadata = redactMetadata(event.metadata); - }, + beforeValidate: redactAuthEventMetadata, + beforeCreate: redactAuthEventMetadata, + beforeUpdate: redactAuthEventMetadata, + beforeSave: redactAuthEventMetadata, beforeBulkCreate(events) { for (const event of events) { - event.metadata = redactMetadata(event.metadata); + redactAuthEventMetadata(event); } }, }, diff --git a/src/routes/admin.routes.ts b/src/routes/admin.routes.ts index 37a2291..90640ec 100644 --- a/src/routes/admin.routes.ts +++ b/src/routes/admin.routes.ts @@ -39,6 +39,8 @@ import { UpdateUserSchema, } from '../schemas/admin.requests.js'; import { + AdminUserAnomaliesResponseSchema, + AdminUserDetailResponseSchema, DeviceReplacementRecoveryResponseSchema, UserResponseSchema, } from '../schemas/admin.responses.js'; @@ -57,6 +59,12 @@ import { UpdateOrganizationMemberRequestSchema, UpdateOrganizationRequestSchema, } from '../schemas/organization.requests.js'; +import { + AdminOrganizationListResponseSchema, + OrganizationEnvelopeResponseSchema, + OrganizationMembershipEnvelopeResponseSchema, + OrganizationMembersResponseSchema, +} from '../schemas/organization.responses.js'; import { SessionIdParamsSchema } from '../schemas/session.params.js'; import { SessionListResponseSchema } from '../schemas/session.responses.js'; @@ -69,6 +77,11 @@ adminRouter.get( summary: 'List organizations', tags: ['Admin'], middleware: [requireAdmin('read')], + schemas: { + response: { + 200: AdminOrganizationListResponseSchema, + }, + }, }, listAdminOrganizations, ); @@ -82,6 +95,9 @@ adminRouter.post( middleware: [requireAdmin('write')], schemas: { body: CreateOrganizationRequestSchema, + response: { + 201: OrganizationEnvelopeResponseSchema, + }, }, }, createOrganization, @@ -96,6 +112,10 @@ adminRouter.get( middleware: [requireAdmin('read')], schemas: { params: OrganizationIdParamSchema, + response: { + 200: OrganizationEnvelopeResponseSchema, + 404: InternalErrorSchema, + }, }, }, getOrganization, @@ -111,6 +131,10 @@ adminRouter.patch( schemas: { params: OrganizationIdParamSchema, body: UpdateOrganizationRequestSchema, + response: { + 200: OrganizationEnvelopeResponseSchema, + 404: InternalErrorSchema, + }, }, }, updateOrganization, @@ -125,6 +149,10 @@ adminRouter.get( middleware: [requireAdmin('read')], schemas: { params: OrganizationIdParamSchema, + response: { + 200: OrganizationMembersResponseSchema, + 404: InternalErrorSchema, + }, }, }, listMembers, @@ -140,6 +168,11 @@ adminRouter.post( schemas: { params: OrganizationIdParamSchema, body: AddOrganizationMemberRequestSchema, + response: { + 201: OrganizationMembershipEnvelopeResponseSchema, + 404: InternalErrorSchema, + 409: InternalErrorSchema, + }, }, }, addMember, @@ -155,6 +188,11 @@ adminRouter.patch( schemas: { params: OrganizationMemberParamSchema, body: UpdateOrganizationMemberRequestSchema, + response: { + 200: OrganizationMembershipEnvelopeResponseSchema, + 400: InternalErrorSchema, + 404: InternalErrorSchema, + }, }, }, updateMember, @@ -171,6 +209,8 @@ adminRouter.delete( params: OrganizationMemberParamSchema, response: { 200: MessageSchema, + 400: InternalErrorSchema, + 404: InternalErrorSchema, }, }, }, @@ -237,6 +277,9 @@ adminRouter.post( middleware: [requireAdmin('write')], schemas: { body: CreateUserSchema, + response: { + 201: UserResponseSchema, + }, }, }, createUser, @@ -310,6 +353,13 @@ adminRouter.get( auth: 'access', tags: ['Admin'], middleware: [requireAdmin('read')], + schemas: { + params: UserIdParamSchema, + response: { + 200: AdminUserDetailResponseSchema, + 404: InternalErrorSchema, + }, + }, }, getUserDetail, ); @@ -320,6 +370,13 @@ adminRouter.get( auth: 'access', tags: ['Admin'], middleware: [requireAdmin('read')], + schemas: { + params: UserIdParamSchema, + response: { + 200: AdminUserAnomaliesResponseSchema, + 500: InternalErrorSchema, + }, + }, }, getUserAnomalies, ); @@ -332,6 +389,9 @@ adminRouter.get( middleware: [requireAdmin('read')], schemas: { query: PaginationQuerySchema, + response: { + 200: SessionListResponseSchema, + }, }, }, listAllSessions, diff --git a/src/routes/auth.routes.ts b/src/routes/auth.routes.ts index 2235271..bf6b93c 100644 --- a/src/routes/auth.routes.ts +++ b/src/routes/auth.routes.ts @@ -4,7 +4,13 @@ * See LICENSE file in the project root for full license information */ -import { login, logout, refreshSession } from '../controllers/authentication.js'; +import { + login, + logout, + logoutAllSessions, + logoutCurrentSession, + refreshSession, +} from '../controllers/authentication.js'; import { createRouter } from '../lib/createRouter.js'; import { LoginRequestSchema } from '../schemas/auth.requests.js'; import { @@ -40,7 +46,7 @@ authRouter.get( '/logout', { auth: 'access', - summary: 'Logout current user', + summary: 'Logout all sessions for the current user (deprecated; use DELETE /logout/all)', tags: ['Authentication'], schemas: { @@ -52,6 +58,39 @@ authRouter.get( logout, ); +authRouter.delete( + '/logout', + { + auth: 'access', + summary: 'Logout current session', + tags: ['Authentication'], + + schemas: { + response: { + 200: MessageSchema, + 401: ErrorSchema, + }, + }, + }, + logoutCurrentSession, +); + +authRouter.delete( + '/logout/all', + { + auth: 'access', + summary: 'Logout all sessions for the current user', + tags: ['Authentication'], + + schemas: { + response: { + 200: MessageSchema, + }, + }, + }, + logoutAllSessions, +); + authRouter.post( '/refresh', { diff --git a/src/routes/internal.routes.ts b/src/routes/internal.routes.ts index 80999e7..2cdc825 100644 --- a/src/routes/internal.routes.ts +++ b/src/routes/internal.routes.ts @@ -14,7 +14,15 @@ import { import { getSecurityAnomalies } from '../controllers/internalSecurity.js'; import { createRouter } from '../lib/createRouter.js'; import { requireAdmin } from '../middleware/requireAdmin.js'; +import { MessageSchema } from '../schemas/generic.responses.js'; import { MetricsQuerySchema } from '../schemas/internal.query.js'; +import { + AuthEventSummaryResponseSchema, + AuthEventTimeseriesResponseSchema, + DashboardMetricsResponseSchema, + LoginStatsResponseSchema, + SecurityAnomaliesResponseSchema, +} from '../schemas/internalMetrics.responses.js'; const internalRouter = createRouter('/internal'); @@ -26,6 +34,11 @@ internalRouter.get( tags: ['Internal'], schemas: { query: MetricsQuerySchema, + response: { + 200: AuthEventSummaryResponseSchema, + 400: MessageSchema, + 500: MessageSchema, + }, }, }, getAuthEventSummary, @@ -39,6 +52,11 @@ internalRouter.get( tags: ['Internal'], schemas: { query: MetricsQuerySchema, + response: { + 200: AuthEventTimeseriesResponseSchema, + 400: MessageSchema, + 500: MessageSchema, + }, }, }, getAuthEventTimeseries, @@ -50,6 +68,12 @@ internalRouter.get( auth: 'access', middleware: [requireAdmin('read')], tags: ['Internal'], + schemas: { + response: { + 200: LoginStatsResponseSchema, + 500: MessageSchema, + }, + }, }, getLoginStats, ); @@ -61,6 +85,12 @@ internalRouter.get( middleware: [requireAdmin('read')], summary: 'Detect suspicious activity', tags: ['Internal'], + schemas: { + response: { + 200: SecurityAnomaliesResponseSchema, + 500: MessageSchema, + }, + }, }, getSecurityAnomalies, ); @@ -72,6 +102,12 @@ internalRouter.get( middleware: [requireAdmin('read')], summary: 'Dashboard metrics', tags: ['Internal'], + schemas: { + response: { + 200: DashboardMetricsResponseSchema, + 500: MessageSchema, + }, + }, }, getDashboardMetrics, ); @@ -83,6 +119,12 @@ internalRouter.get( middleware: [requireAdmin('read')], summary: 'Auth Event metrics grouped', tags: ['Internal'], + schemas: { + response: { + 200: AuthEventSummaryResponseSchema, + 500: MessageSchema, + }, + }, }, getGroupedEventSummary, ); diff --git a/src/routes/oauth.routes.ts b/src/routes/oauth.routes.ts index 751379f..5f0bf0c 100644 --- a/src/routes/oauth.routes.ts +++ b/src/routes/oauth.routes.ts @@ -6,11 +6,17 @@ import { finishOAuthLogin, listOAuthProviders, startOAuthLogin } from '../controllers/oauth.js'; import { createRouter } from '../lib/createRouter.js'; +import { InternalErrorSchema } from '../schemas/generic.responses.js'; import { FinishOAuthLoginRequestSchema, OAuthProviderParamSchema, StartOAuthLoginRequestSchema, } from '../schemas/oauth.requests.js'; +import { + OAuthLoginSuccessResponseSchema, + OAuthProvidersResponseSchema, + StartOAuthLoginResponseSchema, +} from '../schemas/oauth.responses.js'; const oauthRouter = createRouter('/oauth'); @@ -19,6 +25,11 @@ oauthRouter.get( { summary: 'List enabled OAuth providers', tags: ['OAuth'], + schemas: { + response: { + 200: OAuthProvidersResponseSchema, + }, + }, }, listOAuthProviders, ); @@ -31,6 +42,11 @@ oauthRouter.post( schemas: { params: OAuthProviderParamSchema, body: StartOAuthLoginRequestSchema, + response: { + 200: StartOAuthLoginResponseSchema, + 400: InternalErrorSchema, + 404: InternalErrorSchema, + }, }, }, startOAuthLogin, @@ -44,6 +60,12 @@ oauthRouter.post( schemas: { params: OAuthProviderParamSchema, body: FinishOAuthLoginRequestSchema, + response: { + 200: OAuthLoginSuccessResponseSchema, + 400: InternalErrorSchema, + 403: InternalErrorSchema, + 404: InternalErrorSchema, + }, }, }, finishOAuthLogin, diff --git a/src/routes/organization.routes.ts b/src/routes/organization.routes.ts index e9e6e3b..73620c4 100644 --- a/src/routes/organization.routes.ts +++ b/src/routes/organization.routes.ts @@ -16,7 +16,7 @@ import { updateOrganization, } from '../controllers/organizations.js'; import { createRouter } from '../lib/createRouter.js'; -import { MessageSchema } from '../schemas/generic.responses.js'; +import { InternalErrorSchema, MessageSchema } from '../schemas/generic.responses.js'; import { AddOrganizationMemberRequestSchema, CreateOrganizationRequestSchema, @@ -25,6 +25,13 @@ import { UpdateOrganizationMemberRequestSchema, UpdateOrganizationRequestSchema, } from '../schemas/organization.requests.js'; +import { + OrganizationEnvelopeResponseSchema, + OrganizationListResponseSchema, + OrganizationMembershipEnvelopeResponseSchema, + OrganizationMembersResponseSchema, + OrganizationSwitchResponseSchema, +} from '../schemas/organization.responses.js'; const organizationRouter = createRouter('/organizations'); @@ -34,6 +41,11 @@ organizationRouter.get( auth: 'access', tags: ['Organizations'], summary: 'List organizations for the authenticated user', + schemas: { + response: { + 200: OrganizationListResponseSchema, + }, + }, }, listOrganizations, ); @@ -46,6 +58,9 @@ organizationRouter.post( summary: 'Create organization', schemas: { body: CreateOrganizationRequestSchema, + response: { + 201: OrganizationEnvelopeResponseSchema, + }, }, }, createOrganization, @@ -59,6 +74,10 @@ organizationRouter.get( summary: 'Get organization', schemas: { params: OrganizationIdParamSchema, + response: { + 200: OrganizationEnvelopeResponseSchema, + 404: InternalErrorSchema, + }, }, }, getOrganization, @@ -73,6 +92,10 @@ organizationRouter.patch( schemas: { params: OrganizationIdParamSchema, body: UpdateOrganizationRequestSchema, + response: { + 200: OrganizationEnvelopeResponseSchema, + 404: InternalErrorSchema, + }, }, }, updateOrganization, @@ -86,6 +109,11 @@ organizationRouter.post( summary: 'Switch active organization for the current session', schemas: { params: OrganizationIdParamSchema, + response: { + 200: OrganizationSwitchResponseSchema, + 400: InternalErrorSchema, + 404: InternalErrorSchema, + }, }, }, switchOrganization, @@ -99,6 +127,10 @@ organizationRouter.get( summary: 'List organization members', schemas: { params: OrganizationIdParamSchema, + response: { + 200: OrganizationMembersResponseSchema, + 404: InternalErrorSchema, + }, }, }, listMembers, @@ -113,6 +145,11 @@ organizationRouter.post( schemas: { params: OrganizationIdParamSchema, body: AddOrganizationMemberRequestSchema, + response: { + 201: OrganizationMembershipEnvelopeResponseSchema, + 404: InternalErrorSchema, + 409: InternalErrorSchema, + }, }, }, addMember, @@ -127,6 +164,11 @@ organizationRouter.patch( schemas: { params: OrganizationMemberParamSchema, body: UpdateOrganizationMemberRequestSchema, + response: { + 200: OrganizationMembershipEnvelopeResponseSchema, + 400: InternalErrorSchema, + 404: InternalErrorSchema, + }, }, }, updateMember, @@ -142,6 +184,8 @@ organizationRouter.delete( params: OrganizationMemberParamSchema, response: { 200: MessageSchema, + 400: InternalErrorSchema, + 404: InternalErrorSchema, }, }, }, diff --git a/src/routes/systemConfig.routes.ts b/src/routes/systemConfig.routes.ts index 44efae7..a3316a3 100644 --- a/src/routes/systemConfig.routes.ts +++ b/src/routes/systemConfig.routes.ts @@ -12,7 +12,9 @@ import { import { createRouter } from '../lib/createRouter.js'; import { requireAdmin } from '../middleware/requireAdmin.js'; import { ErrorSchema, InternalErrorSchema } from '../schemas/generic.responses.js'; +import { SystemConfigPatchSchema } from '../schemas/systemConfig.patch.schema.js'; import { + AvailableRolesResponseSchema, GetSystemConfigResponseSchema, InvalidPayloadSchema, UpdateSystemConfigResponseSchema, @@ -28,6 +30,11 @@ systemConfigRouter.get( tags: ['SystemConfig'], middleware: [requireAdmin('read')], + schemas: { + response: { + 200: AvailableRolesResponseSchema, + }, + }, }, getAvailableRoles, ); @@ -62,6 +69,7 @@ systemConfigRouter.patch( middleware: [requireAdmin('write')], schemas: { + body: SystemConfigPatchSchema, response: { 200: UpdateSystemConfigResponseSchema, 400: InvalidPayloadSchema, diff --git a/src/routes/users.routes.ts b/src/routes/users.routes.ts index ed14080..9c2a163 100644 --- a/src/routes/users.routes.ts +++ b/src/routes/users.routes.ts @@ -8,6 +8,7 @@ import { DeleteCredentialRequestSchema, UpdateCredentialRequestSchema } from '@s import { deleteCredential, deleteUser, getUser, updateCredential } from '../controllers/user.js'; import { createRouter } from '../lib/createRouter.js'; +import { CredentialUpdateResponseSchema } from '../schemas/credential.responses.js'; import { MessageSchema } from '../schemas/generic.responses.js'; import { MeResponseSchema } from '../schemas/me.response.js'; @@ -35,6 +36,9 @@ usersRouter.post( schemas: { body: UpdateCredentialRequestSchema, + response: { + 200: CredentialUpdateResponseSchema, + }, }, }, updateCredential, @@ -63,6 +67,9 @@ usersRouter.delete( schemas: { body: DeleteCredentialRequestSchema, + response: { + 200: MessageSchema, + }, }, }, deleteCredential, diff --git a/src/schemas/admin.responses.ts b/src/schemas/admin.responses.ts index d393bd3..a573acd 100644 --- a/src/schemas/admin.responses.ts +++ b/src/schemas/admin.responses.ts @@ -4,8 +4,10 @@ * See LICENSE file in the project root for full license information */ +import { AuthEventSchema, SessionSchema } from '@seamless-auth/types'; import { z } from 'zod'; +import { CredentialResponseSchema } from './credential.responses.js'; import { ApiUserSchema } from './user.schema.js'; export const UserResponseSchema = z.object({ @@ -18,3 +20,16 @@ export const DeviceReplacementRecoveryResponseSchema = z.object({ removedCredentials: z.number().int().nonnegative(), disabledTotpCredentials: z.number().int().nonnegative(), }); + +export const AdminUserDetailResponseSchema = z.object({ + user: ApiUserSchema, + sessions: z.array(SessionSchema), + credentials: z.array(CredentialResponseSchema), + events: z.array(AuthEventSchema), +}); + +export const AdminUserAnomaliesResponseSchema = z.object({ + suspiciousEvents: z.array(AuthEventSchema), + relatedIps: z.array(z.string()), + relatedAgents: z.array(z.string()), +}); diff --git a/src/schemas/credential.responses.ts b/src/schemas/credential.responses.ts new file mode 100644 index 0000000..9c6d0f5 --- /dev/null +++ b/src/schemas/credential.responses.ts @@ -0,0 +1,19 @@ +/* + * Copyright © 2026 Fells Code, LLC + * Licensed under the GNU Affero General Public License v3.0 + * See LICENSE file in the project root for full license information + */ + +import { CredentialApiSchema } from '@seamless-auth/types'; +import { z } from 'zod'; + +export const CredentialResponseSchema = CredentialApiSchema.extend({ + backedup: z.boolean(), + backedUp: z.boolean(), + prfCapable: z.boolean().optional(), +}); + +export const CredentialUpdateResponseSchema = z.object({ + message: z.string(), + credential: CredentialResponseSchema, +}); diff --git a/src/schemas/internalMetrics.responses.ts b/src/schemas/internalMetrics.responses.ts new file mode 100644 index 0000000..9502b79 --- /dev/null +++ b/src/schemas/internalMetrics.responses.ts @@ -0,0 +1,66 @@ +/* + * Copyright © 2026 Fells Code, LLC + * Licensed under the GNU Affero General Public License v3.0 + * See LICENSE file in the project root for full license information + */ + +import { z } from 'zod'; + +const AuthEventSummaryItemSchema = z.object({ + type: z.string(), + count: z.number(), +}); + +export const AuthEventSummaryResponseSchema = z.object({ + summary: z.array(AuthEventSummaryItemSchema), +}); + +export const AuthEventTimeseriesResponseSchema = z.object({ + timeseries: z.array( + z.object({ + bucket: z.string(), + success: z.number(), + failed: z.number(), + }), + ), +}); + +export const LoginStatsResponseSchema = z.object({ + success: z.number(), + failed: z.number(), + successRate: z.number(), +}); + +const InternalAuthEventSchema = z.object({ + id: z.string().optional(), + user_id: z.string().nullable().optional(), + type: z.string(), + ip_address: z.string().nullable().optional(), + user_agent: z.string().nullable().optional(), + metadata: z.record(z.string(), z.unknown()).nullable().optional(), + created_at: z.coerce + .date() + .transform((date) => date.toISOString()) + .optional(), + updated_at: z.coerce + .date() + .transform((date) => date.toISOString()) + .optional(), +}); + +export const SecurityAnomaliesResponseSchema = z.object({ + suspiciousEvents: z.array(InternalAuthEventSchema), + total: z.number().int().nonnegative(), +}); + +export const DashboardMetricsResponseSchema = z.object({ + totalUsers: z.number(), + activeSessions: z.number(), + newUsers24h: z.number(), + loginSuccess24h: z.number(), + loginFailed24h: z.number(), + successRate24h: z.number(), + otpUsage24h: z.number(), + passkeyUsage24h: z.number(), + databaseSize: z.number(), +}); diff --git a/src/schemas/me.response.ts b/src/schemas/me.response.ts index 25f71a4..c7ab027 100644 --- a/src/schemas/me.response.ts +++ b/src/schemas/me.response.ts @@ -4,15 +4,12 @@ * See LICENSE file in the project root for full license information */ -import { CredentialApiSchema } from '@seamless-auth/types'; import { z } from 'zod'; +import { CredentialResponseSchema } from './credential.responses.js'; +import { OrganizationResponseSchema } from './organization.responses.js'; import { RoleNameSchema } from './roles.schema.js'; -const CredentialWithPrfSchema = CredentialApiSchema.extend({ - prfCapable: z.boolean().optional(), -}); - const MeUserSchema = z.object({ id: z.string(), email: z.email(), @@ -22,31 +19,9 @@ const MeUserSchema = z.object({ activeOrganizationId: z.string().nullable().optional(), }); -const OrganizationMembershipSchema = z.object({ - id: z.string(), - organizationId: z.string(), - userId: z.string(), - roles: z.array(z.string()), - scopes: z.array(z.string()), - createdAt: z.any(), - updatedAt: z.any(), -}); - -const OrganizationSchema = z.object({ - id: z.string(), - name: z.string(), - slug: z.string(), - createdByUserId: z.string().nullable(), - metadata: z.record(z.string(), z.unknown()).nullable(), - createdAt: z.any(), - updatedAt: z.any(), - membership: OrganizationMembershipSchema.optional(), - memberCount: z.number().optional(), -}); - export const MeResponseSchema = z.object({ user: MeUserSchema, - credentials: z.array(CredentialWithPrfSchema), - organizations: z.array(OrganizationSchema).optional(), - activeOrganization: OrganizationSchema.nullable().optional(), + credentials: z.array(CredentialResponseSchema), + organizations: z.array(OrganizationResponseSchema).optional(), + activeOrganization: OrganizationResponseSchema.nullable().optional(), }); diff --git a/src/schemas/oauth.responses.ts b/src/schemas/oauth.responses.ts new file mode 100644 index 0000000..022e82c --- /dev/null +++ b/src/schemas/oauth.responses.ts @@ -0,0 +1,29 @@ +/* + * Copyright © 2026 Fells Code, LLC + * Licensed under the GNU Affero General Public License v3.0 + * See LICENSE file in the project root for full license information + */ + +import { z } from 'zod'; + +import { RefreshSuccessResponseSchema } from './auth.responses.js'; + +export const PublicOAuthProviderSchema = z.object({ + id: z.string(), + name: z.string(), + scopes: z.array(z.string()), +}); + +export const OAuthProvidersResponseSchema = z.object({ + providers: z.array(PublicOAuthProviderSchema), +}); + +export const StartOAuthLoginResponseSchema = z.object({ + provider: PublicOAuthProviderSchema, + state: z.string(), + authorizationUrl: z.url(), +}); + +export const OAuthLoginSuccessResponseSchema = RefreshSuccessResponseSchema.omit({ + sessionId: true, +}); diff --git a/src/schemas/organization.responses.ts b/src/schemas/organization.responses.ts new file mode 100644 index 0000000..5a8f56e --- /dev/null +++ b/src/schemas/organization.responses.ts @@ -0,0 +1,70 @@ +/* + * Copyright © 2026 Fells Code, LLC + * Licensed under the GNU Affero General Public License v3.0 + * See LICENSE file in the project root for full license information + */ + +import { z } from 'zod'; + +const OrganizationMembershipUserSchema = z.object({ + id: z.string(), + email: z.email(), + phone: z.string(), + roles: z.array(z.string()), +}); + +export const OrganizationMembershipResponseSchema = z.object({ + id: z.string(), + organizationId: z.string(), + userId: z.string(), + roles: z.array(z.string()), + scopes: z.array(z.string()), + createdAt: z.any(), + updatedAt: z.any(), + user: OrganizationMembershipUserSchema.optional(), +}); + +export const OrganizationResponseSchema = z.object({ + id: z.string(), + name: z.string(), + slug: z.string(), + createdByUserId: z.string().nullable(), + metadata: z.record(z.string(), z.unknown()).nullable(), + createdAt: z.any(), + updatedAt: z.any(), + membership: OrganizationMembershipResponseSchema.optional(), + memberCount: z.number().int().nonnegative().optional(), +}); + +export const OrganizationEnvelopeResponseSchema = z.object({ + organization: OrganizationResponseSchema, +}); + +export const OrganizationListResponseSchema = z.object({ + organizations: z.array(OrganizationResponseSchema), + activeOrganizationId: z.string().nullable(), +}); + +export const AdminOrganizationListResponseSchema = z.object({ + organizations: z.array(OrganizationResponseSchema), + total: z.number().int().nonnegative(), +}); + +export const OrganizationMembersResponseSchema = z.object({ + members: z.array(OrganizationMembershipResponseSchema), + total: z.number().int().nonnegative(), +}); + +export const OrganizationMembershipEnvelopeResponseSchema = z.object({ + membership: OrganizationMembershipResponseSchema, +}); + +export const OrganizationSwitchResponseSchema = z.object({ + message: z.string(), + token: z.string(), + sub: z.string(), + sessionId: z.string(), + organizationId: z.string(), + organization: OrganizationResponseSchema, + ttl: z.number(), +}); diff --git a/src/schemas/systemConfig.patch.schema.ts b/src/schemas/systemConfig.patch.schema.ts index 71e015c..fec5ab3 100644 --- a/src/schemas/systemConfig.patch.schema.ts +++ b/src/schemas/systemConfig.patch.schema.ts @@ -14,7 +14,7 @@ import { SystemConfigSchema, } from './systemConfig.schema.js'; -const SystemConfigPatchSchema = z +export const SystemConfigPatchSchema = z .object({ app_name: SystemConfigSchema.shape.app_name.optional(), default_roles: SystemConfigSchema.shape.default_roles.optional(), diff --git a/src/schemas/systemConfig.responses.ts b/src/schemas/systemConfig.responses.ts index 6ad6eea..3075aca 100644 --- a/src/schemas/systemConfig.responses.ts +++ b/src/schemas/systemConfig.responses.ts @@ -13,6 +13,10 @@ export const UpdateSystemConfigResponseSchema = z.object({ updatedKeys: z.array(z.string()), }); +export const AvailableRolesResponseSchema = z.object({ + roles: z.array(z.string()), +}); + export const UnauthorizedSchema = z.object({ error: z.string(), }); diff --git a/src/schemas/user.schema.ts b/src/schemas/user.schema.ts index db4da45..54b5894 100644 --- a/src/schemas/user.schema.ts +++ b/src/schemas/user.schema.ts @@ -8,11 +8,20 @@ import { z } from 'zod'; import { RoleNameSchema } from './roles.schema.js'; +const IsoDateSchema = z.coerce.date().transform((date) => date.toISOString()); + export const ApiUserSchema = z .object({ id: z.string(), email: z.email(), phone: z.string(), roles: z.array(RoleNameSchema).default([]), + revoked: z.boolean().optional(), + emailVerified: z.boolean().optional(), + phoneVerified: z.boolean().optional(), + verified: z.boolean().optional(), + lastLogin: IsoDateSchema.nullable().optional(), + createdAt: IsoDateSchema.optional(), + updatedAt: IsoDateSchema.optional(), }) - .passthrough(); + .strict(); diff --git a/src/services/apiResponseSerializers.ts b/src/services/apiResponseSerializers.ts new file mode 100644 index 0000000..d4ce13e --- /dev/null +++ b/src/services/apiResponseSerializers.ts @@ -0,0 +1,161 @@ +/* + * Copyright © 2026 Fells Code, LLC + * Licensed under the GNU Affero General Public License v3.0 + * See LICENSE file in the project root for full license information + */ + +type SerializableRecord = Record; + +function isRecord(value: unknown): value is SerializableRecord { + return Boolean(value && typeof value === 'object'); +} + +function readField(source: unknown, key: string): unknown { + if (!isRecord(source)) return undefined; + + const direct = source[key]; + if (direct !== undefined) return direct; + + const get = source.get; + if (typeof get === 'function') { + try { + const plain = get.call(source, { plain: true }); + if (isRecord(plain)) return plain[key]; + } catch { + // Fall back to direct object fields below. + } + } + + return undefined; +} + +function stringField(source: unknown, key: string): string { + const value = readField(source, key); + return typeof value === 'string' ? value : String(value ?? ''); +} + +function nullableStringField(source: unknown, key: string): string | null | undefined { + const value = readField(source, key); + if (value === undefined) return undefined; + if (value === null) return null; + return typeof value === 'string' ? value : String(value); +} + +function booleanField(source: unknown, key: string): boolean | undefined { + const value = readField(source, key); + return typeof value === 'boolean' ? value : undefined; +} + +function numberField(source: unknown, key: string, fallback = 0): number { + const value = readField(source, key); + if (typeof value === 'number' && Number.isFinite(value)) return value; + if (typeof value === 'string') { + const parsed = Number(value); + if (Number.isFinite(parsed)) return parsed; + } + + return fallback; +} + +function dateField(source: unknown, key: string): string | null | undefined { + const value = readField(source, key); + if (value === undefined) return undefined; + if (value === null) return null; + if (value instanceof Date) return value.toISOString(); + if (typeof value === 'string') return value; + if (typeof value === 'number') { + const date = new Date(value); + return Number.isNaN(date.getTime()) ? undefined : date.toISOString(); + } + + return undefined; +} + +function stringArrayField(source: unknown, key: string): string[] { + const value = readField(source, key); + if (!Array.isArray(value)) return []; + return value.filter((item): item is string => typeof item === 'string'); +} + +function optionalStringArrayField(source: unknown, key: string): string[] | undefined { + const value = readField(source, key); + if (value === undefined) return undefined; + if (!Array.isArray(value)) return []; + return value.filter((item): item is string => typeof item === 'string'); +} + +function credentialDeviceTypeField( + credential: unknown, +): 'singleDevice' | 'multiDevice' | undefined { + const value = readField(credential, 'deviceType'); + return value === 'singleDevice' || value === 'multiDevice' ? value : undefined; +} + +function transportField( + credential: unknown, +): Array<'usb' | 'ble' | 'nfc' | 'internal'> | undefined { + const transports = optionalStringArrayField(credential, 'transports'); + if (transports === undefined) return undefined; + + return transports.filter( + (transport): transport is 'usb' | 'ble' | 'nfc' | 'internal' => + transport === 'usb' || transport === 'ble' || transport === 'nfc' || transport === 'internal', + ); +} + +function omitUndefined(value: T) { + return Object.fromEntries( + Object.entries(value).filter(([, fieldValue]) => fieldValue !== undefined), + ) as T; +} + +export function serializeApiUser(user: unknown) { + return omitUndefined({ + id: stringField(user, 'id'), + email: stringField(user, 'email'), + phone: stringField(user, 'phone'), + roles: stringArrayField(user, 'roles'), + revoked: booleanField(user, 'revoked'), + emailVerified: booleanField(user, 'emailVerified'), + phoneVerified: booleanField(user, 'phoneVerified'), + verified: booleanField(user, 'verified'), + lastLogin: dateField(user, 'lastLogin'), + createdAt: dateField(user, 'createdAt'), + updatedAt: dateField(user, 'updatedAt'), + }); +} + +export function serializeCredential(credential: unknown) { + const backedUp = + booleanField(credential, 'backedUp') ?? booleanField(credential, 'backedup') ?? false; + + return omitUndefined({ + id: stringField(credential, 'id'), + transports: transportField(credential), + deviceType: credentialDeviceTypeField(credential), + backedup: backedUp, + backedUp, + counter: numberField(credential, 'counter'), + prfCapable: booleanField(credential, 'prfCapable'), + friendlyName: nullableStringField(credential, 'friendlyName'), + lastUsedAt: dateField(credential, 'lastUsedAt'), + platform: nullableStringField(credential, 'platform'), + browser: nullableStringField(credential, 'browser'), + deviceInfo: nullableStringField(credential, 'deviceInfo'), + createdAt: dateField(credential, 'createdAt'), + }); +} + +export function serializeSession(session: unknown, currentSessionId?: string | null) { + const id = stringField(session, 'id'); + + return omitUndefined({ + id, + deviceName: nullableStringField(session, 'deviceName'), + ipAddress: nullableStringField(session, 'ipAddress'), + userAgent: nullableStringField(session, 'userAgent'), + lastUsedAt: dateField(session, 'lastUsedAt'), + expiresAt: dateField(session, 'expiresAt'), + current: currentSessionId ? id === currentSessionId : false, + }); +} diff --git a/src/services/authEventService.ts b/src/services/authEventService.ts index 3841c6c..ba64b43 100644 --- a/src/services/authEventService.ts +++ b/src/services/authEventService.ts @@ -59,7 +59,7 @@ export class AuthEventService { metadata: redactMetadata(metadata), }); } catch (err) { - logger.error('Failed to write AuthEvent:', err); + logger.error(`Failed to write AuthEvent: ${err}`); } } diff --git a/src/services/messagingService.ts b/src/services/messagingService.ts index 7b30836..2bda6fb 100644 --- a/src/services/messagingService.ts +++ b/src/services/messagingService.ts @@ -24,7 +24,7 @@ async function getMessagingService() { } export const sendOTPEmail = async (to: string, token: string) => { - logger.debug(`Sending verification email to: ${to}`); + logger.debug('Sending verification email'); if (shouldBypassDirectMessaging()) { logger.debug('Skipping direct email delivery in development'); @@ -46,7 +46,7 @@ export const sendOTPEmail = async (to: string, token: string) => { }; export const sendOTPSMS = async (to: string, token: number) => { - logger.debug(`Sending verification SMS to: ${to}`); + logger.debug('Sending verification SMS'); if (shouldBypassDirectMessaging()) { logger.debug('Skipping direct SMS delivery in development'); @@ -58,7 +58,7 @@ export const sendOTPSMS = async (to: string, token: number) => { const normalizedPhone = normalizePhoneNumber(to); if (!normalizedPhone) { - throw new Error(`Invalid phone number for direct SMS delivery: ${to}`); + throw new Error('Invalid phone number for direct SMS delivery'); } await messaging.sendOtpSms({ @@ -71,7 +71,7 @@ export const sendOTPSMS = async (to: string, token: number) => { }; export const sendMagicLinkEmail = async (to: string, token: string, safeRedirect: string) => { - logger.debug(`Sending magic link to: ${to}`); + logger.debug('Sending magic link'); if (shouldBypassDirectMessaging()) { logger.debug('Skipping direct magic link delivery in development'); @@ -92,7 +92,7 @@ export const sendMagicLinkEmail = async (to: string, token: string, safeRedirect }; export const sendBootstrapEmail = async (to: string, url: string) => { - logger.debug(`Sending bootstrap invitation email to: ${to}`); + logger.debug('Sending bootstrap invitation email'); if (shouldBypassDirectMessaging()) { logger.debug('Skipping direct bootstrap delivery in development'); diff --git a/src/services/sessionService.ts b/src/services/sessionService.ts index 2cb0b69..3fe978c 100644 --- a/src/services/sessionService.ts +++ b/src/services/sessionService.ts @@ -6,30 +6,27 @@ import { compareSync } from 'bcrypt-ts'; import { importSPKI, jwtVerify } from 'jose'; -import jwt from 'jsonwebtoken'; import { Op } from 'sequelize'; import { createRefreshTokenLookup } from '../lib/token.js'; import { Session } from '../models/sessions.js'; import { User } from '../models/users.js'; import getLogger from '../utils/logger.js'; -import { getSecret } from '../utils/secretsStore.js'; import { getPublicKeyByKid } from '../utils/signingKeyStore.js'; const logger = getLogger('sessionService'); export type AuthTokenType = 'ephemeral' | 'access'; -let cachedSecret: string | null = null; +const ISSUER = process.env.ISSUER!; -async function getInternalSecret() { - if (cachedSecret) return cachedSecret; - cachedSecret = await getSecret('API_SERVICE_TOKEN'); - return cachedSecret; +export interface ValidatedAccessToken { + userId: string; + sessionId: string; + roles: string[]; + organizationId: string | null; } -const ISSUER = process.env.ISSUER!; - export async function verifyJwtWithKid(token: string, expectedType?: 'access' | 'ephemeral') { try { const { payload } = await jwtVerify( @@ -101,18 +98,20 @@ export async function hardRevokeSession(session: Session, reason = 'manual_revok await session.save(); } -export async function validateAccessToken(token: string) { +export async function validateAccessToken(token: string): Promise { const payload = await verifyJwtWithKid(token, 'access'); if (!payload) return null; const { sub: userId, sid: sessionId } = payload; - if (!userId || !sessionId) return null; + if (typeof userId !== 'string' || typeof sessionId !== 'string') return null; return { userId, sessionId, - roles: payload.roles || [], + roles: Array.isArray(payload.roles) + ? payload.roles.filter((role): role is string => typeof role === 'string') + : [], organizationId: typeof payload.org_id === 'string' ? payload.org_id : null, }; } @@ -206,33 +205,52 @@ export interface ValidatedBearerToken { organizationId?: string | null; } -export async function validateBearerToken(token: string) { - const serviceSecret = await getInternalSecret(); - let payload; +export async function validateEphemeralToken(token: string): Promise { + const payload = await verifyJwtWithKid(token, 'ephemeral'); - try { - payload = jwt.verify(token, serviceSecret, { - issuer: process.env.APP_ORIGINS!.split(',')[0], - audience: process.env.ISSUER, - }); - } catch (err: Error | unknown) { - if (err instanceof Error && err.name === 'TokenExpiredError') { - logger.info(`Expired bearer token`); - } else { - logger.error(`Bearer token verification error: ${err}`); - } + if (!payload || typeof payload.sub !== 'string') { return null; } - if (typeof payload === 'string' || typeof payload.sub !== 'string') { - return null; - } - - const sessionId = typeof payload.sid === 'string' ? payload.sid : undefined; - const organizationId = typeof payload.org_id === 'string' ? payload.org_id : null; const user = await User.findOne({ where: { id: payload.sub, revoked: false }, }); - return user ? { user, sessionId, organizationId } : null; + return user ? { user } : null; +} + +export async function validateBearerToken( + token: string, + expectedType: AuthTokenType = 'access', +): Promise { + if (expectedType === 'ephemeral') { + return validateEphemeralToken(token); + } + + const accessToken = await validateAccessToken(token); + + if (!accessToken) { + return null; + } + + const session = await validateSessionRecord(accessToken.sessionId); + + if (!session) { + return null; + } + + if (session.userId !== accessToken.userId) { + logger.warn('Access token subject did not match the session owner'); + return null; + } + + const user = await getUserFromSession(session); + + return user + ? { + user, + sessionId: accessToken.sessionId, + organizationId: accessToken.organizationId, + } + : null; } diff --git a/src/utils/redaction.ts b/src/utils/redaction.ts index 95171fd..d37c52a 100644 --- a/src/utils/redaction.ts +++ b/src/utils/redaction.ts @@ -14,11 +14,12 @@ const SENSITIVE_KEY_PATTERN = const TOKEN_TEXT_PATTERNS: Array<[RegExp, string]> = [ [/\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}\b/gi, REDACTED], + [/\+[1-9]\d{6,14}\b/g, REDACTED], [/\b(Bearer\s+)[A-Za-z0-9._~+/=-]+/gi, `$1${REDACTED}`], [/(\/magic-link\/verify\/)[^/?#\s]+/gi, `$1${REDACTED}`], [/([?&](?:token|bootstrapToken|state|code|salt)=)[^&#\s]+/gi, `$1${REDACTED}`], [ - /\b((?:token|bootstrapToken|verificationToken|identifier|phone|state|code|secret|salt)\s*[:=]\s*)[^,&\s}]+/gi, + /\b((?:token|bootstrapToken|verificationToken|identifier|email(?:\s+address)?|phone(?:\s+number)?|state|code|secret|salt)\s*[:=]\s*)[^,&\s}]+/gi, `$1${REDACTED}`, ], [/\b(client_secret=)[^&\s]+/gi, `$1${REDACTED}`], diff --git a/tests/integration/admin/admin.spec.ts b/tests/integration/admin/admin.spec.ts index d58af7b..a1bbe47 100644 --- a/tests/integration/admin/admin.spec.ts +++ b/tests/integration/admin/admin.spec.ts @@ -33,6 +33,7 @@ describe('GET /admin/users', () => { expect(res.status).toBe(200); expect(res.body.users).toHaveLength(1); expect(res.body.total).toBe(1); + expect(JSON.stringify(res.body)).not.toContain('challenge'); }); }); @@ -54,15 +55,39 @@ describe('DELETE /admin/users', () => { describe('GET /admin/users/:userId', () => { it('returns user detail', async () => { - (User.findByPk as any).mockResolvedValue(buildUser()); - (Session.findAll as any).mockResolvedValue([]); - (Credential.findAll as any).mockResolvedValue([]); + (User.findByPk as any).mockResolvedValue( + buildUser({ + emailVerificationToken: 'email-token', + phoneVerificationToken: 'phone-token', + challengeContext: { prfSalt: 'salt' }, + }), + ); + (Session.findAll as any).mockResolvedValue([ + buildSession({ + refreshTokenHash: 'refresh-hash', + refreshTokenLookup: 'refresh-lookup', + idleExpiresAt: new Date(), + }), + ]); + (Credential.findAll as any).mockResolvedValue([ + buildCredential({ + publicKey: 'public-key', + }), + ]); (AuthEvent.findAll as any).mockResolvedValue([]); const res = await request(app).get(`/admin/users/${testGuid}`); expect(res.status).toBe(200); expect(res.body.user).toBeDefined(); + expect(res.body.sessions).toHaveLength(1); + expect(res.body.credentials).toHaveLength(1); + expect(JSON.stringify(res.body)).not.toContain('emailVerificationToken'); + expect(JSON.stringify(res.body)).not.toContain('phoneVerificationToken'); + expect(JSON.stringify(res.body)).not.toContain('challengeContext'); + expect(JSON.stringify(res.body)).not.toContain('refreshTokenHash'); + expect(JSON.stringify(res.body)).not.toContain('refreshTokenLookup'); + expect(JSON.stringify(res.body)).not.toContain('publicKey'); }); it('returns 404 if user missing', async () => { @@ -245,6 +270,8 @@ describe('POST /admin/users', () => { email: 'test@example.com', phone: '+14155552671', roles: ['user'], + challenge: 'challenge', + emailVerificationToken: 'email-token', }); const res = await request(app) @@ -257,6 +284,8 @@ describe('POST /admin/users', () => { expect(res.status).toBe(201); expect(res.body.user).toBeDefined(); + expect(res.body.user).not.toHaveProperty('challenge'); + expect(res.body.user).not.toHaveProperty('emailVerificationToken'); }); it('creates user with scoped roles', async () => { @@ -330,6 +359,8 @@ describe('PATCH /admin/users/:userId', () => { expect(res.status).toBe(200); expect(user.update).toHaveBeenCalled(); + expect(res.body.user).not.toHaveProperty('challenge'); + expect(res.body.user).not.toHaveProperty('challengeContext'); }); it('updates scoped roles successfully', async () => { diff --git a/tests/integration/authentication/authentication.spec.ts b/tests/integration/authentication/authentication.spec.ts index 60f271d..1b9f7c9 100644 --- a/tests/integration/authentication/authentication.spec.ts +++ b/tests/integration/authentication/authentication.spec.ts @@ -14,7 +14,7 @@ import { signAccessToken, signEphemeralToken, } from '../../../src/lib/token'; -import { findRefreshSessionByToken } from '../../../src/services/sessionService'; +import { findRefreshSessionByToken, hardRevokeSession } from '../../../src/services/sessionService'; let app: Application; @@ -150,13 +150,49 @@ describe('POST /login', () => { }); describe('GET /logout', () => { - it('logs out user', async () => { - (Session.findAll as any).mockResolvedValue([{ revokedAt: null }]); + it('logs out all user sessions for backward compatibility', async () => { + const sessions = [{ id: 'session-1' }, { id: 'session-2' }]; + (Session.findAll as any).mockResolvedValue(sessions); const res = await request(app).get('/logout'); expect(res.status).toBe(200); expect(res.body.message).toBe('Success'); + expect(hardRevokeSession).toHaveBeenCalledTimes(2); + expect(hardRevokeSession).toHaveBeenNthCalledWith(1, sessions[0], 'user_logout_all'); + expect(hardRevokeSession).toHaveBeenNthCalledWith(2, sessions[1], 'user_logout_all'); + }); +}); + +describe('DELETE /logout', () => { + it('logs out only the current session', async () => { + const session = { id: 'session-1', userId: 'user-1' }; + (Session.findOne as any).mockResolvedValue(session); + + const res = await request(app).delete('/logout'); + + expect(res.status).toBe(200); + expect(res.body.message).toBe('Success'); + expect(Session.findOne).toHaveBeenCalledWith({ + where: { id: 'session-1', userId: expect.any(String), revokedAt: null }, + }); + expect(hardRevokeSession).toHaveBeenCalledWith(session, 'user_logout'); + }); +}); + +describe('DELETE /logout/all', () => { + it('logs out all user sessions', async () => { + const sessions = [{ id: 'session-1' }, { id: 'session-2' }]; + (Session.findAll as any).mockResolvedValue(sessions); + + const res = await request(app).delete('/logout/all'); + + expect(res.status).toBe(200); + expect(res.body.message).toBe('Success'); + expect(Session.findAll).toHaveBeenCalledWith({ + where: { userId: expect.any(String), revokedAt: null }, + }); + expect(hardRevokeSession).toHaveBeenCalledTimes(2); }); }); diff --git a/tests/integration/user/user.spec.ts b/tests/integration/user/user.spec.ts index 9c3b50a..5cef9c6 100644 --- a/tests/integration/user/user.spec.ts +++ b/tests/integration/user/user.spec.ts @@ -26,6 +26,9 @@ describe('GET /users/me', () => { expect(res.status).toBe(200); expect(res.body.user.id).toBe('user-1'); expect(res.body.credentials).toHaveLength(1); + expect(res.body.credentials[0]).not.toHaveProperty('publicKey'); + expect(res.body.credentials[0]).not.toHaveProperty('userId'); + expect(JSON.stringify(res.body.user)).not.toContain('challenge'); }); it('returns 404 when no user', async () => { @@ -59,6 +62,8 @@ describe('POST /users/credentials', () => { expect(res.status).toBe(200); expect(cred.update).toHaveBeenCalled(); + expect(res.body.credential).not.toHaveProperty('publicKey'); + expect(res.body.credential).not.toHaveProperty('userId'); }); it('returns 404 when credential not found', async () => { diff --git a/tests/setup/mocks.ts b/tests/setup/mocks.ts index 656872b..de70e24 100644 --- a/tests/setup/mocks.ts +++ b/tests/setup/mocks.ts @@ -102,6 +102,7 @@ vi.mock('../../src/config/getSystemConfig.js', () => ({ vi.mock('../../src/services/sessionService.js', () => ({ validateAccessToken: vi.fn(), + validateBearerToken: vi.fn(), validateSessionRecord: vi.fn(), findRefreshSessionByToken: vi.fn(), getUserFromSession: vi.fn(), diff --git a/tests/unit/middleware/attachAuthMiddleware.spec.ts b/tests/unit/middleware/attachAuthMiddleware.spec.ts index 8f25d55..fdebd3d 100644 --- a/tests/unit/middleware/attachAuthMiddleware.spec.ts +++ b/tests/unit/middleware/attachAuthMiddleware.spec.ts @@ -1,6 +1,5 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; -vi.unmock('../../../src/middleware/verifyBearerAuth'); vi.unmock('../../../src/middleware/attachAuthMiddleware'); vi.mock('../../../src/middleware/verifyBearerAuth', () => ({ @@ -16,17 +15,31 @@ describe('attachAuthMiddleware', () => { const { attachAuthMiddleware } = await import('../../../src/middleware/attachAuthMiddleware'); const { verifyBearerAuth } = await import('../../../src/middleware/verifyBearerAuth'); const middleware = attachAuthMiddleware(); + const req = {}; + const res = {}; + const next = vi.fn(); - expect(middleware).toBe(verifyBearerAuth); expect(middleware.seamlessAuthType).toBe('access'); + + await middleware(req as any, res as any, next); + + expect(verifyBearerAuth).toHaveBeenCalledWith(req, res, next, 'access'); }); it('tracks ephemeral bearer auth for route metadata', async () => { const { attachAuthMiddleware } = await import('../../../src/middleware/attachAuthMiddleware'); + const { verifyBearerAuth } = await import('../../../src/middleware/verifyBearerAuth'); const middleware = attachAuthMiddleware('ephemeral'); + const req = {}; + const res = {}; + const next = vi.fn(); expect(middleware.seamlessAuthType).toBe('ephemeral'); expect(typeof middleware).toBe('function'); + + await middleware(req as any, res as any, next); + + expect(verifyBearerAuth).toHaveBeenCalledWith(req, res, next, 'ephemeral'); }); it('always maps protected routes to bearer security', async () => { diff --git a/tests/unit/middleware/rateLimit.spec.ts b/tests/unit/middleware/rateLimit.spec.ts index ef5f9de..41f749e 100644 --- a/tests/unit/middleware/rateLimit.spec.ts +++ b/tests/unit/middleware/rateLimit.spec.ts @@ -17,6 +17,11 @@ vi.mock('express-slow-down', () => { }; }); +beforeEach(() => { + vi.resetModules(); + vi.clearAllMocks(); +}); + describe('dynamicSlowDown', () => { let req: any, res: any, next: any; @@ -144,7 +149,7 @@ describe('magicLinkIpLimiter', () => { }); describe('magicLinkEmailLimiter', () => { - it('uses email or ip as key', async () => { + it('uses authenticated email or ip as key', async () => { const { getSystemConfig } = await import('../../../src/config/getSystemConfig'); const rateLimit = await import('express-rate-limit'); @@ -153,7 +158,7 @@ describe('magicLinkEmailLimiter', () => { const { magicLinkEmailLimiter } = await import('../../../src/middleware/rateLimit'); const req: any = { - body: { email: 'test@example.com' }, + user: { email: 'Test@Example.com' }, ip: '127.0.0.1', }; @@ -164,12 +169,43 @@ describe('magicLinkEmailLimiter', () => { expect(rateLimit.default).toHaveBeenCalledWith( expect.objectContaining({ + keyGenerator: expect.any(Function), legacyHeaders: false, - max: 100, - message: 'Too many requests, please try again later', + max: 5, standardHeaders: true, - windowMs: 60000, + windowMs: 15 * 60 * 1000, }), ); + + const options = (rateLimit.default as any).mock.calls[0][0]; + + expect(options.keyGenerator(req)).toBe('email:test@example.com'); + expect(options.keyGenerator({ ip: '127.0.0.1' })).toBe('ip:127.0.0.1'); + }); +}); + +describe('rate limiter caches', () => { + it('keeps dynamic and magic link limiter instances isolated', async () => { + const { getSystemConfig } = await import('../../../src/config/getSystemConfig'); + const rateLimit = await import('express-rate-limit'); + + (getSystemConfig as any).mockResolvedValue({ rate_limit: 100 }); + + const { dynamicRateLimit, magicLinkEmailLimiter, magicLinkIpLimiter } = + await import('../../../src/middleware/rateLimit'); + + const next = vi.fn(); + + // @ts-ignore + await dynamicRateLimit({}, {}, next); + // @ts-ignore + await magicLinkIpLimiter({}, {}, next); + // @ts-ignore + await magicLinkEmailLimiter({}, {}, next); + + expect(rateLimit.default).toHaveBeenCalledTimes(3); + expect((rateLimit.default as any).mock.calls.map(([options]: any[]) => options.max)).toEqual([ + 100, 20, 5, + ]); }); }); diff --git a/tests/unit/middleware/verifyBearerAuth.spec.ts b/tests/unit/middleware/verifyBearerAuth.spec.ts index edeaf7c..f7cb53c 100644 --- a/tests/unit/middleware/verifyBearerAuth.spec.ts +++ b/tests/unit/middleware/verifyBearerAuth.spec.ts @@ -57,6 +57,7 @@ describe('verifyBearerAuth', () => { expect(res.json).toHaveBeenCalledWith({ error: 'unauthorized', }); + expect(validateBearerToken).toHaveBeenCalledWith('token', 'access'); expect(next).not.toHaveBeenCalled(); }); @@ -72,11 +73,29 @@ describe('verifyBearerAuth', () => { await verifyBearerAuth(req, res, next); + expect(validateBearerToken).toHaveBeenCalledWith('token', 'access'); expect(req.user).toEqual(mockUser); expect(req.sessionId).toBe('session-1'); expect(next).toHaveBeenCalled(); }); + it('validates with the requested auth token type', async () => { + req.headers.authorization = 'Bearer token'; + + const mockUser = { id: 'user-1' }; + + (validateBearerToken as any).mockResolvedValue({ + user: mockUser, + }); + + await verifyBearerAuth(req, res, next, 'ephemeral'); + + expect(validateBearerToken).toHaveBeenCalledWith('token', 'ephemeral'); + expect(req.user).toEqual(mockUser); + expect(req.sessionId).toBeUndefined(); + expect(next).toHaveBeenCalled(); + }); + it('returns 401 if validation throws', async () => { req.headers.authorization = 'Bearer token'; diff --git a/tests/unit/routes/responseSchemaCoverage.spec.ts b/tests/unit/routes/responseSchemaCoverage.spec.ts new file mode 100644 index 0000000..e78744b --- /dev/null +++ b/tests/unit/routes/responseSchemaCoverage.spec.ts @@ -0,0 +1,32 @@ +import { readdirSync, readFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { describe, expect, it } from 'vitest'; + +const routesDir = join(dirname(fileURLToPath(import.meta.url)), '../../../src/routes'); +const routeCallPattern = + /\w+Router\.(get|post|patch|put|delete)\(\s*(["'])([^"']+)\2([\s\S]*?)\n\);/g; + +describe('route response schema coverage', () => { + it('documents every route with an explicit response schema', () => { + const missingResponses: string[] = []; + + for (const fileName of readdirSync(routesDir) + .filter((name) => name.endsWith('.ts')) + .sort()) { + const source = readFileSync(join(routesDir, fileName), 'utf8'); + let match: RegExpExecArray | null; + + while ((match = routeCallPattern.exec(source))) { + const [, method, , routePath, routeDefinition] = match; + + if (!/response\s*:/.test(routeDefinition)) { + missingResponses.push(`${fileName}: ${method.toUpperCase()} ${routePath}`); + } + } + } + + expect(missingResponses).toEqual([]); + }); +}); diff --git a/tests/unit/services/apiResponseSerializers.spec.ts b/tests/unit/services/apiResponseSerializers.spec.ts new file mode 100644 index 0000000..3f0847b --- /dev/null +++ b/tests/unit/services/apiResponseSerializers.spec.ts @@ -0,0 +1,89 @@ +import { describe, expect, it } from 'vitest'; + +import { + serializeApiUser, + serializeCredential, + serializeSession, +} from '../../../src/services/apiResponseSerializers'; + +describe('api response serializers', () => { + it('minimizes user responses to non-secret account fields', () => { + const user = serializeApiUser({ + id: 'user-1', + email: 'test@example.com', + phone: '+14155552671', + roles: ['admin:read'], + revoked: false, + emailVerified: true, + phoneVerified: true, + challenge: 'challenge', + challengeContext: { prfSalt: 'salt' }, + emailVerificationToken: 'email-token', + phoneVerificationToken: 'phone-token', + }); + + expect(user).toEqual( + expect.objectContaining({ + id: 'user-1', + email: 'test@example.com', + phone: '+14155552671', + roles: ['admin:read'], + }), + ); + expect(user).not.toHaveProperty('challenge'); + expect(user).not.toHaveProperty('challengeContext'); + expect(user).not.toHaveProperty('emailVerificationToken'); + expect(user).not.toHaveProperty('phoneVerificationToken'); + }); + + it('minimizes credential responses without public key material', () => { + const credential = serializeCredential({ + id: 'credential-1', + userId: 'user-1', + publicKey: 'public-key', + counter: 10, + backedup: true, + prfCapable: true, + friendlyName: 'Laptop', + createdAt: new Date('2026-01-01T00:00:00.000Z'), + }); + + expect(credential).toEqual( + expect.objectContaining({ + id: 'credential-1', + counter: 10, + backedup: true, + backedUp: true, + prfCapable: true, + }), + ); + expect(credential).not.toHaveProperty('userId'); + expect(credential).not.toHaveProperty('publicKey'); + }); + + it('minimizes session responses without refresh token internals', () => { + const session = serializeSession( + { + id: 'session-1', + refreshTokenHash: 'hash', + refreshTokenLookup: 'lookup', + idleExpiresAt: new Date('2026-01-01T00:10:00.000Z'), + lastUsedAt: new Date('2026-01-01T00:00:00.000Z'), + expiresAt: new Date('2026-01-02T00:00:00.000Z'), + }, + 'session-1', + ); + + expect(session).toEqual( + expect.objectContaining({ + id: 'session-1', + lastUsedAt: '2026-01-01T00:00:00.000Z', + expiresAt: '2026-01-02T00:00:00.000Z', + current: true, + }), + ); + expect(session).not.toHaveProperty('refreshTokenHash'); + expect(session).not.toHaveProperty('refreshTokenLookup'); + expect(session).not.toHaveProperty('idleExpiresAt'); + }); +}); diff --git a/tests/unit/services/sessionService.spec.ts b/tests/unit/services/sessionService.spec.ts index a69fd1a..c69055a 100644 --- a/tests/unit/services/sessionService.spec.ts +++ b/tests/unit/services/sessionService.spec.ts @@ -317,18 +317,23 @@ describe('sessionService', () => { expect(result).toBeNull(); }); - it('returns user when valid', async () => { - const { getSecret } = await import('../../../src/utils/secretsStore'); - const jwt = await import('jsonwebtoken'); + it('returns user when access bearer token and session are valid', async () => { + const jose = await import('jose'); + const { getPublicKeyByKid } = await import('../../../src/utils/signingKeyStore'); + const { Session } = await import('../../../src/models/sessions'); const { User } = await import('../../../src/models/users'); - (getSecret as any).mockResolvedValue('secret'); - - (jwt.default.verify as any).mockReturnValue({ - sub: 'user', - sid: 'session-1', + (getPublicKeyByKid as any).mockResolvedValue('pem'); + (jose.jwtVerify as any).mockResolvedValue({ + payload: { + typ: 'access', + sub: 'user', + sid: 'session-1', + org_id: 'org-1', + }, }); + (Session.findByPk as any).mockResolvedValue(buildSession({ id: 'session-1', userId: 'user' })); const user = { id: 'user' }; (User.findOne as any).mockResolvedValue(user); @@ -339,20 +344,61 @@ describe('sessionService', () => { expect(result).toEqual({ user, sessionId: 'session-1', - organizationId: null, + organizationId: 'org-1', }); }); - it('returns null if jwt fails', async () => { - const { getSecret } = await import('../../../src/utils/secretsStore'); - const jwt = await import('jsonwebtoken'); + it('returns null when access bearer token subject does not match the session owner', async () => { + const jose = await import('jose'); + const { getPublicKeyByKid } = await import('../../../src/utils/signingKeyStore'); + const { Session } = await import('../../../src/models/sessions'); + + (getPublicKeyByKid as any).mockResolvedValue('pem'); + (jose.jwtVerify as any).mockResolvedValue({ + payload: { + typ: 'access', + sub: 'user', + sid: 'session-1', + }, + }); + + (Session.findByPk as any).mockResolvedValue(buildSession({ id: 'session-1', userId: 'other' })); + + const { validateBearerToken } = await import('../../../src/services/sessionService'); + + const result = await validateBearerToken('token'); - (getSecret as any).mockResolvedValue('secret'); + expect(result).toBeNull(); + }); - (jwt.default.verify as any).mockImplementation(() => { - throw new Error('fail'); + it('returns user when ephemeral bearer token is valid', async () => { + const jose = await import('jose'); + const { getPublicKeyByKid } = await import('../../../src/utils/signingKeyStore'); + const { User } = await import('../../../src/models/users'); + + (getPublicKeyByKid as any).mockResolvedValue('pem'); + (jose.jwtVerify as any).mockResolvedValue({ + payload: { + typ: 'ephemeral', + sub: 'user', + }, }); + const user = { id: 'user' }; + (User.findOne as any).mockResolvedValue(user); + + const { validateBearerToken } = await import('../../../src/services/sessionService'); + + const result = await validateBearerToken('token', 'ephemeral'); + + expect(result).toEqual({ user }); + }); + + it('returns null if jwt verification fails', async () => { + const jose = await import('jose'); + + (jose.jwtVerify as any).mockRejectedValue(new Error('fail')); + const { validateBearerToken } = await import('../../../src/services/sessionService'); const result = await validateBearerToken('token'); diff --git a/tests/unit/utils/redaction.spec.ts b/tests/unit/utils/redaction.spec.ts index 2cfa113..dc8deb3 100644 --- a/tests/unit/utils/redaction.spec.ts +++ b/tests/unit/utils/redaction.spec.ts @@ -49,4 +49,12 @@ describe('redaction utilities', () => { 'Bearer [REDACTED] /magic-link/verify/[REDACTED] token=[REDACTED] code=[REDACTED] salt=[REDACTED] [REDACTED]', ); }); + + it('redacts phone and labeled identifier values embedded in text', () => { + expect( + redactSensitiveText( + 'phone number: +15555550123 email address: user@example.com identifier: +15555550124', + ), + ).toBe('phone number: [REDACTED] email address: [REDACTED] identifier: [REDACTED]'); + }); });