diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..74a6d2a --- /dev/null +++ b/.editorconfig @@ -0,0 +1,16 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +indent_style = space +indent_size = 2 +insert_final_newline = true +trim_trailing_whitespace = true + +[*.md] +trim_trailing_whitespace = false + +[test/data/*] +insert_final_newline = unset +trim_trailing_whitespace = unset diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 24cc068..e6470cb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -5,19 +5,51 @@ on: branches: [main] pull_request: +permissions: + contents: read + jobs: test: + name: Test (Node.js ${{ matrix.node }}) runs-on: ubuntu-latest strategy: + fail-fast: false matrix: - node-version: [18, 20, 22] + node: [20, 22, 24] steps: - - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + with: + persist-credentials: false + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 with: - node-version: ${{ matrix.node-version }} + node-version: ${{ matrix.node }} cache: npm - - run: npm install + - run: npm ci - run: npm run typecheck + - run: npm run lint + - run: npm test -- --coverage + - run: npm run build + - run: npm run test:package + - run: npm run test:runtime + - run: npm run test:example + + runtime-floor: + name: Runtime (Node.js 18) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + with: + persist-credentials: false + # The development tools need Node.js 20+; the package itself supports Node.js 18. + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + with: + node-version: 22 + cache: npm + - run: npm ci - run: npm run build - - run: npm test + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + with: + node-version: 18 + - run: node --version + - run: npm run test:runtime + - run: npm run test:example diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml deleted file mode 100644 index 3370487..0000000 --- a/.github/workflows/publish.yml +++ /dev/null @@ -1,29 +0,0 @@ -name: Publish - -on: - push: - tags: - - "v*" - -permissions: - contents: read - id-token: write # npm provenance / OIDC - -jobs: - publish: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 - with: - node-version: 22 - registry-url: https://registry.npmjs.org - cache: npm - - run: npm ci - - run: npm run typecheck - - run: npm run build - - run: npm test - # Requires npm org @shieldlabs trusted publishing (OIDC) or NODE_AUTH_TOKEN secret. - - run: npm publish --access public --provenance - env: - NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..edccba5 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,76 @@ +name: Release + +# Publishes @shieldlabs-ai/node to npm when a maintainer pushes a tag such as v1.0.0. +# The first job checks and packs the package with read-only permissions; the second publishes +# that tarball with provenance (environment "npm", NPM_TOKEN secret). Safe to re-run: a version +# that is already on npm is skipped. +on: + push: + tags: ['v*'] + +permissions: + contents: read + +jobs: + build: + name: Check and pack + runs-on: ubuntu-latest + outputs: + version: ${{ steps.version.outputs.version }} + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + with: + persist-credentials: false + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + with: + node-version: 22 + cache: npm + - run: npm ci + - name: Check that the tag matches package.json + id: version + run: | + version="$(node -p "require('./package.json').version")" + if [ "v${version}" != "${GITHUB_REF_NAME}" ]; then + echo "::error::The tag does not match the package.json version ${version}." + exit 1 + fi + echo "version=${version}" >> "$GITHUB_OUTPUT" + - run: npm run typecheck + - run: npm run lint + - run: npm test + - run: npm run build + - run: npm run test:package + - run: npm run test:runtime + - run: npm pack + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: package + path: shieldlabs-ai-node-${{ steps.version.outputs.version }}.tgz + if-no-files-found: error + + publish: + name: Publish to npm + needs: build + runs-on: ubuntu-latest + environment: npm + permissions: + contents: read + id-token: write # npm provenance + steps: + - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 + with: + name: package + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + with: + node-version: 22 + registry-url: https://registry.npmjs.org + - name: Publish unless this version is already on npm + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + VERSION: ${{ needs.build.outputs.version }} + run: | + if [ -n "$(npm view "@shieldlabs-ai/node@${VERSION}" version 2>/dev/null)" ]; then + echo "@shieldlabs-ai/node@${VERSION} is already on npm: nothing to publish." + exit 0 + fi + npm publish "shieldlabs-ai-node-${VERSION}.tgz" --provenance --access public diff --git a/.gitignore b/.gitignore index 2767b2d..d380ce8 100644 --- a/.gitignore +++ b/.gitignore @@ -1,12 +1,11 @@ node_modules/ dist/ -build/ -*.log +coverage/ *.tgz +*.log .env .env.* .DS_Store -coverage/ -.turbo/ - .history +.vscode/ +.idea/ diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..6450b9b --- /dev/null +++ b/.prettierignore @@ -0,0 +1,6 @@ +dist/ +coverage/ +node_modules/ +test/data/ +*.md +package-lock.json diff --git a/.prettierrc.json b/.prettierrc.json new file mode 100644 index 0000000..5e2863a --- /dev/null +++ b/.prettierrc.json @@ -0,0 +1,5 @@ +{ + "printWidth": 100, + "singleQuote": true, + "trailingComma": "all" +} diff --git a/CHANGELOG.md b/CHANGELOG.md index ca512e6..5b95ce4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,72 @@ # Changelog -## 2026-09-06 +All notable changes to this package are documented in this file. The format follows +[Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and the package uses +[Semantic Versioning](https://semver.org/spec/v2.0.0.html). -- Minor improvements and bug fixes +## [Unreleased] + +## [1.0.0] - 2026-09-30 + +### Added + +- `ShieldLabs` History API client: `identifications.get` with wait-for-verdict polling, + `history.search`, and `history.iterate` with deduplication on `request_id`. +- `identifications.get` waits within one total `timeout` budget (default 10 s): the first poll + runs at once, then after 250 ms, 500 ms, 1 s, 1.5 s and every 2 s, and the last poll runs at + the deadline. A custom `pollInterval` p gives waits of p, 2p, 4p, 6p and then 8p, each capped + at 2 s or at p when p is longer, so 1 s waits 1 s and then every 2 s, and 3 s polls every 3 s. + Each poll is one HTTP attempt without retries; its timeout is the client timeout, shortened to + the time left but at least 1 s. A 429, a 5xx, a connection error or a timeout keeps it + polling. After a 429 the next wait is the longest of the scheduled wait, 1 s and its + `Retry-After` capped at 10 s (a missing `Retry-After`, `0` or a past date counts as 0), cut + short at the deadline; a capped `Retry-After` longer than the time left is thrown at once. At + the deadline the error of the last poll is thrown, or `null` is returned when that poll found + nothing. 400, 401, 403 and 404 are thrown at once. +- Client-side validation of lookup types, UUIDs, IPv4 addresses, `limit` and `offset`: invalid + arguments throw `ValidationError` and send nothing. +- User HID lookups are sent in canonical path form (`@`, `+`, `=`, `:` and similar characters + stay as they are), so such values match. A User HID that contains `/`, or is `.` or `..`, + throws `ValidationError`, because the History API cannot search it. +- `ShieldLabsManagement` client with `getProfile()`, domain normalization and no retries on 429. +- Keys, secrets and domains are checked when a client is created: a value with characters that + cannot be sent in an HTTP header (line breaks, NUL, spaces, non-ASCII) throws + `ValidationError`, and no error message or cause repeats a key or secret. `ConnectionError` + messages name the network cause, for example `connect ECONNREFUSED`. +- `webhooks.verifySignature` and `webhooks.constructEvent`, plus `verifySignatureAsync` and + `constructEventAsync` for every runtime. One secret or a list of secrets for rotation. Typed + `IdentificationScoredEvent`, `WebhookPingEvent` and `UnknownWebhookEvent`. +- One `Identification` model for webhook deliveries and History rows: 19 detection flags, weighted + risk signals and `observed_at` as RFC 3339 UTC with milliseconds. +- `evaluateIdentification`, `riskBand`, `isRateLimited`, `userHid`, `userHidAsync`, `SIGNALS`, + `RISK_BANDS`, `NIL_UUID` and `VERSION`. `evaluateIdentification` with `maxAge: Infinity` skips + the freshness check. +- Base URLs must use https. Plain http is accepted for `localhost`, `127.0.0.1` and `[::1]`, and + for other hosts only with `allowInsecureHttp: true`, so a mistyped URL never sends a key + unencrypted. +- Error hierarchy under `ShieldLabsError`, including `QuotaExceededError` (402); retries with + jittered exponential backoff and `Retry-After`; per-attempt timeouts; `AbortSignal` cancellation. +- Edge build for workers, edge runtimes, Deno and browsers, selected by export conditions. It never + loads `node:crypto`. A client created in a web page prints a one-time warning, because every + visitor could read its key there. +- ESM and CommonJS builds with TypeScript declarations, and the `examples/node-http` app. + +### Changed + +- History requests go to `https://account.shieldlabs.ai/api/v1/history/...`. The preview doubled + the `/api` prefix (`/api/api/v1/...`) and got 404 responses. A base URL that ends in `/api` is + now normalized. + +### Removed + +- The preview `ShieldLabsClient` and `verifyWebhook` exports, replaced by `ShieldLabs` and + `webhooks.verifySignature`. + +## [0.1.0] - 2026-09-06 + +### Added + +- Preview release: `verifyWebhook`, webhook payload types and a basic History API client. + +[Unreleased]: https://github.com/ShieldLabs-ai/shieldlabs-node/compare/v1.0.0...HEAD +[1.0.0]: https://github.com/ShieldLabs-ai/shieldlabs-node/releases/tag/v1.0.0 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..8050320 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,49 @@ +# Contributing + +Thank you for helping improve `@shieldlabs-ai/node`. + +## Set up + +Development needs Node.js 20 or later (the published package supports Node.js 18 and later). + +```bash +npm ci +``` + +## Checks + +Every change must pass these before it is merged: + +```bash +npm run typecheck +npm run lint +npm test -- --coverage # coverage must stay at or above 90% +npm run build +npm run test:package # export conditions, edge bundles, published files +npm run test:runtime # the built package in the Node.js and edge entries +npm run test:example # examples/node-http end to end +``` + +## Guidelines + +- Keep the package free of runtime dependencies. +- Only `src/index.ts` may import `node:crypto`. Everything shared by both entries (`src/common.ts` + and what it imports) must run on `fetch` and WebCrypto alone; `npm run test:package` checks it. +- `test/data` holds shared test fixtures that every ShieldLabs server SDK passes. Do not edit + them here; add new cases in a separate file. +- Changes to public behavior need tests and an entry under `Unreleased` in `CHANGELOG.md`. +- Commit messages follow Conventional Commits (`feat:`, `fix:`, `docs:`, `test:`, `ci:`, `chore:`). +- Documentation is plain technical English. Say "risk signals" and use the three risk bands: + trusted 0-29, suspicious 30-59, dangerous 60-100. + +## Releases + +A maintainer bumps the version in `package.json` and `src/version.ts`, updates `CHANGELOG.md`, and +pushes a tag `vX.Y.Z`. The release workflow checks and packs the package with read-only +permissions, then publishes that tarball to npm with provenance (GitHub environment `npm`, +`NPM_TOKEN` secret). Re-running it skips a version that is already on npm. + +## Security + +Report vulnerabilities privately to [contact@shieldlabs.ai](mailto:contact@shieldlabs.ai) instead +of opening a public issue. diff --git a/LICENSE b/LICENSE index 39bb7e4..597e96b 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2026 ShieldLabs +Copyright (c) 2026 ShieldLabs Inc. Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/PUBLISHING.md b/PUBLISHING.md deleted file mode 100644 index 7f4b863..0000000 --- a/PUBLISHING.md +++ /dev/null @@ -1,21 +0,0 @@ -# Publishing - -Release packages by pushing a semver tag (`v0.1.0`, `v1.0.0`, …). Workflows live in `.github/workflows/publish.yml`. - -## Prerequisites (founder / org admin) - -| Registry | Package | Setup | -|---|---|---| -| npm | `@shieldlabs/node`, `@shieldlabs/js`, … | Create npm org `@shieldlabs`. Add GitHub Actions secret `NPM_TOKEN` **or** configure [trusted publishing / OIDC](https://docs.npmjs.com/trusted-publishers) for each package. Provenance is enabled (`id-token: write`). | -| PyPI | `shieldlabs` | Claim project name. Add a [trusted publisher](https://docs.pypi.org/trusted-publishers/) for `ShieldLabs-ai/shieldlabs-python` → workflow `publish.yml`. | -| Packagist | `shieldlabs/shieldlabs` | Submit https://github.com/ShieldLabs-ai/shieldlabs-php. Optional secrets `PACKAGIST_USERNAME` + `PACKAGIST_TOKEN` for update hooks. | -| Go | `github.com/ShieldLabs-ai/shieldlabs-go` | Consumers `go get` by git tag; publish workflow creates a GitHub Release. | - -## Publish - -```bash -git tag v0.1.0 -git push origin v0.1.0 -``` - -Do not publish until namespaces are reserved and secrets/OIDC are configured — the workflows will fail otherwise. diff --git a/README.md b/README.md index 895082b..9acb50c 100644 --- a/README.md +++ b/README.md @@ -1,43 +1,487 @@ -# @shieldlabs/node +# @shieldlabs-ai/node -ShieldLabs server SDK for Node.js: webhook verification, typed webhook events, and History API client. +Read ShieldLabs identifications, verify signed webhooks and apply risk checks from your Node.js or edge backend. -Your code decides what to do with the score. This SDK never makes the decision for you. +[![CI](https://github.com/ShieldLabs-ai/shieldlabs-node/actions/workflows/ci.yml/badge.svg)](https://github.com/ShieldLabs-ai/shieldlabs-node/actions/workflows/ci.yml) +[![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE) +[![npm](https://img.shields.io/npm/v/@shieldlabs-ai/node.svg)](https://www.npmjs.com/package/@shieldlabs-ai/node) + +## How it fits + +1. **Browser.** The ShieldLabs agent runs an identification and hands your page a `requestId`. + The browser never sees a Risk Score, a visitor ID or a device ID. +2. **Your backend.** It receives the `requestId` together with the protected action (signup, + login, checkout) and reads the verdict with this SDK, or receives it in a signed + `identification.scored` webhook. +3. **Decision.** Your backend acts on `risk_score`, the three risk bands, `detection_flags` and + the identifiers (for example, how many accounts one `device_id` has created). + +This package covers steps 2 and 3. For step 1, use the browser SDK (`@shieldlabs-ai/js` or a +framework package). New to ShieldLabs? Start free at [app.shieldlabs.ai](https://app.shieldlabs.ai). ## Install ```bash -npm install @shieldlabs/node +npm install @shieldlabs-ai/node +``` + +Node.js 18 or later, with no runtime dependencies. The same package is designed for Bun, Deno and +edge runtimes with `fetch` and WebCrypto, such as Cloudflare Workers and Vercel Edge Functions +(see [Edge runtimes, Bun and Deno](#edge-runtimes-bun-and-deno)). + +## Quick start + +```ts +import { ShieldLabs, evaluateIdentification, webhooks } from '@shieldlabs-ai/node'; + +const shieldlabs = new ShieldLabs({ apiKey: process.env.SHIELDLABS_API_KEY! }); + +// One identification authorizes one action. This in-memory set keeps the example short; in +// production, claim request IDs atomically in Redis or your database (see "Apply a policy"). +const usedRequestIds = new Set(); + +function claimRequestId(requestId: string): boolean { + if (usedRequestIds.has(requestId)) return false; + usedRequestIds.add(requestId); + return true; +} + +// 1. Call this with the requestId the browser sent together with the signup form. +export async function allowSignup(requestId: string): Promise { + // Scoring is asynchronous: this waits (up to 10 s by default) until the verdict is stored. + const identification = await shieldlabs.identifications.get(requestId); + + // 2. Missing, reused, stale, rate-limited, automated or dangerous: refuse. + const firstUse = identification !== null && claimRequestId(identification.request_id); + const verdict = evaluateIdentification(identification, { isReplay: () => !firstUse }); + return verdict.ok; +} + +// 3. Call this with the raw body and the X-Shield-Signature header of a webhook delivery. +export function handleWebhook(rawBody: string | Uint8Array, signatureHeader: string | null): void { + const event = webhooks.constructEvent( + rawBody, + signatureHeader, + process.env.SHIELDLABS_WEBHOOK_SECRET!, + ); + if (event.event_type === 'identification.scored') { + console.log(event.data.request_id, event.data.risk_score, event.data.detection_flags.vpn); + } +} +``` + +Environment variables used throughout: `SHIELDLABS_API_KEY` (Private API Key, `sec_...`), +`SHIELDLABS_WEBHOOK_SECRET` (endpoint signing secret, `whsec_...`), `SHIELDLABS_SECRET_KEY` and +`SHIELDLABS_DOMAIN` (Management API), and `SHIELDLABS_API_BASE_URL` / +`SHIELDLABS_MANAGEMENT_BASE_URL` to point at another host in development and tests (https, or +plain http on `localhost`). The SDK never reads the environment itself: pass the values to the +constructors. For development and staging, register a separate domain in the analytics dashboard +and use its keys. + +A runnable version of this flow is in [`examples/node-http`](examples/node-http). + +## Guide + +### Wait for the verdict + +The History row of an identification appears about 1 to 3 seconds after the browser call and can +be refined for up to about 10 seconds while follow-up checks finish (network checks such as the +local IP arrive in later versions of the row). Start the identification when the user begins the +action, for example when the signup form gets focus, so the verdict is usually stored by the time +the form is submitted. `identifications.get(requestId)` polls the History API until the row +appears and returns the first version it sees. How it waits: + +- **Total budget.** `timeout` (default 10 000 ms) is the time budget of the whole call, counted + from the call and including the time requests take. +- **Schedule.** The first poll runs at once. With `pollInterval` p (default 250 ms), the waits + between polls are p, 2p, 4p, 6p and then 8p for every later wait, each capped at 2 s, or at p + when p is longer: 250 ms, 500 ms, 1 s, 1.5 s and then every 2 s by default. A `pollInterval` + of 1 000 waits 1 s and then every 2 s, and one of 3 000 polls every 3 s. A wait that would pass + the deadline is cut short, so the last poll runs at the deadline. +- **One attempt per poll.** Each poll is a single HTTP attempt that is never retried + (`maxRetries` does not apply). Its timeout is the client `timeout`, shortened to the time left + but never below 1 s. +- **Transient errors keep polling.** A 429, a 5xx, a connection error or an attempt timeout does + not end the wait. +- **429.** The next wait is the longest of the scheduled wait, 1 s (the History limit counts + requests per second) and `Retry-After` capped at 10 s. A missing `Retry-After`, `0` or a date in + the past counts as 0, so the 1 s floor still applies. That wait is cut short at the deadline like + any other, and the last poll runs there. When the capped `Retry-After` is longer than the time + left, that `RateLimitError` is thrown at once. +- **At the deadline.** If the last poll failed, its error is thrown. If it found nothing, the call + resolves `null`. +- **Errors that do not heal.** 400, 401, 403 and 404 are thrown at once: a wrong key or base URL + stays wrong. + +```ts +const identification = await shieldlabs.identifications.get(requestId, { + timeout: 5_000, // total budget: the last poll runs 5 s after the call + pollInterval: 250, // waits of p, 2p, 4p, 6p, then 8p, each at most max(2 s, p) + signal: AbortSignal.timeout(6_000), // hard limit, including the last request +}); + +if (identification === null) { + // Unverified, never "clean": treat it like a failed check. +} + +// Read once, without waiting: +const latest = await shieldlabs.identifications.get(requestId, { wait: false }); +``` + +`null` means no row existed for the request ID when the last poll ran. It also covers an +identification that was never stored, for example when the visitor's IP went over the per-IP rate +limit of identifications: the browser still gets a request ID, but no row is written for it. With +`wait: false`, `identifications.get` reads once and uses the client's normal retries. To read the +refined row later, call `identifications.get(requestId, { wait: false })` again after about 10 +seconds. + +### Apply a policy + +`evaluateIdentification` turns the guard logic every integration needs into one call. Checks run +in this order and the first failure wins: + +| Order | Check | `reason` | +|---|---|---| +| 1 | No identification (`null`) | `missing` | +| 2 | `isReplay(requestId)` returned true | `replayed` | +| 3 | Older than `maxAge` (default 5 minutes, by `observed_at`; `Infinity` skips this check) | `stale` | +| 4 | Risk Score above 100 (the 999 rate-limit marker) | `rate_limited` | +| 5 | All-zero device ID (no usable device signals) | `no_device_signals` | +| 6 | A flag in `blockFlags` (default `browser_automation`, `javascript_disabled`) | `blocked_flag` | +| 7 | A band in `blockBands` (default `dangerous`) | `blocked_band` | + +```ts +// Claim the request ID before evaluating: an atomic insert-if-absent, so that two concurrent +// requests with the same ID cannot both pass. With node-redis, SET NX answers 'OK' only once. +const claimed = + identification !== null && + (await redis.set(`shieldlabs:rid:${identification.request_id}`, '1', { NX: true, EX: 600 })) === + 'OK'; + +const verdict = evaluateIdentification(identification, { + maxAge: 2 * 60_000, + blockBands: ['dangerous'], + blockFlags: ['browser_automation', 'javascript_disabled', 'tor'], + isReplay: () => !claimed, +}); +// A Tor identification: { ok: false, reason: 'blocked_flag', band: 'dangerous', flag: 'tor' } +``` + +The defaults are a starting point to tune for each protected action. One identification should +authorize one action, and the SDK keeps no state: `isReplay` must answer synchronously, so claim +the request ID in a shared store first and pass the result. Use an atomic insert-if-absent (Redis +`SET key 1 NX EX 600`, or an insert into a table with a unique key on the request ID), not a +lookup followed by a separate write, which lets two concurrent requests with the same ID both +pass. If the protected action does not go ahead after a passing check, you can delete the claim +so the request ID can be used again. + +A few rules that keep decisions sound: + +- Branch on `detection_flags` and `risk_score`. Signal names are for display and logs. +- Never sum signal weights yourself: weights can be negative or informational. +- `riskBand(score)` returns `trusted` (0-29), `suspicious` (30-59) or `dangerous` (60-100), and + `rate_limited` for the 999 marker, which is never a score (`isRateLimited(score)` checks it). +- The device ID `00000000-0000-0000-0000-000000000000` (`NIL_UUID`) means no usable device + signals reached ShieldLabs. + +### Search history for account-abuse checks + +`history.search(type, value)` reads identifications by one identifier, newest first. Lookup types +are `user_hid`, `device_id`, `visitor_id`, `ip` (IPv4), `request_id`, `session_id` and +`cookie_id`. Arguments are validated before anything is sent, because the server does not reject +an unknown type or a malformed UUID or IP. + +```ts +import { NIL_UUID } from '@shieldlabs-ai/node'; + +// User HID values that do not name one of your accounts (null is skipped as well). +const NOT_AN_ACCOUNT = new Set(['anonymous', 'fail', '-1', 'unknown']); + +// How many accounts has this device been used with? (The all-zero device ID groups +// identifications without device signals, so skip it.) +const accounts = new Set(); +if (identification.device_id !== NIL_UUID) { + for await (const item of shieldlabs.history.iterate('device_id', identification.device_id, { maxItems: 500 })) { + if (item.user_hid !== null && !NOT_AN_ACCOUNT.has(item.user_hid)) accounts.add(item.user_hid); + } +} +if (accounts.size >= 3) { + // Route the signup to review. +} + +// One page at a time: +const page = await shieldlabs.history.search('user_hid', hashedUserId, { limit: 50, offset: 0 }); +console.log(page.total, page.data.map((item) => item.public_ip.country)); // "Germany", "France", ... +``` + +`history.iterate` pages with `offset` (100 rows per request by default), skips rows repeated +across pages by `request_id` (new rows can shift offsets) and stops at `total`, at an empty page +or after `maxItems`. Each request counts toward the History API rate limit. + +A User HID is sent as one URL path segment in canonical form, so values with `@`, `+`, `=` or +spaces (an email address, a base64 string) match exactly. A value that contains `/`, and the +values `.` and `..`, cannot be searched and throw `ValidationError`. User HIDs from `userHid()` +are 64 hex characters and always work. + +### Receive webhooks + +Every scored identification is sent to each enabled endpoint as a signed `POST`. Verify the +signature over the exact bytes you received, before parsing anything: + +```js +import express from 'express'; +import { SignatureVerificationError, webhooks } from '@shieldlabs-ai/node'; + +const app = express(); + +// express.raw keeps the body as a Buffer. Register this route before any global express.json(). +app.post('/webhooks/shieldlabs', express.raw({ type: 'application/json' }), (req, res) => { + let event; + try { + event = webhooks.constructEvent(req.body, req.get('x-shield-signature'), process.env.SHIELDLABS_WEBHOOK_SECRET); + } catch (error) { + return res.sendStatus(error instanceof SignatureVerificationError ? 401 : 400); + } + res.sendStatus(200); // answer within 1 second, then process + + if (event.event_type === 'identification.scored') { + jobs.enqueue(event.data); // make the job idempotent on event.data.request_id + } +}); ``` -## Verify webhooks +What to know about deliveries: + +- The header is `X-Shield-Signature: sha256=`, keyed with the + full signing secret including its `whsec_` prefix. It is the only ShieldLabs header, so the + idempotency key comes from the body: `data.request_id`. +- Today each identification is delivered once per enabled endpoint: one attempt with a 1-second + timeout and no retries. Future retries will resend identical bytes, so make handlers + idempotent on `data.request_id` now. +- For guaranteed reads, use the History API: a missed delivery is not sent again, and a History + row can be refined after its webhook was sent. +- To rotate a secret without downtime, pass both secrets: `constructEvent(body, header, [newSecret, oldSecret])`. + In an environment variable, keep them comma-separated and split them: + `process.env.SHIELDLABS_WEBHOOK_SECRET!.split(',').map((secret) => secret.trim())`. +- `event_type` is `identification.scored`, `webhook.ping` (Verify in the analytics dashboard, no + `data`) or an event type this version does not know yet, returned as an `UnknownWebhookEvent` + with the parsed body in `raw` instead of throwing. +- The Test delivery from the analytics dashboard parses like production traffic; flags it does + not carry are `false`. + +`webhooks.verifySignature(payload, header, secret)` returns a boolean instead of throwing. + +### Read the domain profile + +The Management API returns the remaining included identifications of the account and the masked +keys of the domain. ```ts -import { verifyWebhook } from "@shieldlabs/node"; -import type { WebhookEvent } from "@shieldlabs/node"; +import { ShieldLabsManagement } from '@shieldlabs-ai/node'; + +const management = new ShieldLabsManagement({ + secretKey: process.env.SHIELDLABS_SECRET_KEY!, + domain: process.env.SHIELDLABS_DOMAIN!, // "https://www.Example.com/" is sent as "example.com" +}); + +const profile = await management.getProfile(); +// { domain: 'example.com', remaining_identifications: 148230, public_key_masked: '****...a3f8', ... } +``` + +`remaining_identifications` is negative when the account is over its included volume. Call the +Management API sparingly and cache the profile: it allows about 15 requests per minute per IP and +then blocks the IP for 10 minutes, so this client never retries a 429. + +### Create a User HID -// Pass the raw body bytes exactly as received (before JSON.parse). -const ok = verifyWebhook(rawBody, req.headers["x-shield-signature"]!, process.env.SHIELDLABS_WEBHOOK_SECRET!); -if (!ok) throw new Error("invalid signature"); +The browser agent links identifications to your accounts through a User HID. Compute it on your +server and pass it to the browser SDK, instead of a raw email address or account ID: -const event = JSON.parse(rawBody.toString()) as WebhookEvent; -if (event.event_type === "webhook.ping") return; -// handle event.data — idempotent on event.data.request_id +```ts +import { userHid } from '@shieldlabs-ai/node'; + +// hidSecret: a long random value you create once and keep on your server. Changing it changes +// every User HID, so treat it as permanent. +const hid = userHid(user.id, hidSecret); // HMAC-SHA256, 64 lowercase hex characters ``` -Signature: `X-Shield-Signature: sha256=` + hex(HMAC-SHA256(secret, raw_body)). Schema version `2026-06-01`. +The same account always gets the same User HID, and the value cannot be reversed. Search its +history with `history.search('user_hid', hid)`. + +### Rate limits -## History API +| API | Limit | What the SDK does | +|---|---|---| +| History API | About 15 requests per second per domain, shared by every caller of that domain | `identifications.get` polls on a backoff schedule, and inside its wait a 429 waits at least 1 s. Ordinary calls (`history.search`, `history.iterate`, `identifications.get` with `wait: false`) follow `Retry-After` as sent, up to 10 s, and wait at least 1 s after a 429 without it | +| Management API | About 15 requests per minute per IP, then the IP is blocked for 10 minutes | A 429 is raised at once, never retried | + +History reads and Management calls never use your included identifications. + +### Edge runtimes, Bun and Deno + +Bundlers and runtimes that set the `worker`, `workerd`, `edge-light`, `browser` or `deno` export +condition get a build that uses only `fetch` and WebCrypto and never loads `node:crypto`. There, +use the Async webhook helpers; the synchronous ones throw an error that points to them. + +The `browser` condition serves edge bundlers. The Private API Key and the Secret Key belong on +your server: a client created in a web page (a `window` with a `document`) prints a one-time +warning, because every visitor could read its key there. ```ts -import { ShieldLabsClient } from "@shieldlabs/node"; +// Cloudflare Worker +import { webhooks } from '@shieldlabs-ai/node'; + +export default { + async fetch(request: Request, env: { SHIELDLABS_WEBHOOK_SECRET: string }): Promise { + const body = new Uint8Array(await request.arrayBuffer()); + try { + const event = await webhooks.constructEventAsync(body, request.headers.get('x-shield-signature'), env.SHIELDLABS_WEBHOOK_SECRET); + // handle event + return new Response(null, { status: 200 }); + } catch { + return new Response(null, { status: 401 }); + } + }, +}; +``` -const client = new ShieldLabsClient({ apiKey: process.env.SHIELDLABS_API_KEY! }); -const { data, total } = await client.getHistory("request_id", requestId); +`userHidAsync` computes a User HID with WebCrypto. Bun resolves the Node.js build, where both +flavors work. + +## Reference + +### `new ShieldLabs(options)` + +History API client, authenticated with the Private API Key of one domain. Safe for concurrent use: +create one per process. + +| Option | Default | Notes | +|---|---|---| +| `apiKey` | required | Private API Key (`sec_...`), visible ASCII characters only. A key in another format triggers a one-time warning | +| `baseUrl` | `https://account.shieldlabs.ai` | Origin of the History API; a trailing `/api` is removed | +| `timeout` | `10000` | Milliseconds per HTTP attempt | +| `maxRetries` | `2` | Retries for connection errors, timeouts, 429 and 5xx | +| `fetch` | global `fetch` | Custom fetch implementation | +| `allowInsecureHttp` | `false` | Accept a plain http `baseUrl` on a host other than `localhost`, `127.0.0.1` or `[::1]`, for a test server. Without it such a URL throws `ValidationError` | + +| Method | Returns | Notes | +|---|---|---| +| `identifications.get(requestId, { wait?, timeout?, pollInterval?, signal? })` | `Promise` | `wait` true, `timeout` 10 000 ms (total budget of the wait), `pollInterval` 250 ms by default. `pollInterval` p gives waits of p, 2p, 4p, 6p and then 8p, each at most max(2 s, p). See [Wait for the verdict](#wait-for-the-verdict) | +| `history.search(type, value, { limit?, offset?, signal? })` | `Promise` | `limit` 1-100 (default 20), `offset` 0 or more (default 0). A `user_hid` value cannot contain `/` | +| `history.iterate(type, value, { pageSize?, maxItems?, signal? })` | `AsyncGenerator` | `pageSize` 1-100 (default 100), `maxItems` 0 or more (default no limit; 0 yields nothing) | + +### `new ShieldLabsManagement(options)` + +| Option | Default | Notes | +|---|---|---| +| `secretKey` | required | Secret Key of the domain, visible ASCII characters only | +| `domain` | required | Registered domain; trimmed, lowercased, without scheme, path or leading `www.`. International domains in punycode (`xn--...`) | +| `baseUrl` | `https://api.shieldlabs.ai` | Origin of the Management API | +| `timeout` | `10000` | Milliseconds per HTTP attempt | +| `maxRetries` | `2` | Retries for connection errors, timeouts and 5xx (never 429) | +| `fetch` | global `fetch` | Custom fetch implementation | +| `allowInsecureHttp` | `false` | Same as for `ShieldLabs` | + +| Method | Returns | +|---|---| +| `getProfile({ signal? })` | `Promise` | + +### Functions and constants + +| Export | Description | +|---|---| +| `webhooks.verifySignature(payload, header, secret)` | `boolean`. `payload`: string, `Uint8Array`/`Buffer` or `ArrayBuffer`; `secret`: string or list | +| `webhooks.constructEvent(payload, header, secret)` | `WebhookEvent`; throws `SignatureVerificationError` or `WebhookParseError` | +| `webhooks.verifySignatureAsync(...)`, `webhooks.constructEventAsync(...)` | Promise-based equivalents for every runtime | +| `evaluateIdentification(identification, options?)` | `{ ok, reason, band, flag? }` (see [Apply a policy](#apply-a-policy)) | +| `riskBand(score)` | `'trusted' \| 'suspicious' \| 'dangerous' \| 'rate_limited'` | +| `isRateLimited(score)` | `true` for the 999 marker (any score above 100) | +| `userHid(userId, secret)`, `userHidAsync(userId, secret)` | HMAC-SHA256 User HID as 64 lowercase hex characters | +| `SIGNALS` | Known risk signal slugs, for example `SIGNALS.VPN === 'vpn'`. Signal names are an open set | +| `RISK_BANDS` | `{ trusted: { min: 0, max: 29 }, suspicious: { min: 30, max: 59 }, dangerous: { min: 60, max: 100 } }` | +| `NIL_UUID` | `'00000000-0000-0000-0000-000000000000'` | +| `VERSION` | SDK version, also sent in the `User-Agent` header | + +### `Identification` + +The same shape whether it comes from a webhook or from a History row. Property names follow the +webhook JSON. + +| Property | Type | Notes | +|---|---|---| +| `request_id`, `visitor_id`, `device_id`, `session_id`, `cookie_id` | `string` | UUIDs; the all-zero UUID is possible | +| `user_hid` | `string \| null` | `"anonymous"` for anonymous checks; `null` when it was empty | +| `domain` | `string` | Registered domain of the site | +| `public_ip`, `local_ip` | `{ ip, country }` | IPv4 or `""`; English country name (`"Germany"`) or `""` | +| `connection_type` | `string` | `direct`, `mobile`, `vpn`, `proxy`, `tor`, `privacy_relay`, `browser_vpn_proxy`, `unknown` | +| `os`, `browser`, `device_type` | `string` | `device_type` is `desktop`, `mobile`, `tablet` or `unknown` | +| `traffic_source` | object | `channel`, `referrer_domain`, `landing_url`, `click_id_type`, `utm_*` (`""` when absent) | +| `risk_score` | `number` | 0-100, or 999 for the rate-limit marker | +| `signals` | `{ name, weight, description }[]` | Weighted risk signals; `description` is `null` for webhooks | +| `detection_flags` | object of 19 booleans | `vpn`, `privacy_relay`, `browser_vpn_proxy`, `tor`, `proxy`, `datacenter_ip`, `abuser`, `os_mismatch`, `os_not_detected`, `timezone_mismatch`, `anti_detect_browser`, `browser_automation`, `ip_mismatch`, `incognito`, `search_bot`, `suspicious_paid_click`, `javascript_disabled`, `stun_not_checked`, `check_incomplete` | +| `observed_at` | `string \| null` | RFC 3339 UTC with milliseconds, for example `2026-09-30T12:34:56.123Z` | +| `source` | `'webhook' \| 'history'` | Which payload it was built from | +| `raw` | object | The original payload, including fields the model omits | + +Other exported types: `IdentificationSignal`, `DetectionFlags`, `TrafficSource`, `IpInfo`, +`HistoryPage`, `DomainProfile`, `WebhookEvent` (`IdentificationScoredEvent`, `WebhookPingEvent`, +`UnknownWebhookEvent`), `LookupType`, `RiskBand`, `Evaluation` and the option types. + +## Errors and retries + +Every error extends `ShieldLabsError`. + +| Class | When | +|---|---| +| `ValidationError` | Invalid arguments, detected before any request is sent | +| `ApiError` | Any other error status, or a response body the SDK cannot use; has `status`, `body`, `headers` | +| `BadRequestError` | 400 | +| `AuthenticationError` | 401 or 403: wrong key, secret or domain, or a disabled domain | +| `QuotaExceededError` | 402. Neither the History API nor the Management API returns it today: an account over its included volume shows a negative `remaining_identifications` | +| `NotFoundError` | 404: usually a wrong base URL | +| `RateLimitError` | 429; `retryAfter` holds the seconds from `Retry-After` when present | +| `ServerError` | 5xx, including gateway errors | +| `ConnectionError` | The request never got a response; the message names the network cause, for example `connect ECONNREFUSED` | +| `TimeoutError` | An attempt took longer than `timeout` | +| `SignatureVerificationError` | Missing, malformed or wrong `X-Shield-Signature`, or no secret | +| `WebhookParseError` | The body is authentic but is not a usable event | + +Requests are retried (`maxRetries`, default 2) on connection errors, timeouts, 429 and 5xx, with +exponential backoff and jitter (0.5 s base, doubling, capped at 8 s). `Retry-After` is followed as +sent, capped at 10 s (`0` retries at once), and a 429 without it waits at least 1 s. 400, 401, 402, +403 and 404 are never retried, and neither is a Management API 429. +While `identifications.get` waits for a verdict, its polling schedule takes the place of these +retries: one attempt per poll (see [Wait for the verdict](#wait-for-the-verdict)). +Pass an `AbortSignal` as `signal` to cancel a call; it rejects with the signal's reason. Error +messages never include keys or secrets: a key, secret or domain with characters that cannot be +sent in an HTTP header (line breaks, NUL, spaces, non-ASCII) throws `ValidationError` when the +client is created, without repeating the value. + +## Compatibility + +- Node.js 18, 20, 22 and 24, tested in CI. The package is also designed for Bun, Deno, Cloudflare + Workers and Vercel Edge Functions: their builds use only `fetch` and WebCrypto. +- ESM and CommonJS builds with TypeScript declarations. +- Webhook schema version `2026-06-01`. Unknown fields, flags and event types are tolerated; an + unknown `schema_version` is accepted with a one-time warning. +- Semantic versioning: breaking changes only in a new major version. + +## Development + +```bash +npm ci +npm run typecheck +npm run lint +npm test -- --coverage +npm run build +npm run test:package # export conditions, edge bundles, published files +npm run test:runtime # built package, Node.js entry and edge entry +npm run test:example # examples/node-http end to end ``` -Base URL defaults to `https://account.shieldlabs.ai/api` (Shield.Portal.Admin). +See [CONTRIBUTING.md](CONTRIBUTING.md). Documentation: [docs.shieldlabs.ai](https://docs.shieldlabs.ai). +Support: [contact@shieldlabs.ai](mailto:contact@shieldlabs.ai). ## License -[MIT](./LICENSE) +[MIT](LICENSE) diff --git a/eslint.config.js b/eslint.config.js new file mode 100644 index 0000000..a536bc8 --- /dev/null +++ b/eslint.config.js @@ -0,0 +1,52 @@ +import { fileURLToPath } from 'node:url'; +import js from '@eslint/js'; +import globals from 'globals'; +import tseslint from 'typescript-eslint'; +import { defineConfig } from 'eslint/config'; + +const rootDir = fileURLToPath(new URL('.', import.meta.url)); + +export default defineConfig( + { ignores: ['dist/', 'coverage/', 'node_modules/'] }, + js.configs.recommended, + { + files: ['**/*.ts'], + extends: [tseslint.configs.recommendedTypeChecked], + languageOptions: { + parserOptions: { + projectService: true, + tsconfigRootDir: rootDir, + }, + }, + rules: { + '@typescript-eslint/consistent-type-imports': 'error', + // An AbortSignal reason can be any value; it is passed on unchanged. + '@typescript-eslint/prefer-promise-reject-errors': ['error', { allowThrowingUnknown: true }], + '@typescript-eslint/no-unused-vars': [ + 'error', + { argsIgnorePattern: '^_', varsIgnorePattern: '^_' }, + ], + eqeqeq: ['error', 'always', { null: 'ignore' }], + 'no-console': ['error', { allow: ['warn'] }], + }, + }, + { + files: ['test/**/*.ts'], + rules: { + '@typescript-eslint/no-unsafe-assignment': 'off', + '@typescript-eslint/no-unsafe-member-access': 'off', + '@typescript-eslint/no-unsafe-argument': 'off', + '@typescript-eslint/no-unsafe-call': 'off', + '@typescript-eslint/no-unsafe-return': 'off', + '@typescript-eslint/require-await': 'off', + '@typescript-eslint/no-explicit-any': 'off', + 'no-console': 'off', + }, + }, + { + files: ['**/*.js', '**/*.mjs'], + languageOptions: { + globals: { ...globals.node }, + }, + }, +); diff --git a/examples/node-http/README.md b/examples/node-http/README.md new file mode 100644 index 0000000..84959ce --- /dev/null +++ b/examples/node-http/README.md @@ -0,0 +1,35 @@ +# node:http example + +A signup endpoint and a webhook receiver on plain `node:http`, with no framework and no other +dependencies. + +- `POST /signup` reads `requestId` from the JSON body, waits for the identification with + `identifications.get`, and refuses the signup when the identification is missing, reused, + stale, rate limited, without device signals, automated or in the dangerous band. +- `POST /webhooks/shieldlabs` verifies `X-Shield-Signature` on the raw body, answers 200 at once + and logs each `identification.scored` delivery once per request ID. + +## Run it from this repository + +```bash +npm ci && npm run build +SHIELDLABS_API_KEY=sec_your_private_key \ +SHIELDLABS_WEBHOOK_SECRET=whsec_your_signing_secret \ +node examples/node-http/server.mjs +``` + +Inside this repository `@shieldlabs-ai/node` resolves to the local build. In your own project, +install the package (`npm install @shieldlabs-ai/node`) and copy `server.mjs`. While you rotate the +webhook signing secret, set `SHIELDLABS_WEBHOOK_SECRET` to the new and the old secret separated +by a comma. + +## Try it + +```bash +curl -s -X POST localhost:3000/signup \ + -H 'content-type: application/json' \ + -d '{"requestId":"7c1e2f4a-3b6d-4e8f-9a0b-1c2d3e4f5a6b"}' +``` + +The request ID comes from `identify()` in the browser SDK. Each one authorizes a single signup: +sending the same request ID twice is refused with `"reason":"replayed"`. diff --git a/examples/node-http/server.mjs b/examples/node-http/server.mjs new file mode 100644 index 0000000..c347522 --- /dev/null +++ b/examples/node-http/server.mjs @@ -0,0 +1,166 @@ +// A minimal ShieldLabs integration on plain node:http, with no framework and no dependencies. +// +// POST /signup { "requestId": "" } +// POST /webhooks/shieldlabs signed identification.scored and webhook.ping deliveries +// +// Environment: +// SHIELDLABS_API_KEY Private API Key (sec_...) +// SHIELDLABS_WEBHOOK_SECRET endpoint signing secret (whsec_...); while you rotate secrets, +// the new and the old secret separated by a comma +// SHIELDLABS_API_BASE_URL optional History API override (development and tests) +// PORT optional, default 3000 +import { createServer } from 'node:http'; +import { + ShieldLabs, + SignatureVerificationError, + ValidationError, + WebhookParseError, + evaluateIdentification, + riskBand, + webhooks, +} from '@shieldlabs-ai/node'; + +const apiKey = process.env.SHIELDLABS_API_KEY; +const webhookSecrets = (process.env.SHIELDLABS_WEBHOOK_SECRET ?? '') + .split(',') + .map((secret) => secret.trim()) + .filter((secret) => secret !== ''); +if (!apiKey || webhookSecrets.length === 0) { + console.error( + 'Set SHIELDLABS_API_KEY and SHIELDLABS_WEBHOOK_SECRET before starting the example.', + ); + process.exit(1); +} + +// One client for the whole process: it is safe for concurrent use. +const shieldlabs = new ShieldLabs({ apiKey, baseUrl: process.env.SHIELDLABS_API_BASE_URL }); + +// Request IDs that already authorized a signup. With more than one process, claim them in a +// shared store with an atomic insert-if-absent (Redis SET key 1 NX EX 600, or an insert into a +// table with a unique key) and an expiry a little longer than the freshness window. +const usedRequestIds = new Set(); + +// True for the first caller only. Nothing is awaited between the check and the add, so this is +// atomic within one process. +function claimRequestId(requestId) { + if (usedRequestIds.has(requestId)) return false; + usedRequestIds.add(requestId); + return true; +} + +// Deliveries already handled, by request ID. Today each identification is delivered once (one +// attempt, 1-second timeout, no retries); future retries resend identical bytes, so handlers +// must be idempotent on data.request_id. +const handledDeliveries = new Set(); + +const MAX_BODY_BYTES = 1_000_000; + +function readBody(req) { + return new Promise((resolve, reject) => { + const chunks = []; + let size = 0; + req.on('data', (chunk) => { + size += chunk.length; + if (size > MAX_BODY_BYTES) { + reject(Object.assign(new Error('Request body too large'), { statusCode: 413 })); + req.destroy(); + return; + } + chunks.push(chunk); + }); + req.on('end', () => resolve(Buffer.concat(chunks))); + req.on('error', reject); + }); +} + +function sendJson(res, status, body) { + res.writeHead(status, { 'content-type': 'application/json' }); + res.end(JSON.stringify(body)); +} + +async function handleSignup(req, res) { + let payload; + try { + payload = JSON.parse((await readBody(req)).toString('utf8')); + } catch { + return sendJson(res, 400, { error: 'Send a JSON body with requestId.' }); + } + const requestId = typeof payload?.requestId === 'string' ? payload.requestId : ''; + if (!requestId) return sendJson(res, 400, { error: 'requestId is required.' }); + + let identification; + try { + // Scoring is asynchronous: this waits (with backoff) until the verdict is stored. + identification = await shieldlabs.identifications.get(requestId); + } catch (error) { + if (error instanceof ValidationError) + return sendJson(res, 400, { error: 'requestId must be a UUID.' }); + throw error; + } + + // Claim the request ID first, then evaluate. Refuses a missing identification (unverified, + // never clean), a reused request ID, an identification older than 5 minutes, the rate-limit + // marker, missing device signals, browser automation or disabled JavaScript, and the + // dangerous band. Tune the defaults. + const firstUse = identification !== null && claimRequestId(identification.request_id); + const verdict = evaluateIdentification(identification, { isReplay: () => !firstUse }); + if (!verdict.ok) { + return sendJson(res, 403, { ok: false, reason: verdict.reason }); + } + + // Create the account here. + return sendJson(res, 201, { ok: true, band: verdict.band }); +} + +async function handleWebhook(req, res) { + // Verify the raw bytes before parsing anything. + const rawBody = await readBody(req); + let event; + try { + event = webhooks.constructEvent(rawBody, req.headers['x-shield-signature'], webhookSecrets); + } catch (error) { + if (error instanceof SignatureVerificationError) + return sendJson(res, 401, { error: 'Invalid signature.' }); + if (error instanceof WebhookParseError) + return sendJson(res, 400, { error: 'Invalid payload.' }); + throw error; + } + + // Answer within one second, then do the work (or hand it to a queue). + sendJson(res, 200, { received: true }); + + switch (event.event_type) { + case 'identification.scored': { + const { data } = event; + if (handledDeliveries.has(data.request_id)) return; + handledDeliveries.add(data.request_id); + console.log( + `identification.scored ${data.request_id} risk_score=${data.risk_score} band=${riskBand(data.risk_score)}`, + ); + break; + } + case 'webhook.ping': + console.log('webhook.ping received: the endpoint is verified'); + break; + default: + console.log(`Ignoring event type ${String(event.event_type)}`); + } +} + +const routes = { + 'POST /signup': handleSignup, + 'POST /webhooks/shieldlabs': handleWebhook, +}; + +const server = createServer((req, res) => { + const handler = routes[`${req.method} ${(req.url ?? '').split('?')[0]}`]; + if (!handler) return sendJson(res, 404, { error: 'Not found.' }); + handler(req, res).catch((error) => { + console.error(error); + if (!res.headersSent) sendJson(res, error.statusCode ?? 500, { error: 'Internal error.' }); + }); +}); + +server.listen(Number(process.env.PORT ?? 3000), () => { + console.log(`Listening on http://localhost:${server.address().port}`); +}); diff --git a/package-lock.json b/package-lock.json index a99c173..772976e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,19 +1,86 @@ { - "name": "@shieldlabs/node", - "version": "0.1.0", + "name": "@shieldlabs-ai/node", + "version": "1.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "@shieldlabs/node", - "version": "0.1.0", + "name": "@shieldlabs-ai/node", + "version": "1.0.0", "license": "MIT", "devDependencies": { - "@types/node": "^20.0.0", - "tsup": "^8.0.0", - "tsx": "^4.0.0", - "typescript": "^5.4.0" + "@eslint/js": "^9.39.5", + "@types/node": "^20.19.43", + "@vitest/coverage-v8": "^4.1.11", + "eslint": "^9.39.5", + "globals": "^16.5.0", + "prettier": "^3.9.9", + "tsup": "^8.5.1", + "typescript": "^5.9.3", + "typescript-eslint": "^8.71.0", + "vite": "^6.4.3", + "vitest": "^4.1.11" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.9", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.9.tgz", + "integrity": "sha512-CjXrNHTnvqBVqHgdBysY3vk2T8tpJHb5/RMeHJBTyVa9xgugCB0CJTx/3oO8RV2QRQP391RWpB7D6hLjm8V9uA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.8" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz", + "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@bcoe/v8-coverage": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz", + "integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==", + "dev": true, + "license": "MIT", "engines": { "node": ">=18" } @@ -460,6 +527,229 @@ "node": ">=18" } }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.10.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.10.1.tgz", + "integrity": "sha512-cuadcxVFE8sDK6iWJbs8Sn0av2Nrh2QSGQhVlBW9AaAHqHwjWsZHT8LJ4hFGPh7ASBV2deFdM7H/DPjulmh8rg==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.21.2", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.2.tgz", + "integrity": "sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^2.1.7", + "debug": "^4.3.1", + "minimatch": "^3.1.5" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz", + "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/core": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz", + "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "3.3.7", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.7.tgz", + "integrity": "sha512-F42g89Qd5oAWtp0k0nnSrjziAKza7w8SVT4mStc18LZMaRb4J1HQAHLCalEtDCxrTuksx7NU9qsmeLwpOfPqWw==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^6.14.0", + "debug": "^4.3.2", + "espree": "^10.0.1", + "globals": "^14.0.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.3.2", + "minimatch": "^3.1.5", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/eslintrc/node_modules/globals": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", + "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@eslint/js": { + "version": "9.39.5", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.5.tgz", + "integrity": "sha512-QywQuszQh77pIXCsq998c8hbhSTI/azTty1Z6N53dmAudKHhy573j3yvRLsX2BSp8YpLtoCEG8E9DJe+8zUh4A==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + } + }, + "node_modules/@eslint/object-schema": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz", + "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz", + "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0", + "levn": "^0.4.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", + "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/types": "^0.15.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz", + "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.2", + "@humanfs/types": "^0.15.0", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/types": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz", + "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", @@ -482,9 +772,9 @@ } }, "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.5.5", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", - "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", + "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", "dev": true, "license": "MIT" }, @@ -499,10 +789,27 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@napi-rs/lzma-linux-x64-gnu": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz", + "integrity": "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^22.20 || ^24.12 || >=25" + } + }, "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.62.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.2.tgz", - "integrity": "sha512-6o7ZLZK+BeenkZCFNDXqpbjw9bD6nuWonvS/lwQJp7NoVVxm6p3qE7qQ5jGuBjiFsgvqjD8mZAU5oWxTmbOeOg==", + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.63.5.tgz", + "integrity": "sha512-J25QJU+B78T4FhhBsNpLJyVWOi31mwtpcMwywHmOKH65Q9IWGA81gPj+dnwlhU8wktVriYE+tFAaQgrnJRzAZg==", "cpu": [ "arm" ], @@ -514,9 +821,9 @@ ] }, "node_modules/@rollup/rollup-android-arm64": { - "version": "4.62.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.2.tgz", - "integrity": "sha512-BaH7BllCACHoH1LguOU56UItGfUWjujlO65kS9LAodViaN4bwIKd7oeW/ZHJ/4ljr/7MIiENnNy3HJ0zXv8Zkw==", + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.63.5.tgz", + "integrity": "sha512-LDopB3zuZM5Ux9TT2luNEBJW/tYbGU2g1d+VpKk6I+gSKDb+/7sYE6M225gRQt4RbMX6MSwMsVR/phdjVUgRLg==", "cpu": [ "arm64" ], @@ -528,9 +835,9 @@ ] }, "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.62.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.2.tgz", - "integrity": "sha512-v39RCCvj4He82I9sFmk+M1VZ0PLM9sfsLVikjfx2hYBNALhrrOR2D3JjQA6AhlaSOgcR+RzrKY7e1+bT6SUO/A==", + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.63.5.tgz", + "integrity": "sha512-wlJEERGfeuHeBavCL2qVnNacOK43NDoZM4sjkeRPymd04OAE9T1zBqDJgmZ+CIsPTYKwdzpUC8vmOw84dwY4Tg==", "cpu": [ "arm64" ], @@ -542,9 +849,9 @@ ] }, "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.62.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.2.tgz", - "integrity": "sha512-yl0y2vq3S3lHeuXhEdss6TWfKW8vkujImO12tn4ZkG/4oghr09LvdYm2RElVjokTQiUvDUGXLGsYeLqUMCKpGA==", + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.63.5.tgz", + "integrity": "sha512-4nJJGg5jbo2wwPP4JP+LfEBA3bvP8rU9CLuhp7jWvq9sxEyhjQFTFdrqi+/dHEin/pd8jpT0vcehIpnZtmEdcQ==", "cpu": [ "x64" ], @@ -556,9 +863,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.62.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.2.tgz", - "integrity": "sha512-tT4pvt4qXD+vEoezupCWi+a1F0vvDiksiHc+PxRlYTOH1I6/X4id9jPxTP+Fg+545euaFT1jJVs4CEdHZAU1vw==", + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.63.5.tgz", + "integrity": "sha512-DrZbyCDF1hneuO6jRbvZ2D7+PIBM6yIwYnJpg2vIk58T+wuFpiaGZrfUr59lDWw45bg+IrpTGLPiNi/Fk4w3Cg==", "cpu": [ "arm64" ], @@ -570,9 +877,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.62.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.2.tgz", - "integrity": "sha512-6nU5F2wCW+qvCBhTn1pdIU3bzsIoF7EUwsCDRxilWGprQR6yd508YnH9+OKFCwpfS8pjZqDUmnCAr7exax0XCg==", + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.63.5.tgz", + "integrity": "sha512-gqfUVMJMB3mehqywxp6hTBFfgtMQykZY19+cfiaYP0toIJLb/1DZRJHVkQQGP13W4TAwfZDWeg1qBcheTRioXQ==", "cpu": [ "x64" ], @@ -584,16 +891,13 @@ ] }, "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.62.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.2.tgz", - "integrity": "sha512-n1GJHPOvpIfhi3TmrCeh6S6URt9BFCt0KQE3qvexyGCTAKpR4Lg+eWvNZEqu7epxwus/8ElT3hacYEucm49SZg==", + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.63.5.tgz", + "integrity": "sha512-CFmhpvAwzSaWMlN3VN7UtmoTihlZNzoP0juQib5TQRnYUyDV8dXeWOp29sobWAT6gXl/hQgAClLlEiYozQG3OQ==", "cpu": [ "arm" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -601,16 +905,13 @@ ] }, "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.62.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.2.tgz", - "integrity": "sha512-JqgflS8wEB+UXV/vS1RpRbifGBeN4D5lz8D8oOFbFZw4vedvdOgCFAjfBmIMdW3yL10XpQQ0Ambepw6MXrhOnA==", + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.63.5.tgz", + "integrity": "sha512-Uc9H8eXCOayV6JLTH5bXKMId6qbhNHa818/BgYjm4jrlq3vZquC9cqyvHBw17xy5Mnj5f+I3gFK5JcEf3hSqrw==", "cpu": [ "arm" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -618,16 +919,13 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.62.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.2.tgz", - "integrity": "sha512-wnFJkogWvN4jm/hQRF2UBaeUmk20j5+DmHvoyWii2b8HJDyvz1MF2OU/6ynXt2KR63rbZLWkFpoytpdc/yBuSA==", + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.63.5.tgz", + "integrity": "sha512-VcPr/szv/1BFw112Kt//fxulXt/JPqzzidU84iW68L2DdjnOO8QFUv2zTSYBEPHD6movBD4z+bbr5y60GYM7Jw==", "cpu": [ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -635,16 +933,13 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.62.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.2.tgz", - "integrity": "sha512-HVu2bp0zhvJ8xHEV9+UUs7S90VadmBSY3LcIMvozbPo4AuMGDWlz3ymHLHZPX4hR67TKTt8Qp5PJ5RBg/i+RMQ==", + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.63.5.tgz", + "integrity": "sha512-BnxtJ5/91BrIHYIkGrmjz/lbMhqEHt1dPFqIxIFR+jPn0xVc/oUSCtIT089zfp5ufwGDlYz2UC+Fe1SRBpYFbQ==", "cpu": [ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -652,16 +947,13 @@ ] }, "node_modules/@rollup/rollup-linux-loong64-gnu": { - "version": "4.62.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.2.tgz", - "integrity": "sha512-mQqqAV8QaoSgr9I2fKDLY2BAVvmKjWoGiu/cSYQonsLvtqwEn1E4QYfnCOcp5zoEqNhsDYin1s6jx/VJmrxlZg==", + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.63.5.tgz", + "integrity": "sha512-LrYcHZwF+fAMNKHYTOQ5osWM4AZF7YF6D+XtsjDyEvljtt11twc+zHVXBLNEjxVSUnKYsOhvVz4Z213eW02COQ==", "cpu": [ "loong64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -669,16 +961,13 @@ ] }, "node_modules/@rollup/rollup-linux-loong64-musl": { - "version": "4.62.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.2.tgz", - "integrity": "sha512-IxKLoxCQ2IWi6bT2akyDUBGsOImDKB+sPp4EsTmwFQ/fMwpCKm8uLSSgP/Kx/QYUgKis6SEZ5/Nlhup0DIA0PQ==", + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.63.5.tgz", + "integrity": "sha512-nj7QKQePAAUpCpJHtg0pR0W/b92A9NO17JS3BAQmHDn/yhmkir2p8llrKY9TOhleKIaSzy1JhxS3T9FVld6coA==", "cpu": [ "loong64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -686,16 +975,13 @@ ] }, "node_modules/@rollup/rollup-linux-ppc64-gnu": { - "version": "4.62.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.2.tgz", - "integrity": "sha512-Mk5ha2RQSgyFfmYYLkBpPnUk8D8FriBxesO1u9O75X0mHgXL1UQcH5Itl2lurWL2tj0RxV9b9tJgipac0hRY9A==", + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.63.5.tgz", + "integrity": "sha512-5ylkX6dWMeBKge9nTU+Rxfb+ZfaCIJ9lRqIFaK0eAMcWp7OJbYnLveLgXmm0VrvuLKb8qIK+mHyH0qu88RM+iA==", "cpu": [ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -703,16 +989,13 @@ ] }, "node_modules/@rollup/rollup-linux-ppc64-musl": { - "version": "4.62.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.2.tgz", - "integrity": "sha512-CjvEnqJL/0/TQ3TXX3OPIJ/kmBellrWd4heXUmHeJlTnmwjKpSJzoehLaL6Xk0ZnMHBu9dZuFADNOrtjF4v+2w==", + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.63.5.tgz", + "integrity": "sha512-oHK4ZHYFDKjZviK34I+NwgfbGxgI7ztrNxj2hPTSSNFgeq1a/lEd7dHV2fdGAuTH4Iym3RHJg+vAbWaWG4B7Zg==", "cpu": [ "ppc64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -720,16 +1003,13 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.62.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.2.tgz", - "integrity": "sha512-1SiZbzwdkaDURsew/tSOrooKiYy7EQGT6m8ufavAi9NEyQb/6VuIxFXAL1fqa4iZe3g4NbNk4P7J32z2tw5Mgg==", + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.63.5.tgz", + "integrity": "sha512-UcetmHZ6XOXuUByiKZyQmb55ZPr0LABr3Ec/HB9wKZn6CEAFWZkE+hsJErJ9hbPBC7nI0dKuELx7CoV6IM7TMg==", "cpu": [ "riscv64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -737,16 +1017,13 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.62.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.2.tgz", - "integrity": "sha512-nQts12zJ3NQRoE6uYljOH89v7szzLDvG2JD/vsX+vGXU8w/At1GowTZ5/7qeFQ8m7L55rpR8Okugnuo5bgjy2Q==", + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.63.5.tgz", + "integrity": "sha512-C5CmDPQBtvjVo8cgQsBs+w6WB0JLkiixhgi6hVLV11hERWdn/p0XcPU2OUcZzac9BPOFq7SbaHFa8r3SWEysCQ==", "cpu": [ "riscv64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -754,16 +1031,13 @@ ] }, "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.62.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.2.tgz", - "integrity": "sha512-E9/ll019jhPIJgpzfZoIkBGhcz+kKNgVWYRY0zr9srBdPPFVpvOKW8VaJKUbeK+eZXyQF9ltME+Kk6affeaPgg==", + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.63.5.tgz", + "integrity": "sha512-lHVQHJFKsuuxLMi3MQO9XVL8Tje3JR82CzB+QDKC5NWBcsIWuwsn9uIM5e3lBhI+fF1/s63qnyYqsg65+8rV/w==", "cpu": [ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -771,16 +1045,13 @@ ] }, "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.62.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.2.tgz", - "integrity": "sha512-5BqxR/pshjey51iliyzTD5Xi3EN0aLmQ2lZ3lvefVV9c82BvrLo2/6OT55iifpWBufs6kdwWbuOKS841DrmK9A==", + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.63.5.tgz", + "integrity": "sha512-3W9bTFcQNJn71cSJVM9RKIiZOy8DO/XLDii8Uv/Pm6WKqDRj7JV3ZfuXIEfyuy5LXpIzAbB/1M4Ukp9GKNa7nA==", "cpu": [ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -788,16 +1059,13 @@ ] }, "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.62.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.2.tgz", - "integrity": "sha512-uNN83XxQrRAh/w0/pmAfibcwyb6YWt4gP+dpnQKPVJshAloQ785ii8CT8ZCIxkGg9opVsvAlGhFitSm6D1Jjpg==", + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.63.5.tgz", + "integrity": "sha512-VDC7rRJlee/scpki96GZ27Omf6yU87s1YXwVTpjE5841faVlDYYT565rgfmoR1U0sqL7z5ivQSDjcsF6VRXyBA==", "cpu": [ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -805,9 +1073,9 @@ ] }, "node_modules/@rollup/rollup-openbsd-x64": { - "version": "4.62.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.2.tgz", - "integrity": "sha512-srjEIxSH3LRnJN6THczDHWQplqEMFiAJrTab0msUryh9kwNpkICf3Ea6q6MN/2cZwRFUNx5w+h6Hpi4QuHS6Zg==", + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.63.5.tgz", + "integrity": "sha512-z86Ok2p4pTdv5xqCKZsTooO7yBEiaJR/HzU3Wx8RmWsPoLppnMKROhJusQob8B3IE1ghC343kUW9rC2r+Wf3ig==", "cpu": [ "x64" ], @@ -819,9 +1087,9 @@ ] }, "node_modules/@rollup/rollup-openharmony-arm64": { - "version": "4.62.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.2.tgz", - "integrity": "sha512-8hOJnxgbyObnCm5AlRA3A931xX19xq80RjVTKgJOvEKWqJruP/Uf12IbAOaDjjEXYRewwHLfmF0YRIdK3OwKWA==", + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.63.5.tgz", + "integrity": "sha512-IzQmj+xXwQFGhMAMKMQVXkMwMZN3TqkJgAE0nSsqvVwWWciP4AIPMmWRqOQ2GfX7TUDZr+xqGFcBS36CRPGw0g==", "cpu": [ "arm64" ], @@ -833,9 +1101,9 @@ ] }, "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.62.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.2.tgz", - "integrity": "sha512-mmF4AY1i0hG/bLWUctUq59gtmgaSIRa3cu/A3JFRp/sCNEme2bgDEiDS22P9FbnJB8NJNF4jPJiSP5RHQpUTDg==", + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.63.5.tgz", + "integrity": "sha512-F6qpTaPc9bwBH85kjy0/BLmLSW1uv7AoOXCoRIkg2arlgCYlWYcAbiMkvZuAcaWk9TpCRG//okznLAqLGshkMw==", "cpu": [ "arm64" ], @@ -847,9 +1115,9 @@ ] }, "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.62.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.2.tgz", - "integrity": "sha512-DZgkknc6jhHrk46V25vbAM0zZkyP0nSDkJB8/dRkLTxv470dOmWDqGoEJl/9A0dFfS7yE3REOwNDxpHwSLSt0Q==", + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.63.5.tgz", + "integrity": "sha512-igoDsTFhhwECBeGbUuLeIk7t8Y1apa+cs6mDWpx2EZ0ch7oEQgzHbFUXN9euoHekCAQzXdXApAGkV6jznS7tWw==", "cpu": [ "ia32" ], @@ -861,9 +1129,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-gnu": { - "version": "4.62.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.2.tgz", - "integrity": "sha512-T6xr6ucWSFto+VGajA8YH26LdpHRuP4YLHEKAtCWvJDOlnmWcDZVCI2Jmjr+IFHDlt2zRaTAKE4tfjTaWLgJBg==", + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.63.5.tgz", + "integrity": "sha512-U3teMeMbXFmaM5D+OTJpsOXd+wV/qftIeYF9kBKL4v73641qyJmoXFtA28DQLsnmlyayEsTe72xpLHrArq6vHw==", "cpu": [ "x64" ], @@ -875,9 +1143,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.62.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.2.tgz", - "integrity": "sha512-BfzEnDJOt9T8M989/lA37EcJgat01wLRnoi5dQf3QzOH7jzpqTAzdDbVfRljVr5r+jzKqpbHeyOfAaXxAd0PAA==", + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.63.5.tgz", + "integrity": "sha512-ypfC34F3RKXvCXBglGqGMsUSMKlgwd1HX9AOAlx9RoZZ6GaI42YHVeKpzg3JG+wpBUJYTG+NNZhqbDWL8tBZkw==", "cpu": [ "x64" ], @@ -888,6 +1156,31 @@ "win32" ] }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.9", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", @@ -895,6 +1188,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/node": { "version": "20.19.43", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.43.tgz", @@ -905,208 +1205,1308 @@ "undici-types": "~6.21.0" } }, - "node_modules/acorn": { - "version": "8.17.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.17.0.tgz", - "integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==", + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.71.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.71.0.tgz", + "integrity": "sha512-pqcS9c1HxZTHt7End4nXqd0s5lJrrFzrgCkKFJrsbUnaL6M3+6oBFZaslg6Gjsl3argl2DDRFROnXARaZ2e4Nw==", "dev": true, "license": "MIT", - "bin": { - "acorn": "bin/acorn" + "dependencies": { + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.71.0", + "@typescript-eslint/type-utils": "8.71.0", + "@typescript-eslint/utils": "8.71.0", + "@typescript-eslint/visitor-keys": "8.71.0", + "ignore": "^7.0.5", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.5.0" }, "engines": { - "node": ">=0.4.0" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.71.0", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" } }, - "node_modules/any-promise": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", - "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==", + "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { + "version": "7.0.10", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.10.tgz", + "integrity": "sha512-HpbUakT7xp5miBUywCHf36ZEuAJNklBJDDsGpUIjMzOSmM8ELSfA9Sa/QDPeNeqeoN31u+UTCkL4klCOVvRm4Q==", "dev": true, - "license": "MIT" + "license": "MIT", + "engines": { + "node": ">= 4" + } }, - "node_modules/bundle-require": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/bundle-require/-/bundle-require-5.1.0.tgz", - "integrity": "sha512-3WrrOuZiyaaZPWiEt4G3+IffISVC9HYlWueJEBWED4ZH4aIAC2PnkdnuRrR94M+w6yGWn4AglWtJtBI8YqvgoA==", + "node_modules/@typescript-eslint/parser": { + "version": "8.71.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.71.0.tgz", + "integrity": "sha512-CG4nPk1f2zc8yw4pALqHsFYH2hdo+h1T9daSp21+Hnxi9LOE3GT9hAfTKJCBXVNM2GmYs1eMEP615wPoeOgk3A==", "dev": true, "license": "MIT", "dependencies": { - "load-tsconfig": "^0.2.3" + "@typescript-eslint/scope-manager": "8.71.0", + "@typescript-eslint/types": "8.71.0", + "@typescript-eslint/typescript-estree": "8.71.0", + "@typescript-eslint/visitor-keys": "8.71.0", + "debug": "^4.4.3" }, "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "esbuild": ">=0.18" + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" } }, - "node_modules/cac": { - "version": "6.7.14", - "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", - "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "node_modules/@typescript-eslint/project-service": { + "version": "8.71.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.71.0.tgz", + "integrity": "sha512-aABjw5rjBacYONVPaPiWOCjJu0vEF4a25iQuodlmQYL1trtLZ0X/y+2Vzl3BKI1odM4LnwLE1oUDXYp1wzx1TQ==", "dev": true, "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.71.0", + "@typescript-eslint/types": "^8.71.0", + "debug": "^4.4.3" + }, "engines": { - "node": ">=8" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" } }, - "node_modules/chokidar": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", - "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.71.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.71.0.tgz", + "integrity": "sha512-gWF0BhUcnjZxSpLE8ngS/59n2SB0J3YqRxvX1+2aoRJk9hNtHSLOV+TcarFiOr5ipXm3yc1QrI4c9YZc8zyCxw==", "dev": true, "license": "MIT", "dependencies": { - "readdirp": "^4.0.1" + "@typescript-eslint/types": "8.71.0", + "@typescript-eslint/visitor-keys": "8.71.0" }, "engines": { - "node": ">= 14.16.0" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { - "url": "https://paulmillr.com/funding/" + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" } }, - "node_modules/commander": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", - "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.71.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.71.0.tgz", + "integrity": "sha512-Z1UlWHADEK2Mlb9NpWfDeSjqoZ5EyrOv4R3eQpbkzqn/EwaIdOpXXupEA1+0ZIOSJSZZDBHG0BrQyN8zUG6Pwg==", "dev": true, "license": "MIT", "engines": { - "node": ">= 6" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" } }, - "node_modules/confbox": { - "version": "0.1.8", - "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.1.8.tgz", - "integrity": "sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==", - "dev": true, - "license": "MIT" - }, - "node_modules/consola": { - "version": "3.4.2", - "resolved": "https://registry.npmjs.org/consola/-/consola-3.4.2.tgz", - "integrity": "sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==", + "node_modules/@typescript-eslint/type-utils": { + "version": "8.71.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.71.0.tgz", + "integrity": "sha512-i8uO1qbdxeKgRnS5sCRt6On3/nfo2d2DwQe3Yvjx543zLy7r8ySqRuPPiIIXAhS03U0v5NfAFx+rUgxFzKKwNw==", "dev": true, "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.71.0", + "@typescript-eslint/typescript-estree": "8.71.0", + "@typescript-eslint/utils": "8.71.0", + "debug": "^4.4.3", + "ts-api-utils": "^2.5.0" + }, "engines": { - "node": "^14.18.0 || >=16.10.0" - } + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.71.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.71.0.tgz", + "integrity": "sha512-cJ4OoxPGWvFnBTnSZyaU+qJzGTqPTGJY+gDchj6cRyLRdmIdt4rcsE4twj+zPfrNiWuVi38wijHzShL++Z9atQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.71.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.71.0.tgz", + "integrity": "sha512-PEEF4G5sLLWAS5BpPrUvms4ySZkiBQQZM4z+3ReI46axK5Vqr/vXBQatJQIZZOYdGyPUAKTtsrWzpqKuU+3DEw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.71.0", + "@typescript-eslint/tsconfig-utils": "8.71.0", + "@typescript-eslint/types": "8.71.0", + "@typescript-eslint/visitor-keys": "8.71.0", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { + "version": "5.0.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz", + "integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { + "version": "10.2.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", + "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.8" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.71.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.71.0.tgz", + "integrity": "sha512-pKR/tEMVrXZG23UFKUn5BQf3zfmfk7KQceI2cGzywZ5nxM5Eu3hEJU1utjWzydtzBbcJAQhHN8iPCxobHpPcZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.71.0", + "@typescript-eslint/types": "8.71.0", + "@typescript-eslint/typescript-estree": "8.71.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.71.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.71.0.tgz", + "integrity": "sha512-8eQ9R218XORK+KLosnf4bu/QsUXvUyVwTbArg7/0NMB1Pu87OJKvj4nhFblkYE8gQV73mW1dx1ptlPCkwRGa7A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.71.0", + "eslint-visitor-keys": "^5.0.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@vitest/coverage-v8": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.11.tgz", + "integrity": "sha512-8MVGEFnJIcdGjcbfKmeq8z0pZHH0JlVtoVZH9Q/qwUp6wyFnEJUBMrw9DCaj+ra3vShGmhavjalMIhPNxZAUcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@bcoe/v8-coverage": "^1.0.2", + "@vitest/utils": "4.1.11", + "ast-v8-to-istanbul": "^1.0.0", + "istanbul-lib-coverage": "^3.2.2", + "istanbul-lib-report": "^3.0.1", + "istanbul-reports": "^3.2.0", + "magicast": "^0.5.2", + "obug": "^2.1.1", + "std-env": "^4.0.0-rc.1", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@vitest/browser": "4.1.11", + "vitest": "4.1.11" + }, + "peerDependenciesMeta": { + "@vitest/browser": { + "optional": true + } + } + }, + "node_modules/@vitest/expect": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.11.tgz", + "integrity": "sha512-VX2x5vNJXET47KAFzwERI+KRMtTTCSWTfSMKsW7JsUsXV4psq++e3DvZpuTDOpHcxytiDs6p2nhVb2tVDiiUYw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.11", + "@vitest/utils": "4.1.11", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.11.tgz", + "integrity": "sha512-2XJVD55d1o5AZous5CCGKS74g/riOj9odEt2bQpCVZeblHyHdnMeFl4jl0XjU21stf4mbjUkew2eXQZt65g5CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.11", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.11.tgz", + "integrity": "sha512-yiZzPbGTS9Sr/JpFl8zHrcIkAofNbFV6k21vIgQN/cY/oxZeXhJv5sc/MBJ5jFKWmWs+oJHw0UXLZjmf931+Vw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.11.tgz", + "integrity": "sha512-LztvUgdwMNJMIkj3hQnnxiC2Xy1zNxq928W/xhjCLaNCzqTZOudjwbQf6v9IntZGPw132i2Lq2rgTRZHD3JHNw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.11", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.11.tgz", + "integrity": "sha512-pN7ikn1ON7h8ee4gIAp4AzyK+zBtJPzVbqOgu5LCEh4VaJVbPQcgYQYJIMGQPXVeJJq1fnfazis7a5pFNPahog==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.11", + "@vitest/utils": "4.1.11", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.11.tgz", + "integrity": "sha512-apNa/prQy2qCeywhnixOHPRCgGNhvg7T4Dapfl1GahLp/R+uhBm5cPyFoNVyqsNd2h1nJxL6BqqdIjiABL60YA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.11.tgz", + "integrity": "sha512-zTCVGpyFsGWBhllOyKlTw/vnr6D9qxsfSDyfbyZmTyjHw5N/VuvzHpHoQjm2ZJzn4RJgx5w4r7V0er69CmLgPQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.11", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/acorn": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.18.0.tgz", + "integrity": "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/any-promise": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", + "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==", + "dev": true, + "license": "MIT" + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/ast-v8-to-istanbul": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-1.0.7.tgz", + "integrity": "sha512-kFL68AG6ajd8fg248zwM9GQrUWEp79gsmjum34OEXjs4yHuUMZfYKwOLW9GMmB4oNvVrj+EAGxsP7ye2UR9UlA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.31", + "estree-walker": "^3.0.3", + "js-tokens": "^10.0.0" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/brace-expansion": { + "version": "1.1.21", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.21.tgz", + "integrity": "sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/bundle-require": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/bundle-require/-/bundle-require-5.1.0.tgz", + "integrity": "sha512-3WrrOuZiyaaZPWiEt4G3+IffISVC9HYlWueJEBWED4ZH4aIAC2PnkdnuRrR94M+w6yGWn4AglWtJtBI8YqvgoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "load-tsconfig": "^0.2.3" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "peerDependencies": { + "esbuild": ">=0.18" + } + }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/chokidar": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", + "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "readdirp": "^4.0.1" + }, + "engines": { + "node": ">= 14.16.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/commander": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", + "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/confbox": { + "version": "0.1.8", + "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.1.8.tgz", + "integrity": "sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/consola": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/consola/-/consola-3.4.2.tgz", + "integrity": "sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.18.0 || >=16.10.0" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/es-module-lexer": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.2.tgz", + "integrity": "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==", + "dev": true, + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz", + "integrity": "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.7", + "@esbuild/android-arm": "0.27.7", + "@esbuild/android-arm64": "0.27.7", + "@esbuild/android-x64": "0.27.7", + "@esbuild/darwin-arm64": "0.27.7", + "@esbuild/darwin-x64": "0.27.7", + "@esbuild/freebsd-arm64": "0.27.7", + "@esbuild/freebsd-x64": "0.27.7", + "@esbuild/linux-arm": "0.27.7", + "@esbuild/linux-arm64": "0.27.7", + "@esbuild/linux-ia32": "0.27.7", + "@esbuild/linux-loong64": "0.27.7", + "@esbuild/linux-mips64el": "0.27.7", + "@esbuild/linux-ppc64": "0.27.7", + "@esbuild/linux-riscv64": "0.27.7", + "@esbuild/linux-s390x": "0.27.7", + "@esbuild/linux-x64": "0.27.7", + "@esbuild/netbsd-arm64": "0.27.7", + "@esbuild/netbsd-x64": "0.27.7", + "@esbuild/openbsd-arm64": "0.27.7", + "@esbuild/openbsd-x64": "0.27.7", + "@esbuild/openharmony-arm64": "0.27.7", + "@esbuild/sunos-x64": "0.27.7", + "@esbuild/win32-arm64": "0.27.7", + "@esbuild/win32-ia32": "0.27.7", + "@esbuild/win32-x64": "0.27.7" + } }, - "node_modules/debug": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "9.39.5", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.5.tgz", + "integrity": "sha512-DgZS62aPLXKlnxILS/AYCoRvHaZeXceIzlXPkkGGzJWSow1aEk0lbTlxUSlyjC8jcaKxAdOnTDz+o1JFSBsyjw==", + "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", "dev": true, "license": "MIT", "dependencies": { - "ms": "^2.1.3" + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.1", + "@eslint/config-array": "^0.21.2", + "@eslint/config-helpers": "^0.4.2", + "@eslint/core": "^0.17.0", + "@eslint/eslintrc": "^3.3.6", + "@eslint/js": "9.39.5", + "@eslint/plugin-kit": "^0.4.1", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.14.0", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^8.4.0", + "eslint-visitor-keys": "^4.2.1", + "espree": "^10.4.0", + "esquery": "^1.5.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.5", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-scope": { + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", + "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", + "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.15.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/fix-dts-default-cjs-exports": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/fix-dts-default-cjs-exports/-/fix-dts-default-cjs-exports-1.0.1.tgz", + "integrity": "sha512-pVIECanWFC61Hzl2+oOCtoJ3F17kglZC/6N94eRWycFgBH35hHx0Li604ZIzhseh97mf2p0cv7vVrOZGoqhlEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "magic-string": "^0.30.17", + "mlly": "^1.7.4", + "rollup": "^4.34.8" + } + }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.4.4", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.4.tgz", + "integrity": "sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q==", + "dev": true, + "license": "ISC" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/globals": { + "version": "16.5.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-16.5.0.tgz", + "integrity": "sha512-c/c15i26VrJ4IRt5Z89DnIzCGDn9EcebibhAOjw5ibqEHsE1wLUgkPn9RDmNcUKyU87GeaL633nyJ+pplFR2ZQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/html-escaper": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", + "dev": true, + "license": "MIT" + }, + "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/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/istanbul-lib-coverage": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", + "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-report": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", + "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "istanbul-lib-coverage": "^3.0.0", + "make-dir": "^4.0.0", + "supports-color": "^7.1.0" }, "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } + "node": ">=10" } }, - "node_modules/esbuild": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz", - "integrity": "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==", + "node_modules/istanbul-reports": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", + "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", "dev": true, - "hasInstallScript": true, - "license": "MIT", - "bin": { - "esbuild": "bin/esbuild" + "license": "BSD-3-Clause", + "dependencies": { + "html-escaper": "^2.0.0", + "istanbul-lib-report": "^3.0.0" }, "engines": { - "node": ">=18" - }, - "optionalDependencies": { - "@esbuild/aix-ppc64": "0.27.7", - "@esbuild/android-arm": "0.27.7", - "@esbuild/android-arm64": "0.27.7", - "@esbuild/android-x64": "0.27.7", - "@esbuild/darwin-arm64": "0.27.7", - "@esbuild/darwin-x64": "0.27.7", - "@esbuild/freebsd-arm64": "0.27.7", - "@esbuild/freebsd-x64": "0.27.7", - "@esbuild/linux-arm": "0.27.7", - "@esbuild/linux-arm64": "0.27.7", - "@esbuild/linux-ia32": "0.27.7", - "@esbuild/linux-loong64": "0.27.7", - "@esbuild/linux-mips64el": "0.27.7", - "@esbuild/linux-ppc64": "0.27.7", - "@esbuild/linux-riscv64": "0.27.7", - "@esbuild/linux-s390x": "0.27.7", - "@esbuild/linux-x64": "0.27.7", - "@esbuild/netbsd-arm64": "0.27.7", - "@esbuild/netbsd-x64": "0.27.7", - "@esbuild/openbsd-arm64": "0.27.7", - "@esbuild/openbsd-x64": "0.27.7", - "@esbuild/openharmony-arm64": "0.27.7", - "@esbuild/sunos-x64": "0.27.7", - "@esbuild/win32-arm64": "0.27.7", - "@esbuild/win32-ia32": "0.27.7", - "@esbuild/win32-x64": "0.27.7" + "node": ">=8" } }, - "node_modules/fdir": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", - "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "node_modules/joycon": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/joycon/-/joycon-3.1.1.tgz", + "integrity": "sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==", "dev": true, "license": "MIT", "engines": { - "node": ">=12.0.0" - }, - "peerDependencies": { - "picomatch": "^3 || ^4" - }, - "peerDependenciesMeta": { - "picomatch": { - "optional": true - } + "node": ">=10" } }, - "node_modules/fix-dts-default-cjs-exports": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/fix-dts-default-cjs-exports/-/fix-dts-default-cjs-exports-1.0.1.tgz", - "integrity": "sha512-pVIECanWFC61Hzl2+oOCtoJ3F17kglZC/6N94eRWycFgBH35hHx0Li604ZIzhseh97mf2p0cv7vVrOZGoqhlEg==", + "node_modules/js-tokens": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-10.0.0.tgz", + "integrity": "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.2.tgz", + "integrity": "sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==", "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], "license": "MIT", "dependencies": { - "magic-string": "^0.30.17", - "mlly": "^1.7.4", - "rollup": "^4.34.8" + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" } }, - "node_modules/fsevents": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", - "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", "dev": true, - "hasInstallScript": true, "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + "dependencies": { + "json-buffer": "3.0.1" } }, - "node_modules/joycon": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/joycon/-/joycon-3.1.1.tgz", - "integrity": "sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==", + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", "dev": true, "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, "engines": { - "node": ">=10" + "node": ">= 0.8.0" } }, "node_modules/lilconfig": { @@ -1139,6 +2539,29 @@ "node": "^12.20.0 || ^14.13.1 || >=16.0.0" } }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true, + "license": "MIT" + }, "node_modules/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", @@ -1149,6 +2572,47 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, + "node_modules/magicast": { + "version": "0.5.5", + "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.5.5.tgz", + "integrity": "sha512-UicdXN8zQ3JHlxVq+28afMXPr1z7WNY6+7EJnzTdQWkTAlMLF5fNCCKxJHBQwGaNGR11581EiQmQzx73+MvszA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7", + "source-map-js": "^1.2.1" + } + }, + "node_modules/make-dir": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", + "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, "node_modules/mlly": { "version": "1.8.2", "resolved": "https://registry.npmjs.org/mlly/-/mlly-1.8.2.tgz", @@ -1181,6 +2645,32 @@ "thenify-all": "^1.0.0" } }, + "node_modules/nanoid": { + "version": "3.3.19", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.19.tgz", + "integrity": "sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", @@ -1191,6 +2681,103 @@ "node": ">=0.10.0" } }, + "node_modules/obug": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.2.1.tgz", + "integrity": "sha512-XrsrhT5sybtKI6wakr2SPOlGZWWYbUXZ7a0jT8/QOeAPau+1X/bSegNe5YR75oJmEZQbKningirmGOEJCIk61Q==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "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", @@ -1206,9 +2793,9 @@ "license": "ISC" }, "node_modules/picomatch": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", - "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", "dev": true, "license": "MIT", "engines": { @@ -1240,6 +2827,35 @@ "pathe": "^2.0.1" } }, + "node_modules/postcss": { + "version": "8.5.28", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz", + "integrity": "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.18", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, "node_modules/postcss-load-config": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-6.0.1.tgz", @@ -1283,6 +2899,42 @@ } } }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/prettier": { + "version": "3.9.9", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.9.tgz", + "integrity": "sha512-Z/CJHIkdujO/OtN7nXUii0Rf3VT5SRuhjBA82Xvu2XhBUgX3nhP67T0LHceBdQLex7OOFGTox+Q5Yg8Jk2Qivg==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/readdirp": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", @@ -1298,19 +2950,19 @@ } }, "node_modules/resolve-from": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", - "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", "dev": true, "license": "MIT", "engines": { - "node": ">=8" + "node": ">=4" } }, "node_modules/rollup": { - "version": "4.62.2", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.2.tgz", - "integrity": "sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA==", + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.63.5.tgz", + "integrity": "sha512-KRWwmNLlPw5M7HcdYfm15oBv9n9LPtjzpzCIxS/phwqvPyxHSoKX6Y2YU3pxSPfy0CLquVgsx/j/hBi6OvH1Nw==", "dev": true, "license": "MIT", "dependencies": { @@ -1324,34 +2976,78 @@ "npm": ">=8.0.0" }, "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.62.2", - "@rollup/rollup-android-arm64": "4.62.2", - "@rollup/rollup-darwin-arm64": "4.62.2", - "@rollup/rollup-darwin-x64": "4.62.2", - "@rollup/rollup-freebsd-arm64": "4.62.2", - "@rollup/rollup-freebsd-x64": "4.62.2", - "@rollup/rollup-linux-arm-gnueabihf": "4.62.2", - "@rollup/rollup-linux-arm-musleabihf": "4.62.2", - "@rollup/rollup-linux-arm64-gnu": "4.62.2", - "@rollup/rollup-linux-arm64-musl": "4.62.2", - "@rollup/rollup-linux-loong64-gnu": "4.62.2", - "@rollup/rollup-linux-loong64-musl": "4.62.2", - "@rollup/rollup-linux-ppc64-gnu": "4.62.2", - "@rollup/rollup-linux-ppc64-musl": "4.62.2", - "@rollup/rollup-linux-riscv64-gnu": "4.62.2", - "@rollup/rollup-linux-riscv64-musl": "4.62.2", - "@rollup/rollup-linux-s390x-gnu": "4.62.2", - "@rollup/rollup-linux-x64-gnu": "4.62.2", - "@rollup/rollup-linux-x64-musl": "4.62.2", - "@rollup/rollup-openbsd-x64": "4.62.2", - "@rollup/rollup-openharmony-arm64": "4.62.2", - "@rollup/rollup-win32-arm64-msvc": "4.62.2", - "@rollup/rollup-win32-ia32-msvc": "4.62.2", - "@rollup/rollup-win32-x64-gnu": "4.62.2", - "@rollup/rollup-win32-x64-msvc": "4.62.2", + "@napi-rs/lzma-linux-x64-gnu": "1.5.1", + "@rollup/rollup-android-arm-eabi": "4.63.5", + "@rollup/rollup-android-arm64": "4.63.5", + "@rollup/rollup-darwin-arm64": "4.63.5", + "@rollup/rollup-darwin-x64": "4.63.5", + "@rollup/rollup-freebsd-arm64": "4.63.5", + "@rollup/rollup-freebsd-x64": "4.63.5", + "@rollup/rollup-linux-arm-gnueabihf": "4.63.5", + "@rollup/rollup-linux-arm-musleabihf": "4.63.5", + "@rollup/rollup-linux-arm64-gnu": "4.63.5", + "@rollup/rollup-linux-arm64-musl": "4.63.5", + "@rollup/rollup-linux-loong64-gnu": "4.63.5", + "@rollup/rollup-linux-loong64-musl": "4.63.5", + "@rollup/rollup-linux-ppc64-gnu": "4.63.5", + "@rollup/rollup-linux-ppc64-musl": "4.63.5", + "@rollup/rollup-linux-riscv64-gnu": "4.63.5", + "@rollup/rollup-linux-riscv64-musl": "4.63.5", + "@rollup/rollup-linux-s390x-gnu": "4.63.5", + "@rollup/rollup-linux-x64-gnu": "4.63.5", + "@rollup/rollup-linux-x64-musl": "4.63.5", + "@rollup/rollup-openbsd-x64": "4.63.5", + "@rollup/rollup-openharmony-arm64": "4.63.5", + "@rollup/rollup-win32-arm64-msvc": "4.63.5", + "@rollup/rollup-win32-ia32-msvc": "4.63.5", + "@rollup/rollup-win32-x64-gnu": "4.63.5", + "@rollup/rollup-win32-x64-msvc": "4.63.5", "fsevents": "~2.3.2" } }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, "node_modules/source-map": { "version": "0.7.6", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.6.tgz", @@ -1362,6 +3058,43 @@ "node": ">= 12" } }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.3.0.tgz", + "integrity": "sha512-OtU/EgQ1kIm5KwqQpBC6ZEMXrZRui11w8zgfTWp8cdO9B8OaPsbA8bTHO2P+HNo1VlUTGMVBwPhydu6poeXiag==", + "dev": true, + "license": "MIT" + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/sucrase": { "version": "3.35.1", "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz", @@ -1385,6 +3118,19 @@ "node": ">=16 || 14 >=14.17" } }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/thenify": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", @@ -1408,6 +3154,13 @@ "node": ">=0.8" } }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, "node_modules/tinyexec": { "version": "0.3.2", "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", @@ -1432,6 +3185,16 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/tinyrainbow": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.1.tgz", + "integrity": "sha512-yau8yJdTt989Mm0Bd/236QnzEiPf2xLLTqUZRUJOo/3CB078LSwzei343DgtJVmfJKJE3TMINY1u42SQsP6mXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/tree-kill": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz", @@ -1442,6 +3205,19 @@ "tree-kill": "cli.js" } }, + "node_modules/ts-api-utils": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", + "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, "node_modules/ts-interface-checker": { "version": "0.1.13", "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", @@ -1502,29 +3278,170 @@ } } }, - "node_modules/tsx": { - "version": "4.23.0", - "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.0.tgz", - "integrity": "sha512-eUdUIaCr963q2h5u3+QwvYp0+eqPvn+egeqZUm0hwERCqqx1E3kK5ehbGCvqSE5MQAULr67ww0cA3jKc3YkM1w==", + "node_modules/tsup/node_modules/resolve-from": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", + "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/typescript-eslint": { + "version": "8.71.0", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.71.0.tgz", + "integrity": "sha512-fBdHYiqQ14RW6mOMXD14Svn82ZsCYAoQSzGRzyEjR59S5A2Krh/l7fGTOQ7iCr8gGy/mHVXtEF7s5fgjEdV0Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/eslint-plugin": "8.71.0", + "@typescript-eslint/parser": "8.71.0", + "@typescript-eslint/typescript-estree": "8.71.0", + "@typescript-eslint/utils": "8.71.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/ufo": { + "version": "1.6.4", + "resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.4.tgz", + "integrity": "sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA==", + "dev": true, + "license": "MIT" + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/vite": { + "version": "6.4.3", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.3.tgz", + "integrity": "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==", "dev": true, "license": "MIT", "dependencies": { - "esbuild": "~0.28.0" + "esbuild": "^0.25.0", + "fdir": "^6.4.4", + "picomatch": "^4.0.2", + "postcss": "^8.5.3", + "rollup": "^4.34.9", + "tinyglobby": "^0.2.13" }, "bin": { - "tsx": "dist/cli.mjs" + "vite": "bin/vite.js" }, "engines": { - "node": ">=18.0.0" + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" }, "optionalDependencies": { "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "jiti": ">=1.21.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } } }, - "node_modules/tsx/node_modules/@esbuild/aix-ppc64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", - "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", + "node_modules/vite/node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", "cpu": [ "ppc64" ], @@ -1538,10 +3455,10 @@ "node": ">=18" } }, - "node_modules/tsx/node_modules/@esbuild/android-arm": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz", - "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", + "node_modules/vite/node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", "cpu": [ "arm" ], @@ -1555,10 +3472,10 @@ "node": ">=18" } }, - "node_modules/tsx/node_modules/@esbuild/android-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", - "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", + "node_modules/vite/node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", "cpu": [ "arm64" ], @@ -1572,10 +3489,10 @@ "node": ">=18" } }, - "node_modules/tsx/node_modules/@esbuild/android-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz", - "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", + "node_modules/vite/node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", "cpu": [ "x64" ], @@ -1589,10 +3506,10 @@ "node": ">=18" } }, - "node_modules/tsx/node_modules/@esbuild/darwin-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", - "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", + "node_modules/vite/node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", "cpu": [ "arm64" ], @@ -1606,10 +3523,10 @@ "node": ">=18" } }, - "node_modules/tsx/node_modules/@esbuild/darwin-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", - "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", + "node_modules/vite/node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", "cpu": [ "x64" ], @@ -1623,10 +3540,10 @@ "node": ">=18" } }, - "node_modules/tsx/node_modules/@esbuild/freebsd-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", - "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==", + "node_modules/vite/node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", "cpu": [ "arm64" ], @@ -1640,10 +3557,10 @@ "node": ">=18" } }, - "node_modules/tsx/node_modules/@esbuild/freebsd-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", - "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==", + "node_modules/vite/node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", "cpu": [ "x64" ], @@ -1657,10 +3574,10 @@ "node": ">=18" } }, - "node_modules/tsx/node_modules/@esbuild/linux-arm": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", - "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==", + "node_modules/vite/node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", "cpu": [ "arm" ], @@ -1674,10 +3591,10 @@ "node": ">=18" } }, - "node_modules/tsx/node_modules/@esbuild/linux-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", - "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==", + "node_modules/vite/node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", "cpu": [ "arm64" ], @@ -1691,10 +3608,10 @@ "node": ">=18" } }, - "node_modules/tsx/node_modules/@esbuild/linux-ia32": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", - "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==", + "node_modules/vite/node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", "cpu": [ "ia32" ], @@ -1708,10 +3625,10 @@ "node": ">=18" } }, - "node_modules/tsx/node_modules/@esbuild/linux-loong64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", - "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==", + "node_modules/vite/node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", "cpu": [ "loong64" ], @@ -1725,10 +3642,10 @@ "node": ">=18" } }, - "node_modules/tsx/node_modules/@esbuild/linux-mips64el": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", - "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==", + "node_modules/vite/node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", "cpu": [ "mips64el" ], @@ -1742,10 +3659,10 @@ "node": ">=18" } }, - "node_modules/tsx/node_modules/@esbuild/linux-ppc64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", - "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==", + "node_modules/vite/node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", "cpu": [ "ppc64" ], @@ -1759,10 +3676,10 @@ "node": ">=18" } }, - "node_modules/tsx/node_modules/@esbuild/linux-riscv64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", - "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==", + "node_modules/vite/node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", "cpu": [ "riscv64" ], @@ -1776,10 +3693,10 @@ "node": ">=18" } }, - "node_modules/tsx/node_modules/@esbuild/linux-s390x": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", - "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==", + "node_modules/vite/node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", "cpu": [ "s390x" ], @@ -1793,10 +3710,10 @@ "node": ">=18" } }, - "node_modules/tsx/node_modules/@esbuild/linux-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", - "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==", + "node_modules/vite/node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", "cpu": [ "x64" ], @@ -1810,10 +3727,10 @@ "node": ">=18" } }, - "node_modules/tsx/node_modules/@esbuild/netbsd-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz", - "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==", + "node_modules/vite/node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", "cpu": [ "arm64" ], @@ -1827,10 +3744,10 @@ "node": ">=18" } }, - "node_modules/tsx/node_modules/@esbuild/netbsd-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", - "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==", + "node_modules/vite/node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", "cpu": [ "x64" ], @@ -1844,10 +3761,10 @@ "node": ">=18" } }, - "node_modules/tsx/node_modules/@esbuild/openbsd-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz", - "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==", + "node_modules/vite/node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", "cpu": [ "arm64" ], @@ -1861,10 +3778,10 @@ "node": ">=18" } }, - "node_modules/tsx/node_modules/@esbuild/openbsd-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", - "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==", + "node_modules/vite/node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", "cpu": [ "x64" ], @@ -1878,10 +3795,10 @@ "node": ">=18" } }, - "node_modules/tsx/node_modules/@esbuild/openharmony-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", - "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==", + "node_modules/vite/node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", "cpu": [ "arm64" ], @@ -1895,10 +3812,10 @@ "node": ">=18" } }, - "node_modules/tsx/node_modules/@esbuild/sunos-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", - "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", + "node_modules/vite/node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", "cpu": [ "x64" ], @@ -1912,10 +3829,10 @@ "node": ">=18" } }, - "node_modules/tsx/node_modules/@esbuild/win32-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", - "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", + "node_modules/vite/node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", "cpu": [ "arm64" ], @@ -1929,10 +3846,10 @@ "node": ">=18" } }, - "node_modules/tsx/node_modules/@esbuild/win32-ia32": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", - "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", + "node_modules/vite/node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", "cpu": [ "ia32" ], @@ -1946,10 +3863,10 @@ "node": ">=18" } }, - "node_modules/tsx/node_modules/@esbuild/win32-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", - "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==", + "node_modules/vite/node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", "cpu": [ "x64" ], @@ -1963,10 +3880,10 @@ "node": ">=18" } }, - "node_modules/tsx/node_modules/esbuild": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", - "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", + "node_modules/vite/node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", "dev": true, "hasInstallScript": true, "license": "MIT", @@ -1977,61 +3894,189 @@ "node": ">=18" }, "optionalDependencies": { - "@esbuild/aix-ppc64": "0.28.1", - "@esbuild/android-arm": "0.28.1", - "@esbuild/android-arm64": "0.28.1", - "@esbuild/android-x64": "0.28.1", - "@esbuild/darwin-arm64": "0.28.1", - "@esbuild/darwin-x64": "0.28.1", - "@esbuild/freebsd-arm64": "0.28.1", - "@esbuild/freebsd-x64": "0.28.1", - "@esbuild/linux-arm": "0.28.1", - "@esbuild/linux-arm64": "0.28.1", - "@esbuild/linux-ia32": "0.28.1", - "@esbuild/linux-loong64": "0.28.1", - "@esbuild/linux-mips64el": "0.28.1", - "@esbuild/linux-ppc64": "0.28.1", - "@esbuild/linux-riscv64": "0.28.1", - "@esbuild/linux-s390x": "0.28.1", - "@esbuild/linux-x64": "0.28.1", - "@esbuild/netbsd-arm64": "0.28.1", - "@esbuild/netbsd-x64": "0.28.1", - "@esbuild/openbsd-arm64": "0.28.1", - "@esbuild/openbsd-x64": "0.28.1", - "@esbuild/openharmony-arm64": "0.28.1", - "@esbuild/sunos-x64": "0.28.1", - "@esbuild/win32-arm64": "0.28.1", - "@esbuild/win32-ia32": "0.28.1", - "@esbuild/win32-x64": "0.28.1" + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "node_modules/vitest": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.11.tgz", + "integrity": "sha512-fhACrNXUidIbGSBr5FlbuBkO7VWC1ZyLl0DO4CU2DrQoAPxX84Ysxs+HeGQpii5lZWV1Q4gBZTTu49mF+A6Edw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.11", + "@vitest/mocker": "4.1.11", + "@vitest/pretty-format": "4.1.11", + "@vitest/runner": "4.1.11", + "@vitest/snapshot": "4.1.11", + "@vitest/spy": "4.1.11", + "@vitest/utils": "4.1.11", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.11", + "@vitest/browser-preview": "4.1.11", + "@vitest/browser-webdriverio": "4.1.11", + "@vitest/coverage-istanbul": "4.1.11", + "@vitest/coverage-v8": "4.1.11", + "@vitest/ui": "4.1.11", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } } }, - "node_modules/typescript": { - "version": "5.9.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", - "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "node_modules/vitest/node_modules/tinyexec": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.1.tgz", + "integrity": "sha512-GCvB3aoys96IuDFBMcTB46JOR6mdMtAToqwiW8JlWhsoh1mhHi/xn9ss/Dg7N555GiJyEt2qzoG/NHCwM6h1EA==", "dev": true, - "license": "Apache-2.0", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, "bin": { - "tsc": "bin/tsc", - "tsserver": "bin/tsserver" + "node-which": "bin/node-which" }, "engines": { - "node": ">=14.17" + "node": ">= 8" } }, - "node_modules/ufo": { - "version": "1.6.4", - "resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.4.tgz", - "integrity": "sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA==", + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", "dev": true, - "license": "MIT" + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } }, - "node_modules/undici-types": { - "version": "6.21.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", - "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", "dev": true, - "license": "MIT" + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } } } } diff --git a/package.json b/package.json index cc7e9eb..598e2df 100644 --- a/package.json +++ b/package.json @@ -1,36 +1,140 @@ { - "name": "@shieldlabs/node", - "version": "0.1.0", - "description": "ShieldLabs server SDK for Node.js: API client, webhook verification, and types.", + "name": "@shieldlabs-ai/node", + "version": "1.0.0", + "description": "ShieldLabs server SDK for Node.js and edge runtimes: History API and Management API clients, webhook signature verification, typed identifications and risk helpers.", + "license": "MIT", + "author": "ShieldLabs Inc. ", + "homepage": "https://docs.shieldlabs.ai", + "repository": { + "type": "git", + "url": "git+https://github.com/ShieldLabs-ai/shieldlabs-node.git" + }, + "bugs": { + "url": "https://github.com/ShieldLabs-ai/shieldlabs-node/issues", + "email": "contact@shieldlabs.ai" + }, + "keywords": [ + "shieldlabs", + "device-intelligence", + "risk-score", + "fraud-prevention", + "bot-detection", + "webhooks", + "server-sdk" + ], "type": "module", "main": "./dist/index.cjs", "module": "./dist/index.js", "types": "./dist/index.d.ts", "exports": { ".": { - "types": "./dist/index.d.ts", - "import": "./dist/index.js", - "require": "./dist/index.cjs" - } + "bun": { + "import": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + }, + "require": { + "types": "./dist/index.d.cts", + "default": "./dist/index.cjs" + } + }, + "deno": { + "import": { + "types": "./dist/index.d.ts", + "default": "./dist/edge.js" + }, + "require": { + "types": "./dist/index.d.cts", + "default": "./dist/edge.cjs" + } + }, + "workerd": { + "import": { + "types": "./dist/index.d.ts", + "default": "./dist/edge.js" + }, + "require": { + "types": "./dist/index.d.cts", + "default": "./dist/edge.cjs" + } + }, + "worker": { + "import": { + "types": "./dist/index.d.ts", + "default": "./dist/edge.js" + }, + "require": { + "types": "./dist/index.d.cts", + "default": "./dist/edge.cjs" + } + }, + "edge-light": { + "import": { + "types": "./dist/index.d.ts", + "default": "./dist/edge.js" + }, + "require": { + "types": "./dist/index.d.cts", + "default": "./dist/edge.cjs" + } + }, + "browser": { + "import": { + "types": "./dist/index.d.ts", + "default": "./dist/edge.js" + }, + "require": { + "types": "./dist/index.d.cts", + "default": "./dist/edge.cjs" + } + }, + "import": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + }, + "require": { + "types": "./dist/index.d.cts", + "default": "./dist/index.cjs" + }, + "default": "./dist/index.js" + }, + "./package.json": "./package.json" }, - "files": ["dist", "README.md", "LICENSE"], - "engines": { "node": ">=18" }, + "files": [ + "dist", + "README.md", + "CHANGELOG.md", + "LICENSE" + ], "sideEffects": false, + "engines": { + "node": ">=18" + }, "scripts": { - "build": "tsup src/index.ts --format esm,cjs --dts --clean", + "build": "tsup", "typecheck": "tsc --noEmit", - "test": "tsx --test src/verifyWebhook.test.ts" + "lint": "eslint . && prettier --check .", + "format": "prettier --write .", + "test": "vitest run", + "test:package": "node scripts/check-package.mjs", + "test:runtime": "node test/runtime/smoke.mjs && node --conditions=worker test/runtime/smoke.mjs", + "test:example": "node scripts/smoke-example.mjs", + "prepack": "npm run build" + }, + "publishConfig": { + "access": "public" }, - "keywords": ["shieldlabs", "server-sdk", "webhook-verification", "risk-score", "fraud-prevention"], - "repository": { "type": "git", "url": "git+https://github.com/ShieldLabs-ai/shieldlabs-node.git" }, - "homepage": "https://shieldlabs.ai", - "bugs": { "url": "https://github.com/ShieldLabs-ai/shieldlabs-node/issues" }, - "license": "MIT", - "publishConfig": { "access": "public" }, "devDependencies": { - "@types/node": "^20.0.0", - "tsup": "^8.0.0", - "tsx": "^4.0.0", - "typescript": "^5.4.0" + "@eslint/js": "^9.39.5", + "@types/node": "^20.19.43", + "@vitest/coverage-v8": "^4.1.11", + "eslint": "^9.39.5", + "globals": "^16.5.0", + "prettier": "^3.9.9", + "tsup": "^8.5.1", + "typescript": "^5.9.3", + "typescript-eslint": "^8.71.0", + "vite": "^6.4.3", + "vitest": "^4.1.11" } } diff --git a/scripts/check-package.mjs b/scripts/check-package.mjs new file mode 100644 index 0000000..2f5c91a --- /dev/null +++ b/scripts/check-package.mjs @@ -0,0 +1,140 @@ +// Checks the built package: files, export conditions and the edge bundles. Run after npm run build. +import { execFileSync } from 'node:child_process'; +import { existsSync, readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; + +const root = fileURLToPath(new URL('..', import.meta.url)); +const failures = []; +function check(label, condition) { + console.log(`${condition ? 'ok ' : 'FAIL'} ${label}`); + if (!condition) failures.push(label); +} + +const EXPECTED_EXPORTS = [ + 'ApiError', + 'AuthenticationError', + 'BadRequestError', + 'ConnectionError', + 'NIL_UUID', + 'NotFoundError', + 'QuotaExceededError', + 'RISK_BANDS', + 'RateLimitError', + 'SIGNALS', + 'ServerError', + 'ShieldLabs', + 'ShieldLabsError', + 'ShieldLabsManagement', + 'SignatureVerificationError', + 'TimeoutError', + 'VERSION', + 'ValidationError', + 'WebhookParseError', + 'evaluateIdentification', + 'isRateLimited', + 'riskBand', + 'userHid', + 'userHidAsync', + 'webhooks', +].join(','); + +const files = ['index.js', 'index.cjs', 'index.d.ts', 'index.d.cts', 'edge.js', 'edge.cjs']; +for (const file of files) + check(`dist/${file} exists`, existsSync(new URL(`../dist/${file}`, import.meta.url))); + +// The edge build must never load a Node.js built-in. +const builtinImport = + /(?:from\s*|import\s*\(\s*|require\s*\(\s*)["'](?:node:[\w/]+|crypto|buffer|fs|http|https|stream|util)["']/; +for (const file of ['edge.js', 'edge.cjs']) { + const code = readFileSync(new URL(`../dist/${file}`, import.meta.url), 'utf8'); + check(`dist/${file} imports no Node.js built-in`, !builtinImport.test(code)); +} +for (const file of ['index.js', 'index.cjs']) { + const code = readFileSync(new URL(`../dist/${file}`, import.meta.url), 'utf8'); + check(`dist/${file} imports node:crypto`, /["']node:crypto["']/.test(code)); +} + +// The declarations must not promise a value that the bundles do not export (a type exported +// without the `type` modifier compiles, then fails at import time), and must cover every value. +const runtimeNames = EXPECTED_EXPORTS.split(','); +for (const file of ['index.d.ts', 'index.d.cts']) { + const dts = readFileSync(new URL(`../dist/${file}`, import.meta.url), 'utf8'); + const lists = [...dts.matchAll(/^export \{([^}]*)\};?$/gm)].map((match) => match[1]); + const valueNames = lists + .flatMap((list) => list.split(',')) + .map((name) => name.trim()) + .filter((name) => name !== '' && !name.startsWith('type ')) + .map((name) => name.split(/\s+as\s+/).pop()); + const extra = valueNames.filter((name) => !runtimeNames.includes(name)); + const missing = runtimeNames.filter((name) => !valueNames.includes(name)); + check( + `dist/${file} declares exactly the runtime values${extra.length > 0 ? `; not at runtime: ${extra.join(', ')}` : ''}${missing.length > 0 ? `; undeclared: ${missing.join(', ')}` : ''}`, + lists.length > 0 && extra.length === 0 && missing.length === 0, + ); +} + +function run(args) { + return execFileSync(process.execPath, args, { cwd: root, encoding: 'utf8' }).trim(); +} + +// Resolve the package by name (self-reference) under different export conditions. +const probe = ` + const names = Object.keys(m).sort().join(','); + let entry = 'node'; + try { m.webhooks.verifySignature('x', 'sha256=' + '0'.repeat(64), 'whsec_x'); } catch (e) { entry = /Async/.test(e.message) ? 'edge' : 'error'; } + console.log(entry + ' ' + (names === ${JSON.stringify(EXPECTED_EXPORTS)} ? 'exports-ok' : 'exports-mismatch:' + names)); +`; +const esm = (conditions) => + run([ + ...conditions, + '--input-type=module', + '-e', + `import * as m from '@shieldlabs-ai/node'; ${probe}`, + ]); +const cjs = (conditions) => + run([...conditions, '-e', `const m = require('@shieldlabs-ai/node'); ${probe}`]); + +check('import resolves the Node entry', esm([]) === 'node exports-ok'); +check('require resolves the Node entry', cjs([]) === 'node exports-ok'); +for (const condition of ['worker', 'workerd', 'edge-light', 'browser', 'deno']) { + check( + `import with the ${condition} condition resolves the edge entry`, + esm([`--conditions=${condition}`]) === 'edge exports-ok', + ); + check( + `require with the ${condition} condition resolves the edge entry`, + cjs([`--conditions=${condition}`]) === 'edge exports-ok', + ); +} +check('the bun condition resolves the Node entry', esm(['--conditions=bun']) === 'node exports-ok'); + +// Only the build output and the docs are published. +const npmCli = process.env.npm_execpath; +const packJson = npmCli + ? execFileSync(process.execPath, [npmCli, 'pack', '--dry-run', '--json', '--ignore-scripts'], { + cwd: root, + encoding: 'utf8', + }) + : execFileSync('npm', ['pack', '--dry-run', '--json', '--ignore-scripts'], { + cwd: root, + encoding: 'utf8', + }); +const packed = JSON.parse(packJson)[0] + .files.map((entry) => entry.path) + .sort(); +const allowed = + /^(dist\/(index|edge)\.(js|cjs|d\.ts|d\.cts|js\.map|cjs\.map)|README\.md|CHANGELOG\.md|LICENSE|package\.json)$/; +check( + `the tarball holds only dist, docs and package.json (${packed.length} files)`, + packed.every((path) => allowed.test(path)), +); +check( + 'the tarball includes the type declarations', + packed.includes('dist/index.d.ts') && packed.includes('dist/index.d.cts'), +); + +if (failures.length > 0) { + console.error(`\n${failures.length} package check(s) failed.`); + process.exit(1); +} +console.log('\nPackage checks passed.'); diff --git a/scripts/smoke-example.mjs b/scripts/smoke-example.mjs new file mode 100644 index 0000000..a1b8a4c --- /dev/null +++ b/scripts/smoke-example.mjs @@ -0,0 +1,185 @@ +// End-to-end check of examples/node-http/server.mjs against a local stand-in for the History API. +// Needs a build first (npm run build). Uses only Node.js built-ins. +import { spawn } from 'node:child_process'; +import { createHmac } from 'node:crypto'; +import { readFileSync } from 'node:fs'; +import { createServer } from 'node:http'; +import { once } from 'node:events'; +import { fileURLToPath } from 'node:url'; + +const API_KEY = 'sec_abcd1234-efgh5678-ijkl9012'; +const WEBHOOK_SECRET = 'whsec_00112233445566778899aabbccddeeff'; +// The example accepts comma-separated secrets while a secret is rotated. +const PREVIOUS_SECRET = 'whsec_ffeeddccbbaa99887766554433221100'; +const LISTENING = /Listening on http:\/\/localhost:(\d+)/; +const HISTORY_PATH = /^\/api\/v1\/history\/request_id\/([0-9a-f-]+)$/; +const ids = { + clean: '11111111-1111-4111-8111-111111111111', + dangerous: '22222222-2222-4222-8222-222222222222', + automation: '33333333-3333-4333-8333-333333333333', + marker: '44444444-4444-4444-8444-444444444444', + missing: '55555555-5555-4555-8555-555555555555', +}; + +const root = fileURLToPath(new URL('..', import.meta.url)); +const fixturePage = JSON.parse( + readFileSync(new URL('../test/data/history-page.json', import.meta.url), 'utf8'), +); +const baseRow = fixturePage.data[1]; + +function row(requestId, changes) { + const createdAt = new Date().toISOString().replace('T', ' ').replace('Z', ''); + return { ...baseRow, request_id: requestId, created_at: createdAt, ...changes }; +} + +const rows = { + [ids.clean]: row(ids.clean, {}), + [ids.dangerous]: row(ids.dangerous, { score: 80 }), + [ids.automation]: row(ids.automation, { score: 60, is_browser_automation: true }), + [ids.marker]: row(ids.marker, { score: 999, device_id: '00000000-0000-0000-0000-000000000000' }), +}; + +const historyApi = createServer((req, res) => { + const match = new URL(req.url, 'http://localhost').pathname.match(HISTORY_PATH); + if (req.headers.authorization !== `Bearer ${API_KEY}`) { + res.writeHead(401, { 'content-type': 'text/plain; charset=utf-8' }); + res.end('{"error":"invalid api key"}\n'); + return; + } + if (!match) { + res.writeHead(404, { 'content-type': 'text/plain; charset=utf-8' }); + res.end('404 page not found'); + return; + } + const found = rows[match[1]]; + res.writeHead(200, { 'content-type': 'application/json' }); + res.end(`${JSON.stringify({ data: found ? [found] : [], total: found ? 1 : 0 })}\n`); +}); +historyApi.listen(0, '127.0.0.1'); +await once(historyApi, 'listening'); + +const child = spawn(process.execPath, ['examples/node-http/server.mjs'], { + cwd: root, + env: { + ...process.env, + SHIELDLABS_API_KEY: API_KEY, + SHIELDLABS_WEBHOOK_SECRET: ` ${WEBHOOK_SECRET} , ${PREVIOUS_SECRET}`, + SHIELDLABS_API_BASE_URL: `http://127.0.0.1:${historyApi.address().port}`, + PORT: '0', + }, + stdio: ['ignore', 'pipe', 'pipe'], +}); + +let output = ''; +child.stdout.on('data', (chunk) => (output += chunk)); +child.stderr.on('data', (chunk) => (output += chunk)); + +const failures = []; +function check(label, condition) { + console.log(`${condition ? 'ok ' : 'FAIL'} ${label}`); + if (!condition) failures.push(label); +} + +try { + const started = Date.now(); + while (!LISTENING.test(output)) { + if (child.exitCode !== null || Date.now() - started > 10_000) { + throw new Error(`The example did not start:\n${output}`); + } + await new Promise((resolve) => setTimeout(resolve, 50)); + } + const port = output.match(LISTENING)[1]; + const base = `http://127.0.0.1:${port}`; + + async function signup(body) { + const res = await fetch(`${base}/signup`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(body), + }); + return { status: res.status, body: await res.json() }; + } + + async function deliver(body, secret = WEBHOOK_SECRET) { + const signature = `sha256=${createHmac('sha256', secret).update(body).digest('hex')}`; + const res = await fetch(`${base}/webhooks/shieldlabs`, { + method: 'POST', + headers: { 'content-type': 'application/json', 'x-shield-signature': signature }, + body, + }); + return res.status; + } + + // The missing case waits for the whole polling window (about 10 seconds), so run it alongside. + const missing = signup({ requestId: ids.missing }); + + const clean = await signup({ requestId: ids.clean }); + check( + 'a clean identification is accepted', + clean.status === 201 && clean.body.band === 'trusted', + ); + const replay = await signup({ requestId: ids.clean }); + check( + 'a reused request ID is refused', + replay.status === 403 && replay.body.reason === 'replayed', + ); + const dangerous = await signup({ requestId: ids.dangerous }); + check( + 'the dangerous band is refused', + dangerous.status === 403 && dangerous.body.reason === 'blocked_band', + ); + const automation = await signup({ requestId: ids.automation }); + check( + 'browser automation is refused', + automation.status === 403 && automation.body.reason === 'blocked_flag', + ); + const marker = await signup({ requestId: ids.marker }); + check( + 'the rate-limit marker is refused', + marker.status === 403 && marker.body.reason === 'rate_limited', + ); + const invalid = await signup({ requestId: 'not-a-uuid' }); + check('an invalid request ID is a 400', invalid.status === 400); + const absent = await signup({}); + check('an absent request ID is a 400', absent.status === 400); + + const scored = readFileSync( + new URL('../test/data/webhook-identification-scored.raw.txt', import.meta.url), + ); + check('a signed delivery is accepted', (await deliver(scored)) === 200); + check('a repeated delivery is accepted', (await deliver(scored)) === 200); + check('a wrong signature is refused', (await deliver(scored, 'whsec_wrong')) === 401); + const ping = readFileSync(new URL('../test/data/webhook-ping.raw.txt', import.meta.url)); + check('the ping is accepted', (await deliver(ping)) === 200); + check( + 'a delivery signed with the previous secret is accepted during rotation', + (await deliver(ping, PREVIOUS_SECRET)) === 200, + ); + + const missingResult = await missing; + check( + 'a missing identification is refused', + missingResult.status === 403 && missingResult.body.reason === 'missing', + ); + + await new Promise((resolve) => setTimeout(resolve, 100)); + const scoredLines = output.split('\n').filter((line) => line.startsWith('identification.scored')); + check('each delivery is handled once', scoredLines.length === 1); + check('the delivery log shows the band', scoredLines[0]?.includes('band=dangerous') === true); + check( + 'the pings are logged', + output.split('\n').filter((line) => line.startsWith('webhook.ping received')).length === 2, + ); +} catch (error) { + failures.push(String(error)); + console.error(error); +} finally { + child.kill(); + historyApi.close(); +} + +if (failures.length > 0) { + console.error(`\n${failures.length} check(s) failed. Example output:\n${output}`); + process.exit(1); +} +console.log('\nexamples/node-http works end to end.'); diff --git a/src/client.ts b/src/client.ts deleted file mode 100644 index ee16f9a..0000000 --- a/src/client.ts +++ /dev/null @@ -1,46 +0,0 @@ -export interface ShieldLabsClientOptions { - /** Secret API key issued in the ShieldLabs dashboard. */ - apiKey: string; - /** Optional API base URL. Defaults to the ShieldLabs History API. */ - baseUrl?: string; -} - -const DEFAULT_BASE_URL = "https://account.shieldlabs.ai/api"; - -/** - * Thin client for ShieldLabs server APIs. - * History reads target account.shieldlabs.ai (Shield.Portal.Admin). - */ -export class ShieldLabsClient { - readonly apiKey: string; - readonly baseUrl: string; - - constructor(options: ShieldLabsClientOptions) { - this.apiKey = options.apiKey; - this.baseUrl = options.baseUrl ?? DEFAULT_BASE_URL; - } - - /** - * Fetch history by identifier. - * GET /api/v1/history/{search_type}/{value} → { data, total } - */ - async getHistory( - searchType: string, - value: string, - opts?: { limit?: number; offset?: number }, - ): Promise<{ data: unknown[]; total: number }> { - const url = new URL( - `${this.baseUrl.replace(/\/$/, "")}/api/v1/history/${encodeURIComponent(searchType)}/${encodeURIComponent(value)}`, - ); - if (opts?.limit != null) url.searchParams.set("limit", String(opts.limit)); - if (opts?.offset != null) url.searchParams.set("offset", String(opts.offset)); - - const res = await fetch(url, { - headers: { Authorization: `Bearer ${this.apiKey}` }, - }); - if (!res.ok) { - throw new Error(`ShieldLabs History API error: ${res.status}`); - } - return (await res.json()) as { data: unknown[]; total: number }; - } -} diff --git a/src/common.ts b/src/common.ts new file mode 100644 index 0000000..faafa82 --- /dev/null +++ b/src/common.ts @@ -0,0 +1,55 @@ +// Exports shared by the Node entry (index.ts) and the edge entry (edge.ts). +export { ShieldLabs } from './history.js'; +export type { + GetIdentificationOptions, + IterateOptions, + SearchOptions, + ShieldLabsOptions, +} from './history.js'; +export { ShieldLabsManagement } from './management.js'; +export type { GetProfileOptions, ShieldLabsManagementOptions } from './management.js'; +export { evaluateIdentification, isRateLimited, riskBand } from './risk.js'; +export type { EvaluateOptions, Evaluation, EvaluationReason } from './risk.js'; +export { NIL_UUID, RISK_BANDS, SIGNALS } from './constants.js'; +export type { KnownSignal } from './constants.js'; +export { VERSION } from './version.js'; +export { + ApiError, + AuthenticationError, + BadRequestError, + ConnectionError, + NotFoundError, + QuotaExceededError, + RateLimitError, + ServerError, + ShieldLabsError, + SignatureVerificationError, + TimeoutError, + ValidationError, + WebhookParseError, +} from './errors.js'; +export type { ApiErrorOptions, HeadersLike } from './errors.js'; +export type { Webhooks } from './webhooks.js'; +export type { + ConnectionType, + DetectionFlags, + DomainProfile, + FetchLike, + FetchRequestInit, + FetchResponseLike, + HistoryPage, + Identification, + IdentificationScoredEvent, + IdentificationSignal, + IpInfo, + LookupType, + RiskBand, + SignatureHeader, + TrafficSource, + UnknownEventType, + UnknownWebhookEvent, + WebhookEvent, + WebhookPayload, + WebhookPingEvent, + WebhookSecret, +} from './types.js'; diff --git a/src/constants.ts b/src/constants.ts new file mode 100644 index 0000000..ec50236 --- /dev/null +++ b/src/constants.ts @@ -0,0 +1,82 @@ +import type { DetectionFlags, LookupType, RiskBand } from './types.js'; + +/** The all-zero UUID. As a device ID it means "no usable device signals". */ +export const NIL_UUID = '00000000-0000-0000-0000-000000000000'; + +/** + * Known risk signal slugs (`IdentificationSignal.name`). Signal names are an open set: new slugs + * can appear at any time, so compare against these constants and never treat them as exhaustive. + */ +export const SIGNALS = Object.freeze({ + TOR: 'tor', + JAVASCRIPT_DISABLED: 'javascript_disabled', + OS_MISMATCH: 'os_mismatch', + ANTIDETECT_BROWSER: 'antidetect_browser', + PROXY_ROUTED_ANTIDETECT: 'proxy_routed_antidetect', + PORT_SCAN_ROUTED_VIA_PROXY: 'port_scan_routed_via_proxy', + BROWSER_AUTOMATION: 'browser_automation', + STUN_NOT_CHECKED: 'stun_not_checked', + STUN_LATE_CORRECTION: 'stun_late_correction', + OS_NOT_DETECTED: 'os_not_detected', + BROWSER_VPN_PROXY: 'browser_vpn_proxy', + VPN: 'vpn', + PRIVACY_RELAY: 'privacy_relay', + PROXY: 'proxy', + DATACENTER_IP: 'datacenter_ip', + ABUSER: 'abuser', + TIMEZONE_MISMATCH: 'timezone_mismatch', + RATE_LIMITED: 'rate_limited', +} as const); + +/** A known signal slug. */ +export type KnownSignal = (typeof SIGNALS)[keyof typeof SIGNALS]; + +/** Score ranges of the three risk bands (inclusive). */ +export const RISK_BANDS = Object.freeze({ + trusted: Object.freeze({ min: 0, max: 29 }), + suspicious: Object.freeze({ min: 30, max: 59 }), + dangerous: Object.freeze({ min: 60, max: 100 }), +} as const); + +export const RISK_BAND_NAMES: readonly RiskBand[] = ['trusted', 'suspicious', 'dangerous']; + +export const LOOKUP_TYPES: readonly LookupType[] = [ + 'ip', + 'user_hid', + 'visitor_id', + 'request_id', + 'device_id', + 'session_id', + 'cookie_id', +]; + +export type FlagKey = keyof DetectionFlags; + +/** The 19 detection flag keys, in the order the webhook payload lists them. */ +export const FLAG_KEYS: readonly FlagKey[] = [ + 'vpn', + 'privacy_relay', + 'browser_vpn_proxy', + 'tor', + 'proxy', + 'datacenter_ip', + 'abuser', + 'os_mismatch', + 'os_not_detected', + 'timezone_mismatch', + 'anti_detect_browser', + 'browser_automation', + 'ip_mismatch', + 'incognito', + 'search_bot', + 'suspicious_paid_click', + 'javascript_disabled', + 'stun_not_checked', + 'check_incomplete', +]; + +/** Webhook schema version this SDK was built for. */ +export const KNOWN_SCHEMA_VERSION = '2026-06-01'; + +export const DEFAULT_HISTORY_BASE_URL = 'https://account.shieldlabs.ai'; +export const DEFAULT_MANAGEMENT_BASE_URL = 'https://api.shieldlabs.ai'; diff --git a/src/edge.ts b/src/edge.ts new file mode 100644 index 0000000..d295294 --- /dev/null +++ b/src/edge.ts @@ -0,0 +1,71 @@ +/** + * @shieldlabs-ai/node for edge runtimes, workers, Deno and browsers. + * + * Same API as the Node entry, built on fetch and WebCrypto only. It never loads node:crypto, so + * the synchronous helpers (`webhooks.verifySignature`, `webhooks.constructEvent`, `userHid`) + * throw an error that points to their Async variants. + */ +import { ShieldLabsError } from './errors.js'; +import { + computeUserHid, + computeUserHidAsync, + createWebhooks, + type CryptoBackend, + type Webhooks, +} from './webhooks.js'; + +interface SubtleLike { + importKey( + format: 'raw', + keyData: Uint8Array, + algorithm: { name: 'HMAC'; hash: 'SHA-256' }, + extractable: boolean, + keyUsages: string[], + ): Promise; + sign(algorithm: 'HMAC', key: unknown, data: Uint8Array): Promise; +} + +const encoder = new TextEncoder(); + +function subtle(): SubtleLike { + const cryptoObject = (globalThis as { crypto?: { subtle?: SubtleLike } }).crypto; + if (!cryptoObject?.subtle) { + throw new ShieldLabsError('WebCrypto (crypto.subtle) is not available in this runtime.'); + } + return cryptoObject.subtle; +} + +async function hmacAsync(key: string, message: Uint8Array): Promise { + const webCrypto = subtle(); + const cryptoKey = await webCrypto.importKey( + 'raw', + encoder.encode(key), + { name: 'HMAC', hash: 'SHA-256' }, + false, + ['sign'], + ); + return new Uint8Array(await webCrypto.sign('HMAC', cryptoKey, message)); +} + +const backend: CryptoBackend = { hmacSync: null, hmacAsync }; + +/** Webhook signature verification (X-Shield-Signature) and typed event parsing. */ +export const webhooks: Webhooks = createWebhooks(backend); + +/** + * Not available in this build: it needs node:crypto. Throws an error that points to + * `userHidAsync`, which computes the same value with WebCrypto. + */ +export function userHid(userId: string, secret: string): string { + return computeUserHid(backend, userId, secret); +} + +/** + * A stable, irreversible User HID for one of your accounts: HMAC-SHA256 of `userId` keyed with + * `secret`, as 64 lowercase hex characters. Throws ValidationError on empty input. + */ +export function userHidAsync(userId: string, secret: string): Promise { + return computeUserHidAsync(backend, userId, secret); +} + +export * from './common.js'; diff --git a/src/errors.ts b/src/errors.ts new file mode 100644 index 0000000..989d8a3 --- /dev/null +++ b/src/errors.ts @@ -0,0 +1,118 @@ +/** + * Error hierarchy of the SDK. + * + * ShieldLabsError + * ApiError (the server answered with an error status or an unusable body) + * BadRequestError (400), AuthenticationError (401, 403), QuotaExceededError (402), + * NotFoundError (404), RateLimitError (429), ServerError (5xx) + * ConnectionError (network failure), TimeoutError (per-attempt timeout) + * SignatureVerificationError, WebhookParseError + * ValidationError (invalid arguments, detected before any HTTP request) + */ + +/** Minimal view of response headers (a `Headers` object satisfies it). */ +export interface HeadersLike { + get(name: string): string | null; +} + +/** Base class of every error thrown by this SDK. */ +export class ShieldLabsError extends Error { + override name = 'ShieldLabsError'; + + constructor(message: string, options?: { cause?: unknown }) { + super(message, options?.cause === undefined ? undefined : { cause: options.cause }); + } +} + +export interface ApiErrorOptions { + status: number; + /** Parsed response body: a JSON value when the body parses as JSON, the raw text otherwise, null when empty. */ + body?: unknown; + headers?: HeadersLike | undefined; + cause?: unknown; +} + +const EMPTY_HEADERS: HeadersLike = { get: () => null }; + +/** The server answered with an error status (or a success status with an unusable body). */ +export class ApiError extends ShieldLabsError { + override name = 'ApiError'; + /** HTTP status code. */ + readonly status: number; + /** Parsed response body: JSON value, raw text, or null when the body was empty. */ + readonly body: unknown; + /** Response headers. */ + readonly headers: HeadersLike; + + constructor(message: string, options: ApiErrorOptions) { + super(message, { cause: options.cause }); + this.status = options.status; + this.body = options.body === undefined ? null : options.body; + this.headers = options.headers ?? EMPTY_HEADERS; + } +} + +/** HTTP 400. */ +export class BadRequestError extends ApiError { + override name = 'BadRequestError'; +} + +/** HTTP 401 or 403: the key, secret or domain is wrong, or the domain is disabled. */ +export class AuthenticationError extends ApiError { + override name = 'AuthenticationError'; +} + +/** + * HTTP 402. Neither the History API nor the Management API returns it today: an account over its + * included volume keeps working and its profile shows a negative remaining count. + */ +export class QuotaExceededError extends ApiError { + override name = 'QuotaExceededError'; +} + +/** HTTP 404: usually a wrong base URL or path. */ +export class NotFoundError extends ApiError { + override name = 'NotFoundError'; +} + +/** HTTP 429. */ +export class RateLimitError extends ApiError { + override name = 'RateLimitError'; + /** Seconds to wait, from the Retry-After header, when the server sent one. */ + readonly retryAfter: number | undefined; + + constructor(message: string, options: ApiErrorOptions & { retryAfter?: number | undefined }) { + super(message, options); + this.retryAfter = options.retryAfter; + } +} + +/** HTTP 5xx, including gateway errors from edge proxies. */ +export class ServerError extends ApiError { + override name = 'ServerError'; +} + +/** The request never produced an HTTP response (DNS, TLS, connection reset, and so on). */ +export class ConnectionError extends ShieldLabsError { + override name = 'ConnectionError'; +} + +/** A request attempt took longer than the configured timeout. */ +export class TimeoutError extends ShieldLabsError { + override name = 'TimeoutError'; +} + +/** The X-Shield-Signature header is missing, malformed or does not match the body. */ +export class SignatureVerificationError extends ShieldLabsError { + override name = 'SignatureVerificationError'; +} + +/** The webhook body is authentic but is not a usable ShieldLabs event. */ +export class WebhookParseError extends ShieldLabsError { + override name = 'WebhookParseError'; +} + +/** An argument is invalid. Thrown before any HTTP request is sent. */ +export class ValidationError extends ShieldLabsError { + override name = 'ValidationError'; +} diff --git a/src/history.ts b/src/history.ts new file mode 100644 index 0000000..9f9dc88 --- /dev/null +++ b/src/history.ts @@ -0,0 +1,379 @@ +import { DEFAULT_HISTORY_BASE_URL } from './constants.js'; +import { + ApiError, + ConnectionError, + RateLimitError, + ServerError, + type ShieldLabsError, + TimeoutError, + ValidationError, +} from './errors.js'; +import { RATE_LIMIT_FLOOR_MS, RETRY_AFTER_CAP_MS, Transport, normalizeBaseUrl } from './http.js'; +import { fromHistoryRow, isPlainObject } from './normalize.js'; +import { sleep, throwIfAborted, warnIfBrowserPage, warnOnce } from './runtime.js'; +import type { FetchLike, HistoryPage, Identification, LookupType } from './types.js'; +import { + validateBoolean, + validateCredential, + validateLookup, + validateMilliseconds, + validateNonNegativeInteger, + validatePageSize, + validateRequestId, + type Lookup, +} from './validation.js'; + +/** Options of the History API client. */ +export interface ShieldLabsOptions { + /** Private API Key of the domain (sec_...). Keep it on your server. */ + apiKey: string; + /** Origin of the History API. Default https://account.shieldlabs.ai. A trailing /api is removed. */ + baseUrl?: string | undefined; + /** Timeout of one HTTP attempt in milliseconds. Default 10 000. */ + timeout?: number | undefined; + /** Retries for connection errors, timeouts, 429 and 5xx. Default 2. */ + maxRetries?: number | undefined; + /** Custom fetch implementation. Default: the global fetch. */ + fetch?: FetchLike | undefined; + /** + * Accept a plain http `baseUrl` on a host other than localhost, 127.0.0.1 or [::1], for a test + * server. The key then travels unencrypted. Default false. + */ + allowInsecureHttp?: boolean | undefined; +} + +/** Options of `history.search`. */ +export interface SearchOptions { + /** Page size from 1 to 100. Default 20. */ + limit?: number | undefined; + /** Number of rows to skip. Default 0. */ + offset?: number | undefined; + signal?: AbortSignal | undefined; +} + +/** Options of `history.iterate`. */ +export interface IterateOptions { + /** Rows per request from 1 to 100. Default 100. */ + pageSize?: number | undefined; + /** Stop after this many identifications. Default: no limit. */ + maxItems?: number | undefined; + signal?: AbortSignal | undefined; +} + +/** Options of `identifications.get`. */ +export interface GetIdentificationOptions { + /** Poll until the identification appears (true) or read once (false). Default true. */ + wait?: boolean | undefined; + /** + * Total time budget of the wait in milliseconds, counted from the call. Default 10 000. The + * last poll runs at this deadline. Each poll is one HTTP attempt whose timeout is the client + * timeout, shortened to the time left but never below 1 second, so the last poll can end up + * to 1 second after the deadline. Pass `signal` (for example `AbortSignal.timeout(11_000)`) + * for a hard limit that also cancels a request in flight. + */ + timeout?: number | undefined; + /** + * Base wait between polls in milliseconds. The waits are 1, 2, 4 and 6 times this value, then 8 + * times it for every later wait, each capped at 2 000 or at this value when it is longer. + * Default 250, which gives waits of 250, 500, 1 000, 1 500 and then 2 000; 1 000 gives 1 000 + * and then 2 000; 3 000 gives 3 000 every time. + */ + pollInterval?: number | undefined; + signal?: AbortSignal | undefined; +} + +const API_KEY_FORMAT = /^sec_[a-z0-9]{8}-[a-z0-9]{8}-[a-z0-9]{8}$/; +const DEFAULT_TIMEOUT_MS = 10_000; +const DEFAULT_MAX_RETRIES = 2; +const DEFAULT_LIMIT = 20; +const DEFAULT_PAGE_SIZE = 100; +const DEFAULT_WAIT_TIMEOUT_MS = 10_000; +const DEFAULT_POLL_INTERVAL_MS = 250; +/** Cap of every wait between polls, unless `pollInterval` is longer: then that is the cap. */ +const POLL_WAIT_CAP_MS = 2_000; +/** Multiples of `pollInterval` for the waits after the first four polls. */ +const POLL_WAIT_FACTORS = [1, 2, 4, 6] as const; +/** Multiple of `pollInterval` for every later wait. */ +const LAST_POLL_WAIT_FACTOR = 8; +/** Shortest attempt timeout of a poll, even when less time is left before the deadline. */ +const MIN_POLL_ATTEMPT_MS = 1_000; + +/** + * The scheduled wait after poll number `index` (0 for the first poll): `pollInterval` times 1, 2, + * 4 and 6, then 8 times it for every later wait, each capped at max(2 s, `pollInterval`). The + * default 250 ms gives 250, 500, 1 000, 1 500 and then 2 000 ms; 1 s gives 1 s and then 2 s; + * 3 s gives 3 s every time. + */ +export function pollWait(index: number, pollInterval: number): number { + const factor = POLL_WAIT_FACTORS[index] ?? LAST_POLL_WAIT_FACTOR; + return Math.min(Math.max(POLL_WAIT_CAP_MS, pollInterval), pollInterval * factor); +} + +function parsePage(body: unknown, status: number, headers: ApiError['headers']): HistoryPage { + if (!isPlainObject(body) || !Array.isArray(body.data)) { + throw new ApiError('History API returned an unexpected response body.', { + status, + body, + headers, + }); + } + const data = body.data.filter(isPlainObject).map(fromHistoryRow); + const total = + typeof body.total === 'number' && Number.isFinite(body.total) ? body.total : data.length; + return { data, total }; +} + +/** + * One validated History request. `maxRetries` overrides the client setting and `attemptTimeout` + * caps the client timeout when given. + */ +async function fetchPage( + transport: Transport, + lookup: Lookup, + limit: number, + offset: number, + signal: AbortSignal | undefined, + maxRetries?: number, + attemptTimeout?: number, +): Promise { + const path = `/api/v1/history/${lookup.type}/${lookup.segment}`; + const response = await transport.getJson(path, { + query: { limit: String(limit), offset: String(offset) }, + signal, + maxRetries, + timeout: attemptTimeout, + }); + return parsePage(response.body, response.status, response.headers); +} + +/** Errors a later poll can get past: 429, 5xx, connection failures and attempt timeouts. */ +function isTransientPollError(error: unknown): error is ShieldLabsError { + return ( + error instanceof RateLimitError || + error instanceof ServerError || + error instanceof ConnectionError || + error instanceof TimeoutError + ); +} + +/** Reads identifications from the History API. */ +export class HistoryResource { + readonly #transport: Transport; + + constructor(transport: Transport) { + this.#transport = transport; + } + + /** + * One page of identifications for one identifier, newest first. + * `GET /api/v1/history/{type}/{value}`. + */ + async search(type: LookupType, value: string, options: SearchOptions = {}): Promise { + const lookup = validateLookup(type, value); + const limit = validatePageSize(options.limit ?? DEFAULT_LIMIT, 'limit'); + const offset = validateNonNegativeInteger(options.offset ?? 0, 'offset'); + return fetchPage(this.#transport, lookup, limit, offset, options.signal); + } + + /** + * Every identification for one identifier, newest first, fetched page by page. Rows repeated + * across pages (new rows can shift offsets) are skipped by request ID. Stops at `total`, at an + * empty page or after `maxItems`. + */ + iterate( + type: LookupType, + value: string, + options: IterateOptions = {}, + ): AsyncGenerator { + const lookup = validateLookup(type, value); + const pageSize = validatePageSize(options.pageSize ?? DEFAULT_PAGE_SIZE, 'pageSize'); + const maxItems = + options.maxItems === undefined + ? undefined + : validateNonNegativeInteger(options.maxItems, 'maxItems'); + return this.#iterate(lookup, pageSize, maxItems, options.signal); + } + + async *#iterate( + lookup: Lookup, + pageSize: number, + maxItems: number | undefined, + signal: AbortSignal | undefined, + ): AsyncGenerator { + const seen = new Set(); + let offset = 0; + let yielded = 0; + for (;;) { + if (maxItems !== undefined && yielded >= maxItems) return; + const page = await fetchPage(this.#transport, lookup, pageSize, offset, signal); + if (page.data.length === 0) return; + for (const identification of page.data) { + const key = identification.request_id; + if (key !== '') { + if (seen.has(key)) continue; + seen.add(key); + } + yield identification; + yielded++; + if (maxItems !== undefined && yielded >= maxItems) return; + } + offset += page.data.length; + if (offset >= page.total) return; + } + } +} + +/** Reads single identifications by request ID. */ +export class IdentificationsResource { + readonly #transport: Transport; + + constructor(transport: Transport) { + this.#transport = transport; + } + + /** + * The identification with this request ID, or null when none is found. + * + * Scoring is asynchronous: the row appears about 1 to 3 seconds after the browser call and can + * be refined for up to about 10 seconds while follow-up checks finish; this returns the first + * version it sees. With `wait` (the default), `timeout` is the total budget of the call: + * + * - The first poll runs at once. The waits between polls are `pollInterval` times 1, 2, 4 and 6, + * then 8 times it for every later wait, each capped at max(2 s, `pollInterval`): 250 ms, + * 500 ms, 1 s, 1.5 s and then every 2 s by default, and every 3 s for a `pollInterval` of + * 3 s. A wait that would pass the deadline is cut short, so the last poll runs at the + * deadline. + * - Each poll is one HTTP attempt without retries, with the timeout + * min(client timeout, max(time left, 1 s)). + * - 429, 5xx, connection errors and timeouts do not end the wait. After a 429 the next wait is + * the longest of the scheduled wait, 1 s and the Retry-After capped at 10 s (a missing + * Retry-After, 0 or a past date counts as 0), cut short at the deadline like any other wait. + * When the capped Retry-After is longer than the time left, that RateLimitError is thrown at + * once. + * - At the deadline, the error of the last poll is thrown if it failed; otherwise the result + * is null. + * - 400, 401, 403 and 404 (and any other error that another poll cannot fix) are thrown at once. + */ + async get( + requestId: string, + options: GetIdentificationOptions = {}, + ): Promise { + const id = validateRequestId(requestId); + const wait = validateBoolean(options.wait ?? true, 'wait'); + const timeout = validateMilliseconds( + options.timeout ?? DEFAULT_WAIT_TIMEOUT_MS, + 'timeout', + true, + ); + const pollInterval = validateMilliseconds( + options.pollInterval ?? DEFAULT_POLL_INTERVAL_MS, + 'pollInterval', + ); + const { signal } = options; + throwIfAborted(signal); + const lookup: Lookup = { type: 'request_id', value: id, segment: id }; + + if (!wait) { + const page = await fetchPage(this.#transport, lookup, 1, 0, signal); + return page.data[0] ?? null; + } + return this.#poll(lookup, Date.now() + timeout, pollInterval, signal); + } + + async #poll( + lookup: Lookup, + deadline: number, + pollInterval: number, + signal: AbortSignal | undefined, + ): Promise { + let atDeadline = false; + for (let index = 0; ; index++) { + const attemptTimeout = Math.min( + this.#transport.timeout, + Math.max(deadline - Date.now(), MIN_POLL_ATTEMPT_MS), + ); + let failure: ShieldLabsError | undefined; + try { + const page = await fetchPage(this.#transport, lookup, 1, 0, signal, 0, attemptTimeout); + const found = page.data[0]; + if (found) return found; + } catch (error) { + throwIfAborted(signal); + if (!isTransientPollError(error)) throw error; + failure = error; + } + + const remaining = deadline - Date.now(); + if (atDeadline || remaining <= 0) { + if (failure !== undefined) throw failure; + return null; + } + + let delay = pollWait(index, pollInterval); + if (failure instanceof RateLimitError) { + // No Retry-After counts as 0, like "Retry-After: 0" or a past date: the 1 s floor applies. + const retryAfter = Math.min((failure.retryAfter ?? 0) * 1000, RETRY_AFTER_CAP_MS); + // Retry-After asks for more time than is left: no later poll fits before the deadline. + if (retryAfter > remaining) throw failure; + delay = Math.max(delay, RATE_LIMIT_FLOOR_MS, retryAfter); + } + if (delay >= remaining) { + delay = remaining; + atDeadline = true; + } + await sleep(delay, signal); + } + } +} + +/** + * Client for the History API (`https://account.shieldlabs.ai`), authenticated with the Private + * API Key of one domain. Stateless apart from its configuration, so one instance can be shared + * by concurrent requests. + */ +export class ShieldLabs { + /** Single identifications by request ID, with wait-for-verdict polling. */ + readonly identifications: IdentificationsResource; + /** History lookups by identifier. */ + readonly history: HistoryResource; + + constructor(options: ShieldLabsOptions) { + if (!isPlainObject(options)) { + throw new ValidationError('new ShieldLabs() needs an options object with apiKey.'); + } + const apiKey = validateCredential(options.apiKey, 'apiKey'); + warnIfBrowserPage('ShieldLabs', 'Private API Key'); + if (!API_KEY_FORMAT.test(apiKey)) { + warnOnce( + 'api-key-format', + 'apiKey does not look like a Private API Key (sec_xxxxxxxx-xxxxxxxx-xxxxxxxx). The History API needs the Private API Key, not the Public Key or the Secret Key.', + ); + } + const baseUrl = normalizeBaseUrl( + options.baseUrl ?? DEFAULT_HISTORY_BASE_URL, + 'baseUrl', + true, + validateBoolean(options.allowInsecureHttp ?? false, 'allowInsecureHttp'), + ); + const timeout = validateMilliseconds(options.timeout ?? DEFAULT_TIMEOUT_MS, 'timeout'); + const maxRetries = validateNonNegativeInteger( + options.maxRetries ?? DEFAULT_MAX_RETRIES, + 'maxRetries', + ); + if (options.fetch !== undefined && typeof options.fetch !== 'function') { + throw new ValidationError('fetch must be a function.'); + } + const transport = new Transport({ + apiName: 'History API', + baseUrl, + headers: { Authorization: `Bearer ${apiKey}` }, + secrets: [apiKey], + timeout, + maxRetries, + retryRateLimited: true, + fetch: options.fetch, + }); + this.history = new HistoryResource(transport); + this.identifications = new IdentificationsResource(transport); + } +} diff --git a/src/http.ts b/src/http.ts new file mode 100644 index 0000000..38d23e4 --- /dev/null +++ b/src/http.ts @@ -0,0 +1,409 @@ +import { + ApiError, + AuthenticationError, + BadRequestError, + ConnectionError, + NotFoundError, + QuotaExceededError, + RateLimitError, + ServerError, + ShieldLabsError, + TimeoutError, + ValidationError, + type ApiErrorOptions, + type HeadersLike, +} from './errors.js'; +import { isPlainObject } from './normalize.js'; +import { abortReason, sleep, throwIfAborted, userAgent, warnOnce } from './runtime.js'; +import type { FetchLike, FetchResponseLike } from './types.js'; + +export const BACKOFF_BASE_MS = 500; +export const BACKOFF_CAP_MS = 8_000; +export const RETRY_AFTER_CAP_MS = 10_000; +/** + * Shortest wait after a History 429 (the limit resets every second): after any 429 inside the wait + * of `identifications.get`, and before retrying an ordinary request whose 429 had no Retry-After. + * An ordinary request with Retry-After waits as long as it says, capped at RETRY_AFTER_CAP_MS. + */ +export const RATE_LIMIT_FLOOR_MS = 1_000; +const MAX_DETAIL_LENGTH = 200; + +export type ApiName = 'History API' | 'Management API'; + +export interface TransportOptions { + apiName: ApiName; + /** Normalized base URL without a trailing slash. */ + baseUrl: string; + /** Credential headers sent with every request. */ + headers: Record; + /** Credentials that must never appear in an error message or cause. */ + secrets: readonly string[]; + /** Per-attempt timeout in milliseconds. */ + timeout: number; + maxRetries: number; + /** False for the Management API: a 429 there starts a 10-minute block, so it is never retried. */ + retryRateLimited: boolean; + fetch: FetchLike | undefined; +} + +export interface RequestOptions { + query?: Record | undefined; + signal?: AbortSignal | undefined; + /** Overrides the transport's maxRetries for this call. */ + maxRetries?: number | undefined; + /** Caps the attempt timeout of this call in milliseconds; the client timeout still applies. */ + timeout?: number | undefined; +} + +export interface JsonResponse { + body: unknown; + status: number; + headers: HeadersLike; +} + +/** Loopback hosts, as the URL parser normalizes them. */ +export function isLoopbackHost(hostname: string): boolean { + return ( + hostname === 'localhost' || + hostname.endsWith('.localhost') || + hostname === '[::1]' || + /^127\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(hostname) + ); +} + +/** + * Normalizes a base URL: https origin plus an optional path prefix, no trailing slash. Plain http + * is accepted for loopback hosts, and for other hosts only with `allowInsecureHttp`. + * With `stripApiSuffix`, a trailing "/api" is removed so paths never become "/api/api/...". + */ +export function normalizeBaseUrl( + input: unknown, + name: string, + stripApiSuffix: boolean, + allowInsecureHttp = false, +): string { + if (typeof input !== 'string' || input.trim() === '') { + throw new ValidationError(`${name} must be a non-empty URL.`); + } + let url: URL; + try { + url = new URL(input.trim()); + } catch { + throw new ValidationError(`${name} is not a valid URL.`); + } + if (url.protocol !== 'https:' && url.protocol !== 'http:') { + throw new ValidationError(`${name} must be an https URL.`); + } + if (url.username !== '' || url.password !== '') { + throw new ValidationError(`${name} must not contain credentials.`); + } + if (url.search !== '' || url.hash !== '') { + throw new ValidationError(`${name} must not contain a query string or a fragment.`); + } + if (url.protocol === 'http:' && !isLoopbackHost(url.hostname)) { + if (!allowInsecureHttp) { + throw new ValidationError( + `${name} must be an https URL: with plain http the key would travel unencrypted. Plain http is accepted for localhost, 127.0.0.1 and [::1]; for a test server on another host, pass allowInsecureHttp: true.`, + ); + } + warnOnce( + `insecure-base-url:${url.origin}`, + `${name} uses plain http, so credentials travel unencrypted. Use https outside local testing.`, + ); + } + let path = url.pathname.replace(/\/+$/, ''); + if (stripApiSuffix && /\/api$/i.test(path)) path = path.slice(0, -4).replace(/\/+$/, ''); + return `${url.origin}${path}`; +} + +/** Parses a Retry-After header (delta seconds or an HTTP date) into seconds. */ +export function parseRetryAfter( + value: string | null | undefined, + nowMs = Date.now(), +): number | undefined { + if (value === null || value === undefined) return undefined; + const text = value.trim(); + if (/^\d+(?:\.\d+)?$/.test(text)) return Number(text); + // HTTP dates always name the weekday and month ("Wed, 30 Sep 2026 12:00:30 GMT"). + if (!/[a-z]{3}/i.test(text)) return undefined; + const date = Date.parse(text); + if (Number.isNaN(date)) return undefined; + return Math.max(0, Math.ceil((date - nowMs) / 1000)); +} + +function truncate(text: string): string { + return text.length > MAX_DETAIL_LENGTH ? `${text.slice(0, MAX_DETAIL_LENGTH - 3)}...` : text; +} + +function errorDetail(body: unknown, parsedJson: boolean, text: string): string | undefined { + if (parsedJson) { + if (isPlainObject(body) && typeof body.error === 'string' && body.error !== '') { + return truncate(body.error); + } + if (typeof body === 'string' && body !== '') return truncate(body); + return undefined; + } + const trimmed = text.trim(); + if (trimmed === '' || trimmed.startsWith('<')) return undefined; + return truncate(trimmed); +} + +function hintFor(apiName: ApiName, status: number): string { + if (status === 401 || status === 403) { + return apiName === 'History API' + ? ' Check that apiKey is the Private API Key (sec_...) of this domain.' + : ' Check the Secret Key and that domain matches the registered domain exactly.'; + } + if (status === 404) return ' Check the base URL.'; + if (status === 429 && apiName === 'Management API') { + return ' The Management API allows about 15 requests per minute per IP, then blocks the IP for 10 minutes.'; + } + return ''; +} + +/** Maps an error response to the matching error class. Never throws. */ +export function createApiError( + apiName: ApiName, + status: number, + text: string, + headers: HeadersLike, +): ApiError { + let body: unknown = null; + let parsedJson = false; + if (text !== '') { + try { + body = JSON.parse(text); + parsedJson = true; + } catch { + body = text; + } + } + const detail = errorDetail(body, parsedJson, text); + const message = `${apiName} request failed with HTTP ${status}${detail ? ` (${detail})` : ''}.${hintFor(apiName, status)}`; + const options: ApiErrorOptions = { status, body, headers }; + if (status === 400) return new BadRequestError(message, options); + if (status === 401 || status === 403) return new AuthenticationError(message, options); + if (status === 402) return new QuotaExceededError(message, options); + if (status === 404) return new NotFoundError(message, options); + if (status === 429) { + let retryAfter: number | undefined; + try { + retryAfter = parseRetryAfter(headers.get('retry-after')); + } catch { + retryAfter = undefined; + } + return new RateLimitError(message, { ...options, retryAfter }); + } + if (status >= 500 && status <= 599) return new ServerError(message, options); + return new ApiError(message, options); +} + +function messageOf(error: unknown): string { + return error instanceof Error && error.message ? error.message : String(error); +} + +/** + * The network cause behind a failed fetch, for example " (connect ECONNREFUSED 192.0.2.1:443)": + * runtimes wrap it in a generic "fetch failed" error. + */ +function causeDetail(error: unknown): string { + const cause = error instanceof Error ? error.cause : undefined; + if (typeof cause !== 'object' || cause === null) return ''; + const { code, message } = cause as { code?: unknown; message?: unknown }; + const codeText = typeof code === 'string' ? code : ''; + const text = typeof message === 'string' ? message.trim() : ''; + if (text !== '' && codeText !== '' && !text.includes(codeText)) { + return ` (${codeText}: ${truncate(text)})`; + } + if (text !== '') return ` (${truncate(text)})`; + return codeText === '' ? '' : ` (${codeText})`; +} + +/** Text an error and its causes can print: messages, stacks and their own string properties. */ +function printableTexts(error: unknown): string[] { + const texts: string[] = []; + let current: unknown = error; + for (let depth = 0; depth < 5 && current !== undefined && current !== null; depth++) { + if (typeof current !== 'object') { + if (typeof current === 'string') texts.push(current); + break; + } + const { message, stack } = current as { message?: unknown; stack?: unknown }; + if (typeof message === 'string') texts.push(message); + if (typeof stack === 'string') texts.push(stack); + try { + texts.push(JSON.stringify(current) ?? ''); + } catch { + // Circular or otherwise unserializable: the message and stack above still count. + } + current = (current as { cause?: unknown }).cause; + } + return texts; +} + +function mentionsSecret(texts: readonly string[], secrets: readonly string[]): boolean { + return secrets.some((secret) => secret !== '' && texts.some((text) => text.includes(secret))); +} + +/** Resolves when `promise` settles, or rejects as soon as `signal` aborts (even if `promise` never settles). */ +function raceWithAbort(promise: Promise, signal: AbortSignal): Promise { + return new Promise((resolve, reject) => { + if (signal.aborted) { + reject(abortReason(signal)); + return; + } + const onAbort = (): void => reject(abortReason(signal)); + signal.addEventListener('abort', onAbort, { once: true }); + promise.then( + (value) => { + signal.removeEventListener('abort', onAbort); + resolve(value); + }, + (error: unknown) => { + signal.removeEventListener('abort', onAbort); + reject(error instanceof Error ? error : new Error(String(error))); + }, + ); + }); +} + +function globalFetch(): FetchLike { + const candidate = (globalThis as { fetch?: unknown }).fetch; + if (typeof candidate !== 'function') { + throw new ValidationError( + 'No fetch implementation is available. Use Node.js 18 or later, or pass the fetch option.', + ); + } + const fetchFn = candidate as FetchLike; + return (url, init) => fetchFn.call(globalThis, url, init); +} + +/** Sends GET requests with timeouts, retries and error mapping. Safe for concurrent use. */ +export class Transport { + readonly #options: TransportOptions; + + constructor(options: TransportOptions) { + this.#options = options; + } + + /** The client's per-attempt timeout in milliseconds. */ + get timeout(): number { + return this.#options.timeout; + } + + async getJson(path: string, options: RequestOptions = {}): Promise { + const maxRetries = options.maxRetries ?? this.#options.maxRetries; + let url = `${this.#options.baseUrl}${path}`; + if (options.query) url += `?${new URLSearchParams(options.query).toString()}`; + for (let attempt = 0; ; attempt++) { + throwIfAborted(options.signal); + try { + return await this.#attempt(url, options.signal, options.timeout); + } catch (error) { + if (attempt >= maxRetries || !this.#isRetryable(error)) throw error; + await sleep(this.#retryDelay(error, attempt), options.signal); + } + } + } + + #isRetryable(error: unknown): boolean { + if (error instanceof ConnectionError || error instanceof TimeoutError) return true; + if (error instanceof RateLimitError) return this.#options.retryRateLimited; + return error instanceof ServerError; + } + + #retryDelay(error: unknown, attempt: number): number { + if (error instanceof ApiError) { + const retryAfter = + error instanceof RateLimitError + ? error.retryAfter + : parseRetryAfter(error.headers.get('retry-after')); + if (retryAfter !== undefined) return Math.min(retryAfter * 1000, RETRY_AFTER_CAP_MS); + } + const nominal = Math.min(BACKOFF_CAP_MS, BACKOFF_BASE_MS * 2 ** attempt); + const delay = nominal / 2 + Math.random() * (nominal / 2); + return error instanceof RateLimitError ? Math.max(delay, RATE_LIMIT_FLOOR_MS) : delay; + } + + #connectionError(error: unknown): ConnectionError { + const { apiName, secrets } = this.#options; + const message = `${apiName} request failed: ${messageOf(error)}${causeDetail(error)}`; + if (mentionsSecret([message, ...printableTexts(error)], secrets)) { + // For example a runtime error that echoes a header value: keep it out of logs entirely. + return new ConnectionError( + `${apiName} request failed before a response was received. The underlying error was withheld because it contained a credential.`, + ); + } + return new ConnectionError(message, { cause: error }); + } + + async #attempt( + url: string, + signal: AbortSignal | undefined, + timeoutCap: number | undefined, + ): Promise { + const { apiName } = this.#options; + const timeout = Math.min(this.#options.timeout, timeoutCap ?? Number.POSITIVE_INFINITY); + const fetchImpl = this.#options.fetch ?? globalFetch(); + const controller = new AbortController(); + let timedOut = false; + const timer = setTimeout(() => { + timedOut = true; + controller.abort(); + }, timeout); + const onAbort = (): void => controller.abort(); + signal?.addEventListener('abort', onAbort, { once: true }); + + let response: FetchResponseLike; + let text: string; + try { + const init = { + method: 'GET' as const, + headers: { + Accept: 'application/json', + 'User-Agent': userAgent(), + ...this.#options.headers, + }, + signal: controller.signal, + }; + response = await raceWithAbort( + Promise.resolve().then(() => fetchImpl(url, init)), + controller.signal, + ); + text = await raceWithAbort( + Promise.resolve().then(() => response.text()), + controller.signal, + ); + } catch (error) { + if (signal?.aborted) throw abortReason(signal); + if (timedOut) { + throw new TimeoutError(`${apiName} request timed out after ${timeout} ms.`, { + cause: error, + }); + } + if (error instanceof ShieldLabsError) throw error; + throw this.#connectionError(error); + } finally { + clearTimeout(timer); + signal?.removeEventListener('abort', onAbort); + } + + const { status, headers } = response; + if (status >= 200 && status < 300) { + try { + return { body: JSON.parse(text) as unknown, status, headers }; + } catch (error) { + throw new ApiError( + `${apiName} returned a response that is not valid JSON (HTTP ${status}).`, + { + status, + body: text, + headers, + cause: error, + }, + ); + } + } + throw createApiError(apiName, status, text, headers); + } +} diff --git a/src/index.ts b/src/index.ts index 5ef0e29..e05a21e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,21 +1,44 @@ /** - * @shieldlabs/node — server-side SDK. + * @shieldlabs-ai/node for Node.js and Bun. * - * Talks to the ShieldLabs API and verifies inbound webhooks. Your code - * decides what to do with the score: you set the rules. This SDK never - * makes the decision for you. + * Read identifications from the History API, read the domain profile from the Management API, + * verify signed webhooks and apply risk helpers. Edge runtimes resolve `edge.ts` instead, which + * exposes the same API without loading node:crypto. */ +import { createHmac, timingSafeEqual } from 'node:crypto'; +import { + computeUserHid, + computeUserHidAsync, + createWebhooks, + type CryptoBackend, + type Webhooks, +} from './webhooks.js'; -export type { ShieldLabsClientOptions } from "./client.js"; -export { ShieldLabsClient } from "./client.js"; -export { verifyWebhook } from "./verifyWebhook.js"; -export type { - WebhookDetectionFlags, - WebhookEvent, - WebhookEventType, - WebhookIPAddress, - WebhookScoredData, - WebhookSignal, - WebhookTrafficSource, -} from "./types.js"; -export { WEBHOOK_SCHEMA_VERSION } from "./types.js"; +const hmacSync = (key: string, message: Uint8Array): Uint8Array => + createHmac('sha256', key).update(message).digest(); + +const backend: CryptoBackend = { + hmacSync, + hmacAsync: (key, message) => Promise.resolve().then(() => hmacSync(key, message)), + equal: (a, b) => a.length === b.length && timingSafeEqual(a, b), +}; + +/** Webhook signature verification (X-Shield-Signature) and typed event parsing. */ +export const webhooks: Webhooks = createWebhooks(backend); + +/** + * A stable, irreversible User HID for one of your accounts: HMAC-SHA256 of `userId` keyed with + * `secret`, as 64 lowercase hex characters. Compute it on your server and pass it to the browser + * agent instead of a raw email address or account ID. Throws ValidationError on empty input. + * Needs node:crypto: in edge runtimes use `userHidAsync`. + */ +export function userHid(userId: string, secret: string): string { + return computeUserHid(backend, userId, secret); +} + +/** Same as `userHid`, as a promise (works in every runtime). */ +export function userHidAsync(userId: string, secret: string): Promise { + return computeUserHidAsync(backend, userId, secret); +} + +export * from './common.js'; diff --git a/src/management.ts b/src/management.ts new file mode 100644 index 0000000..fa1c652 --- /dev/null +++ b/src/management.ts @@ -0,0 +1,125 @@ +import { DEFAULT_MANAGEMENT_BASE_URL } from './constants.js'; +import { ApiError, ValidationError } from './errors.js'; +import { Transport, normalizeBaseUrl } from './http.js'; +import { isPlainObject, profileFromResponse } from './normalize.js'; +import { warnIfBrowserPage } from './runtime.js'; +import type { DomainProfile, FetchLike } from './types.js'; +import { + isAscii, + requireHeaderSafe, + validateBoolean, + validateCredential, + validateMilliseconds, + validateNonNegativeInteger, +} from './validation.js'; + +/** Options of the Management API client. */ +export interface ShieldLabsManagementOptions { + /** Secret Key of the domain. Keep it on your server. */ + secretKey: string; + /** The registered domain, for example "example.com". Normalized before it is sent. */ + domain: string; + /** Origin of the Management API. Default https://api.shieldlabs.ai. */ + baseUrl?: string | undefined; + /** Timeout of one HTTP attempt in milliseconds. Default 10 000. */ + timeout?: number | undefined; + /** Retries for connection errors, timeouts and 5xx (never for 429). Default 2. */ + maxRetries?: number | undefined; + /** Custom fetch implementation. Default: the global fetch. */ + fetch?: FetchLike | undefined; + /** + * Accept a plain http `baseUrl` on a host other than localhost, 127.0.0.1 or [::1], for a test + * server. The Secret Key then travels unencrypted. Default false. + */ + allowInsecureHttp?: boolean | undefined; +} + +/** Options of `getProfile`. */ +export interface GetProfileOptions { + signal?: AbortSignal | undefined; +} + +const DEFAULT_TIMEOUT_MS = 10_000; +const DEFAULT_MAX_RETRIES = 2; + +/** + * Normalizes a domain the way registered domains are stored: trimmed, lowercase, without scheme, + * path, trailing slash or a leading "www.". The server matches the registered domain exactly. + */ +export function normalizeDomain(input: string): string { + let domain = input.trim().toLowerCase(); + domain = domain.replace(/^[a-z][a-z0-9+.-]*:\/\//, ''); + domain = domain.split(/[/?#]/, 1)[0] ?? ''; + return domain.replace(/^www\./, ''); +} + +/** + * Client for the Management API (`https://api.shieldlabs.ai`), authenticated with the Secret Key + * and the registered domain. The API allows about 15 requests per minute per IP and then blocks + * the IP for 10 minutes, so this client never retries a 429: call it sparingly and cache results. + * Safe for concurrent use. + */ +export class ShieldLabsManagement { + /** The normalized domain sent in X-Shield-Domain. */ + readonly domain: string; + readonly #transport: Transport; + + constructor(options: ShieldLabsManagementOptions) { + if (!isPlainObject(options)) { + throw new ValidationError( + 'new ShieldLabsManagement() needs an options object with secretKey and domain.', + ); + } + const secretKey = validateCredential(options.secretKey, 'secretKey'); + warnIfBrowserPage('ShieldLabsManagement', 'Secret Key'); + if (typeof options.domain !== 'string') { + throw new ValidationError('domain is required and must be a string such as "example.com".'); + } + const domain = normalizeDomain(options.domain); + if (domain === '') { + throw new ValidationError('domain is required and must be a string such as "example.com".'); + } + if (!isAscii(domain)) { + throw new ValidationError('domain must be ASCII: pass the punycode form (xn--...).'); + } + requireHeaderSafe(domain, 'domain'); + const baseUrl = normalizeBaseUrl( + options.baseUrl ?? DEFAULT_MANAGEMENT_BASE_URL, + 'baseUrl', + false, + validateBoolean(options.allowInsecureHttp ?? false, 'allowInsecureHttp'), + ); + const timeout = validateMilliseconds(options.timeout ?? DEFAULT_TIMEOUT_MS, 'timeout'); + const maxRetries = validateNonNegativeInteger( + options.maxRetries ?? DEFAULT_MAX_RETRIES, + 'maxRetries', + ); + if (options.fetch !== undefined && typeof options.fetch !== 'function') { + throw new ValidationError('fetch must be a function.'); + } + this.domain = domain; + this.#transport = new Transport({ + apiName: 'Management API', + baseUrl, + headers: { 'X-Shield-Domain': domain, Authorization: `Bearer ${secretKey}` }, + secrets: [secretKey], + timeout, + maxRetries, + retryRateLimited: false, + fetch: options.fetch, + }); + } + + /** The domain profile: remaining identifications and masked keys. `GET /v1/profile`. */ + async getProfile(options: GetProfileOptions = {}): Promise { + const response = await this.#transport.getJson('/v1/profile', { signal: options.signal }); + if (!isPlainObject(response.body)) { + throw new ApiError('Management API returned an unexpected response body.', { + status: response.status, + body: response.body, + headers: response.headers, + }); + } + return profileFromResponse(response.body); + } +} diff --git a/src/normalize.ts b/src/normalize.ts new file mode 100644 index 0000000..8d85683 --- /dev/null +++ b/src/normalize.ts @@ -0,0 +1,456 @@ +/** + * Normalization of History API rows and webhook `data` objects into one `Identification` shape. + * + * This module follows the shared normalization rules used by every ShieldLabs server SDK, + * including their edge cases (truthiness of flag values, whitespace trimming rules and + * timestamp handling), so that all SDKs produce identical results for identical input. + * The only intentional differences are type guarantees: a value of the wrong JSON type becomes + * "" (strings), 0 (weights) or NaN (scores) instead of being passed through. + */ +import { FLAG_KEYS, type FlagKey } from './constants.js'; +import type { + DetectionFlags, + DomainProfile, + Identification, + IdentificationSignal, + IpInfo, + TrafficSource, +} from './types.js'; + +export type JsonObject = Record; + +export function isPlainObject(value: unknown): value is JsonObject { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function hasOwn(obj: JsonObject, key: string): boolean { + return Object.prototype.hasOwnProperty.call(obj, key); +} + +/** Own property value, or undefined when the key is absent. */ +function own(obj: JsonObject, key: string): unknown { + return hasOwn(obj, key) ? obj[key] : undefined; +} + +/** A string property, or "" when it is absent or not a string. */ +function stringField(obj: JsonObject, key: string): string { + const value = own(obj, key); + return typeof value === 'string' ? value : ''; +} + +/** Truthiness as defined by the shared rules: empty strings, arrays and objects, 0 and null are false. */ +export function isTruthy(value: unknown): boolean { + if (value === undefined || value === null) return false; + if (typeof value === 'boolean') return value; + if (typeof value === 'number') return value !== 0; + if (typeof value === 'string') return value.length > 0; + if (Array.isArray(value)) return value.length > 0; + if (typeof value === 'object') return Object.keys(value).length > 0; + return true; +} + +// Unicode whitespace as defined by the shared rules. Differs from String.prototype.trim(): +// it includes U+001C to U+001F and U+0085, and excludes U+FEFF. +const WHITESPACE = new Set([ + 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x1c, 0x1d, 0x1e, 0x1f, 0x20, 0x85, 0xa0, 0x1680, 0x2000, 0x2001, + 0x2002, 0x2003, 0x2004, 0x2005, 0x2006, 0x2007, 0x2008, 0x2009, 0x200a, 0x2028, 0x2029, 0x202f, + 0x205f, 0x3000, +]); + +/** Trims whitespace (as defined above) from both ends. */ +export function stripWhitespace(text: string): string { + let start = 0; + let end = text.length; + while (start < end && WHITESPACE.has(text.charCodeAt(start))) start++; + while (end > start && WHITESPACE.has(text.charCodeAt(end - 1))) end--; + return text.slice(start, end); +} + +function stripUnderscores(text: string): string { + let start = 0; + let end = text.length; + while (start < end && text[start] === '_') start++; + while (end > start && text[end - 1] === '_') end--; + return text.slice(start, end); +} + +// --------------------------------------------------------------------------------------------- +// Signal slugs +// --------------------------------------------------------------------------------------------- + +const EXACT_SLUGS = new Map([ + ['Is tor', 'tor'], + ['Is VPN', 'vpn'], + ['Is privacy relay', 'privacy_relay'], + ['Is proxy', 'proxy'], + ['Is datacenter', 'datacenter_ip'], + ['Is abuser', 'abuser'], + ['Stun is not checked', 'stun_not_checked'], + ['Stun passed (late arrival, corrected)', 'stun_late_correction'], + ['UA OS is not detected', 'os_not_detected'], + ['Network OS is not detected', 'os_not_detected'], + ['Browser timezone ≠ IP-timezone', 'timezone_mismatch'], + ['Browser VPN/Proxy', 'browser_vpn_proxy'], + ['Browser Automation', 'browser_automation'], + ['Port scan routed via proxy (antidetect browser pattern)', 'proxy_routed_antidetect'], + ['User has been banned 1H, to many requests', 'rate_limited'], +]); + +const PREFIX_SLUGS: ReadonlyArray = [ + ['Antidetect browser', 'antidetect_browser'], + ['Os_mismatch', 'os_mismatch'], + ['OS mismatch2', 'os_mismatch2'], + ['TCP handshake', 'tcp_handshake_v2'], + ['Latency test', 'ws_tcp_latency'], + ['JavaScript disabled', 'javascript_disabled'], +]; + +const STICKY_PREFIX = 'Sticky verdict: '; +const LETTER_OR_DIGIT = /^[\p{L}\p{Nd}]$/u; + +/** + * Slug for a description without an explicit mapping: the text before the first "(", + * lowercased, with spaces, "-" and "/" collapsed to "_", "≠" written as "_neq_" and any other + * punctuation dropped. + */ +export function fallbackSlug(description: string): string { + let text = stripWhitespace(description); + const paren = text.indexOf('('); + if (paren >= 0) text = stripWhitespace(text.slice(0, paren)); + let out = ''; + let previousWasSeparator = false; + for (const ch of text) { + if (ch === ' ' || ch === '-' || ch === '/') { + if (!previousWasSeparator && out.length > 0) { + out += '_'; + previousWasSeparator = true; + } + } else if (ch === '≠') { + out += '_neq_'; + previousWasSeparator = false; + } else if (LETTER_OR_DIGIT.test(ch)) { + out += ch.toLowerCase(); + previousWasSeparator = false; + } + } + const slug = stripUnderscores(out); + return slug === '' ? 'unknown' : slug; +} + +/** Signal slug for a History `score_details` description (the webhook `signals[].name`). */ +export function signalSlug(description: string): string { + const exact = EXACT_SLUGS.get(description); + if (exact !== undefined) return exact; + for (const [prefix, slug] of PREFIX_SLUGS) { + if (description.startsWith(prefix)) return slug; + } + if (description.startsWith(STICKY_PREFIX)) { + const colon = description.indexOf(':'); + const rest = + colon >= 0 && colon + 1 < description.length + ? stripWhitespace(description.slice(colon + 1)) + : description; + return fallbackSlug(rest); + } + return fallbackSlug(description); +} + +// --------------------------------------------------------------------------------------------- +// Timestamps +// --------------------------------------------------------------------------------------------- + +// History rows: "YYYY-MM-DD HH:MM:SS[.fff]" in UTC. A zone designator is accepted and ignored. +const HISTORY_TIME = + /^(\d{4})-(\d{2})-(\d{2})[ T](\d{2}):(\d{2}):(\d{2})(?:\.(\d{1,9}))?(?:Z|[+-]\d{2}:?\d{2})?$/; +// Webhooks and the Management API: RFC 3339 with up to 9 fractional digits. A single trailing +// newline is tolerated. +const RFC3339 = + /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(?:\.(\d{1,9}))?(Z|[+-]\d{2}:\d{2})\n?$/; + +function isLeapYear(year: number): boolean { + return (year % 4 === 0 && year % 100 !== 0) || year % 400 === 0; +} + +function daysInMonth(year: number, month: number): number { + if (month === 2) return isLeapYear(year) ? 29 : 28; + return [4, 6, 9, 11].includes(month) ? 30 : 31; +} + +function pad(value: number, width: number): string { + return String(value).padStart(width, '0'); +} + +/** + * Formats matched date parts as "YYYY-MM-DDTHH:MM:SS.mmmZ" after applying the offset. The + * fraction is truncated to milliseconds (never rounded). Returns null for impossible dates. + */ +function formatUtc(match: RegExpMatchArray, offsetMinutes: number): string | null { + const [year, month, day, hour, minute, second] = [1, 2, 3, 4, 5, 6].map((i) => + Number(match[i]), + ) as [number, number, number, number, number, number]; + if ( + year < 1 || + month < 1 || + month > 12 || + day < 1 || + day > daysInMonth(year, month) || + hour > 23 || + minute > 59 || + second > 59 + ) { + return null; + } + const local = new Date(0); + local.setUTCFullYear(year, month - 1, day); + local.setUTCHours(hour, minute, second, 0); + const utc = new Date(local.getTime() - offsetMinutes * 60_000); + const utcYear = utc.getUTCFullYear(); + if (utcYear < 1 || utcYear > 9999) return null; + const millis = (match[7] ?? '0').slice(0, 3).padEnd(3, '0'); + return ( + `${pad(utcYear, 4)}-${pad(utc.getUTCMonth() + 1, 2)}-${pad(utc.getUTCDate(), 2)}` + + `T${pad(utc.getUTCHours(), 2)}:${pad(utc.getUTCMinutes(), 2)}:${pad(utc.getUTCSeconds(), 2)}` + + `.${millis}Z` + ); +} + +/** History `created_at` ("YYYY-MM-DD HH:MM:SS[.fff]", UTC) to RFC 3339 with milliseconds. */ +export function parseHistoryTime(value: unknown): string | null { + if (typeof value !== 'string') return null; + const text = stripWhitespace(value); + if (text === '') return null; + const match = text.match(HISTORY_TIME); + return match ? formatUtc(match, 0) : null; +} + +/** RFC 3339 timestamp (up to 9 fractional digits, any offset) to UTC with milliseconds. */ +export function parseRfc3339(value: unknown): string | null { + if (typeof value !== 'string') return null; + const match = value.match(RFC3339); + if (!match) return null; + const zone = match[8] ?? 'Z'; + let offsetMinutes = 0; + if (zone !== 'Z') { + const sign = zone.startsWith('+') ? 1 : -1; + offsetMinutes = sign * (Number(zone.slice(1, 3)) * 60 + Number(zone.slice(4, 6))); + } + return formatUtc(match, offsetMinutes); +} + +// --------------------------------------------------------------------------------------------- +// Identification +// --------------------------------------------------------------------------------------------- + +type HistoryFlagKey = Exclude; + +const HISTORY_FLAG_COLUMNS: Readonly> = { + vpn: 'is_vpn', + privacy_relay: 'is_privacy_relay', + tor: 'is_tor', + proxy: 'is_proxy', + datacenter_ip: 'is_datacenter', + abuser: 'is_abuser', + os_mismatch: 'is_os_mismatch', + os_not_detected: 'is_os_not_detected', + timezone_mismatch: 'is_timezone_mismatch', + anti_detect_browser: 'is_antidetect', + browser_automation: 'is_browser_automation', + incognito: 'is_incognito', + search_bot: 'is_search_bot', + suspicious_paid_click: 'is_suspicious_paid_click', + javascript_disabled: 'is_js_disabled', + stun_not_checked: 'is_stun_not_checked', + check_incomplete: 'check_incomplete', +}; + +/** IP sentinel handling: trimmed, and "0.0.0.0" means no IP (""). */ +function normalizeIp(value: unknown): string { + if (typeof value !== 'string') return ''; + const ip = stripWhitespace(value); + return ip === '0.0.0.0' ? '' : ip; +} + +/** "" and null become null; other User HID values (including sentinels) are kept. */ +function normalizeUserHid(value: unknown): string | null { + if (value === undefined || value === null || value === '') return null; + if (typeof value === 'string') return value; + if (typeof value === 'number' || typeof value === 'boolean') return String(value); + return null; +} + +/** Score as sent; absent means 0, a non-numeric value becomes NaN (never a valid score). */ +function normalizeScore(obj: JsonObject, key: string): number { + if (!hasOwn(obj, key)) return 0; + const value = obj[key]; + return typeof value === 'number' ? value : Number.NaN; +} + +/** Parses the JSON-encoded `score_details` string. "" or invalid JSON gives an empty list. */ +function parseScoreDetails(value: unknown): unknown[] { + const text = isTruthy(value) ? value : '[]'; + if (typeof text !== 'string') return []; + try { + const parsed: unknown = JSON.parse(text); + return Array.isArray(parsed) ? parsed : []; + } catch { + return []; + } +} + +function ipInfo(value: unknown): IpInfo { + const obj = isPlainObject(value) ? value : {}; + return { ip: normalizeIp(own(obj, 'ip')), country: stringField(obj, 'country') }; +} + +const TRAFFIC_KEYS: ReadonlyArray = [ + 'channel', + 'referrer_domain', + 'landing_url', + 'click_id_type', + 'utm_source', + 'utm_medium', + 'utm_campaign', + 'utm_content', + 'utm_term', +]; + +/** Builds an Identification from one History API row. */ +export function fromHistoryRow(row: JsonObject): Identification { + const leakSource = stripWhitespace(stringField(row, 'webrtc_leak_source')); + let localIp: string; + let localCountry: string; + if (leakSource !== '' && leakSource !== 'none') { + localIp = normalizeIp(own(row, 'webrtc_leak_ip')); + localCountry = stringField(row, 'webrtc_leak_country'); + } else { + localIp = normalizeIp(own(row, 'web_rtc_ip')); + localCountry = stringField(row, 'web_rtc_country'); + } + const publicIp = normalizeIp(own(row, 'ip')); + + const signals: IdentificationSignal[] = []; + let ipLeakDetail = false; + for (const entry of parseScoreDetails(own(row, 'score_details'))) { + if (!isPlainObject(entry)) continue; + const description = stringField(entry, 'Description'); + if (description.startsWith('IP ≠ leakIP')) ipLeakDetail = true; + const value = own(entry, 'Value'); + if (typeof value !== 'number' || !Number.isInteger(value) || value === 0) continue; + signals.push({ name: signalSlug(description), weight: value, description }); + } + + const searchBot = isTruthy(own(row, 'is_search_bot')); + const flags = {} as DetectionFlags; + for (const key of FLAG_KEYS) { + if (key === 'browser_vpn_proxy') { + flags[key] = own(row, 'connection_type') === 'browser_vpn_proxy'; + } else if (key === 'ip_mismatch') { + flags[key] = + !searchBot && (ipLeakDetail || (publicIp !== '' && localIp !== '' && publicIp !== localIp)); + } else { + flags[key] = isTruthy(own(row, HISTORY_FLAG_COLUMNS[key])); + } + } + + const siteDomain = own(row, 'site_domain'); + let domain: string; + if (isTruthy(siteDomain)) { + domain = typeof siteDomain === 'string' ? siteDomain : ''; + } else { + domain = stringField(row, 'domain'); + } + + return { + request_id: stringField(row, 'request_id'), + visitor_id: stringField(row, 'visitor_id'), + device_id: stringField(row, 'device_id'), + session_id: stringField(row, 'session_id'), + cookie_id: stringField(row, 'cookie_id'), + user_hid: normalizeUserHid(own(row, 'user_hid')), + domain, + public_ip: { ip: publicIp, country: stringField(row, 'country') }, + local_ip: { ip: localIp, country: localCountry }, + connection_type: stringField(row, 'connection_type'), + os: stringField(row, 'os'), + browser: stringField(row, 'browser'), + device_type: stringField(row, 'device_type'), + traffic_source: { + channel: stringField(row, 'traffic_channel'), + referrer_domain: stringField(row, 'referrer_domain'), + landing_url: stringField(row, 'entry_url'), + click_id_type: stringField(row, 'click_id_type'), + utm_source: stringField(row, 'utm_source'), + utm_medium: stringField(row, 'utm_medium'), + utm_campaign: stringField(row, 'utm_campaign'), + utm_content: stringField(row, 'utm_content'), + utm_term: stringField(row, 'utm_term'), + }, + risk_score: normalizeScore(row, 'score'), + signals, + detection_flags: flags, + observed_at: parseHistoryTime(own(row, 'created_at')), + source: 'history', + raw: row, + }; +} + +/** Builds an Identification from the `data` object of an `identification.scored` webhook. */ +export function fromWebhookData(data: JsonObject): Identification { + const rawFlags = own(data, 'detection_flags'); + const flagSource = isPlainObject(rawFlags) ? rawFlags : {}; + const flags = {} as DetectionFlags; + for (const key of FLAG_KEYS) flags[key] = isTruthy(own(flagSource, key)); + + const rawTraffic = own(data, 'traffic_source'); + const trafficSource = isPlainObject(rawTraffic) ? rawTraffic : {}; + const traffic = {} as TrafficSource; + for (const key of TRAFFIC_KEYS) traffic[key] = stringField(trafficSource, key); + + const rawSignals = own(data, 'signals'); + const signals: IdentificationSignal[] = Array.isArray(rawSignals) + ? rawSignals.filter(isPlainObject).map((signal) => { + const weight = own(signal, 'weight'); + return { + name: stringField(signal, 'name'), + weight: typeof weight === 'number' ? weight : 0, + description: null, + }; + }) + : []; + + return { + request_id: stringField(data, 'request_id'), + visitor_id: stringField(data, 'visitor_id'), + device_id: stringField(data, 'device_id'), + session_id: stringField(data, 'session_id'), + cookie_id: stringField(data, 'cookie_id'), + user_hid: normalizeUserHid(own(data, 'user_hid')), + domain: stringField(data, 'domain'), + public_ip: ipInfo(own(data, 'public_ip')), + local_ip: ipInfo(own(data, 'local_ip')), + connection_type: stringField(data, 'connection_type'), + os: stringField(data, 'os'), + browser: stringField(data, 'browser'), + device_type: stringField(data, 'device_type'), + traffic_source: traffic, + risk_score: normalizeScore(data, 'risk_score'), + signals, + detection_flags: flags, + observed_at: parseRfc3339(own(data, 'observed_at')), + source: 'webhook', + raw: data, + }; +} + +/** Builds a DomainProfile from a Management API profile response (PascalCase keys). */ +export function profileFromResponse(body: JsonObject): DomainProfile { + const weight = own(body, 'Weight'); + return { + domain: stringField(body, 'Domain'), + remaining_identifications: typeof weight === 'number' && Number.isFinite(weight) ? weight : 0, + public_key_masked: stringField(body, 'PublicKey'), + secret_key_masked: stringField(body, 'Secret'), + created_at: parseRfc3339(own(body, 'CreatedAt')), + raw: body, + }; +} diff --git a/src/risk.ts b/src/risk.ts new file mode 100644 index 0000000..1e756e0 --- /dev/null +++ b/src/risk.ts @@ -0,0 +1,164 @@ +import { FLAG_KEYS, NIL_UUID, RISK_BAND_NAMES, type FlagKey } from './constants.js'; +import { ValidationError } from './errors.js'; +import type { DetectionFlags, Identification, RiskBand } from './types.js'; + +/** + * Risk band for a Risk Score: trusted 0-29, suspicious 30-59, dangerous 60-100. + * A score above 100 (the 999 marker) is not a score: it returns "rate_limited". + */ +export function riskBand(score: number): RiskBand | 'rate_limited' { + if (typeof score !== 'number' || Number.isNaN(score)) { + throw new ValidationError('riskBand() needs a numeric Risk Score.'); + } + if (score > 100) return 'rate_limited'; + if (score >= 60) return 'dangerous'; + if (score >= 30) return 'suspicious'; + return 'trusted'; +} + +/** True for the rate-limit marker (a Risk Score above 100, sent as 999). */ +export function isRateLimited(score: number): boolean { + return typeof score === 'number' && score > 100; +} + +/** Why an identification did not pass `evaluateIdentification`. */ +export type EvaluationReason = + | 'missing' + | 'replayed' + | 'stale' + | 'rate_limited' + | 'no_device_signals' + | 'blocked_flag' + | 'blocked_band'; + +/** Result of `evaluateIdentification`. */ +export interface Evaluation { + /** True when every check passed. */ + ok: boolean; + /** The first check that failed, or null when `ok` is true. */ + reason: EvaluationReason | null; + /** Band of the Risk Score ("rate_limited" for the 999 marker), or null when the identification is missing. */ + band: RiskBand | 'rate_limited' | null; + /** The detection flag that failed the check, when `reason` is "blocked_flag". */ + flag?: keyof DetectionFlags; +} + +/** Options of `evaluateIdentification`. The defaults are a starting point to tune. */ +export interface EvaluateOptions { + /** + * Maximum age of the identification in milliseconds, measured from `observed_at`. Default + * 300 000 (5 minutes). `Infinity` skips the freshness check. + */ + maxAge?: number | undefined; + /** Current time (Date or epoch milliseconds). Default: now. */ + now?: Date | number | undefined; + /** Bands that fail the check. Default ["dangerous"]. */ + blockBands?: readonly RiskBand[] | undefined; + /** Detection flags that fail the check. Default ["browser_automation", "javascript_disabled"]. */ + blockFlags?: readonly (keyof DetectionFlags)[] | undefined; + /** + * Returns true when this request ID was already used for a protected action. The SDK keeps + * no state: claim the request ID in your own store first (an atomic insert-if-absent) and + * return the result here. Must return a boolean synchronously. + */ + isReplay?: ((requestId: string) => boolean) | undefined; +} + +const DEFAULT_MAX_AGE_MS = 300_000; +const DEFAULT_BLOCK_BANDS: readonly RiskBand[] = ['dangerous']; +const DEFAULT_BLOCK_FLAGS: readonly FlagKey[] = ['browser_automation', 'javascript_disabled']; + +function resolveNow(now: Date | number | undefined): number { + if (now === undefined) return Date.now(); + const value = now instanceof Date ? now.getTime() : now; + if (typeof value !== 'number' || !Number.isFinite(value)) { + throw new ValidationError('now must be a valid Date or a number of epoch milliseconds.'); + } + return value; +} + +function isThenable(value: unknown): boolean { + return ( + (typeof value === 'object' || typeof value === 'function') && + value !== null && + typeof (value as { then?: unknown }).then === 'function' + ); +} + +/** + * Applies a reusable guard policy to an identification. Checks run in this order and the first + * failure wins: missing identification, replayed request ID (`isReplay`), older than `maxAge`, + * rate-limit marker, all-zero device ID, a flag in `blockFlags`, a band in `blockBands`. + */ +export function evaluateIdentification( + identification: Identification | null | undefined, + options: EvaluateOptions = {}, +): Evaluation { + const maxAge = options.maxAge ?? DEFAULT_MAX_AGE_MS; + if (typeof maxAge !== 'number' || Number.isNaN(maxAge) || maxAge < 0) { + throw new ValidationError( + 'maxAge must be a number of milliseconds greater than or equal to 0, or Infinity to skip the freshness check.', + ); + } + const nowMs = resolveNow(options.now); + const blockBands = options.blockBands ?? DEFAULT_BLOCK_BANDS; + for (const band of blockBands) { + if (!RISK_BAND_NAMES.includes(band)) { + throw new ValidationError( + `Unknown risk band in blockBands. Use one of: ${RISK_BAND_NAMES.join(', ')}.`, + ); + } + } + const blockFlags = options.blockFlags ?? DEFAULT_BLOCK_FLAGS; + for (const flag of blockFlags) { + if (!FLAG_KEYS.includes(flag)) { + throw new ValidationError( + `Unknown detection flag in blockFlags. Use one of: ${FLAG_KEYS.join(', ')}.`, + ); + } + } + + if (identification === null || identification === undefined) { + return { ok: false, reason: 'missing', band: null }; + } + + const band = riskBand(identification.risk_score); + + if (options.isReplay) { + const replayed: unknown = options.isReplay(identification.request_id); + if (isThenable(replayed)) { + throw new ValidationError( + 'isReplay must return a boolean synchronously. Claim the request ID in your store first (an atomic insert-if-absent), then pass the result, for example isReplay: () => !claimed.', + ); + } + if (replayed) return { ok: false, reason: 'replayed', band }; + } + + if (maxAge !== Number.POSITIVE_INFINITY) { + const observedMs = + identification.observed_at === null ? Number.NaN : Date.parse(identification.observed_at); + if (Number.isNaN(observedMs) || nowMs - observedMs > maxAge) { + return { ok: false, reason: 'stale', band }; + } + } + + if (isRateLimited(identification.risk_score)) { + return { ok: false, reason: 'rate_limited', band }; + } + + if (!identification.device_id || identification.device_id === NIL_UUID) { + return { ok: false, reason: 'no_device_signals', band }; + } + + for (const flag of blockFlags) { + if (identification.detection_flags[flag]) { + return { ok: false, reason: 'blocked_flag', band, flag }; + } + } + + if (band !== 'rate_limited' && blockBands.includes(band)) { + return { ok: false, reason: 'blocked_band', band }; + } + + return { ok: true, reason: null, band }; +} diff --git a/src/runtime.ts b/src/runtime.ts new file mode 100644 index 0000000..b67b04e --- /dev/null +++ b/src/runtime.ts @@ -0,0 +1,107 @@ +import { VERSION } from './version.js'; + +interface RuntimeGlobals { + Bun?: { version?: unknown }; + Deno?: { version?: { deno?: unknown } }; + EdgeRuntime?: unknown; + navigator?: { userAgent?: unknown }; + process?: { versions?: { node?: unknown } }; +} + +export function detectRuntime(): string | null { + const g = globalThis as unknown as RuntimeGlobals; + if (typeof g.Bun?.version === 'string') return `bun/${g.Bun.version}`; + if (typeof g.Deno?.version?.deno === 'string') return `deno/${g.Deno.version.deno}`; + if (typeof g.EdgeRuntime === 'string') return 'edge-runtime'; + if (g.navigator?.userAgent === 'Cloudflare-Workers') return 'workerd'; + if (typeof g.process?.versions?.node === 'string') return `node/${g.process.versions.node}`; + return null; +} + +let cachedUserAgent: string | undefined; + +/** "shieldlabs-node/" followed by the runtime, for example "node/20.11.1". */ +export function userAgent(): string { + if (cachedUserAgent === undefined) { + const runtime = detectRuntime(); + cachedUserAgent = runtime + ? `shieldlabs-node/${VERSION} ${runtime}` + : `shieldlabs-node/${VERSION}`; + } + return cachedUserAgent; +} + +/** The reason an aborted signal carries, or a generic AbortError. */ +export function abortReason(signal: AbortSignal): unknown { + if (signal.reason !== undefined) return signal.reason; + const error = new Error('The operation was aborted.'); + error.name = 'AbortError'; + return error; +} + +export function throwIfAborted(signal: AbortSignal | undefined): void { + if (signal?.aborted) throw abortReason(signal); +} + +/** Longest delay of one timer: runtimes fire a timer with a longer delay almost at once. */ +const MAX_TIMER_DELAY_MS = 2_147_483_647; + +/** Waits `ms` milliseconds; rejects with the abort reason as soon as `signal` aborts. */ +export function sleep(ms: number, signal?: AbortSignal): Promise { + return new Promise((resolve, reject) => { + if (signal?.aborted) { + reject(abortReason(signal)); + return; + } + let timer: ReturnType | undefined; + const onAbort = (): void => { + clearTimeout(timer); + reject(abortReason(signal as AbortSignal)); + }; + // A wait longer than one timer allows runs as a chain of timers. + const wait = (left: number): void => { + timer = setTimeout( + () => { + if (left > MAX_TIMER_DELAY_MS) { + wait(left - MAX_TIMER_DELAY_MS); + return; + } + signal?.removeEventListener('abort', onAbort); + resolve(); + }, + Math.min(left, MAX_TIMER_DELAY_MS), + ); + }; + wait(ms); + signal?.addEventListener('abort', onAbort, { once: true }); + }); +} + +const warned = new Set(); +const MAX_WARNINGS = 32; + +/** Prints a warning once per process for each key. Never include secrets in `message`. */ +export function warnOnce(key: string, message: string): void { + if (warned.has(key) || warned.size >= MAX_WARNINGS) return; + warned.add(key); + console.warn(`[shieldlabs] ${message}`); +} + +/** + * Warns once when a client is created in a web page (a global window with a document), where its + * key is visible to every visitor. Servers, workers and edge runtimes stay silent. + */ +export function warnIfBrowserPage(client: string, key: string): void { + const g = globalThis as { window?: unknown; document?: unknown }; + if (g.window !== undefined && g.document !== undefined) { + warnOnce( + `browser-page:${client}`, + `${client} was created in a web page, where its ${key} is visible to every visitor. Use @shieldlabs-ai/node on your server only; in the page, use the browser SDK (@shieldlabs-ai/js).`, + ); + } +} + +/** Test hook: forget which warnings were printed. */ +export function resetWarnings(): void { + warned.clear(); +} diff --git a/src/types.ts b/src/types.ts index 89d5364..93266c4 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,19 +1,34 @@ -/** Webhook schema version currently emitted by Shield.Core. */ -export const WEBHOOK_SCHEMA_VERSION = "2026-06-01" as const; +/** Identifier types the History API can search by. */ +export type LookupType = + 'ip' | 'user_hid' | 'visitor_id' | 'request_id' | 'device_id' | 'session_id' | 'cookie_id'; -export type WebhookEventType = "identification.scored" | "webhook.ping"; +/** The three risk bands: trusted 0-29, suspicious 30-59, dangerous 60-100. */ +export type RiskBand = 'trusted' | 'suspicious' | 'dangerous'; -export interface WebhookIPAddress { +/** + * Connection type of an identification. Known values are listed; unknown strings are kept as sent. + */ +export type ConnectionType = + | 'direct' + | 'mobile' + | 'vpn' + | 'proxy' + | 'tor' + | 'privacy_relay' + | 'browser_vpn_proxy' + | 'unknown' + | (string & {}); + +/** An IP address with its country. */ +export interface IpInfo { + /** Dotted IPv4 address, or "" when none is known. */ ip: string; + /** English country name (for example "Germany"), or "" when unknown. */ country: string; } -export interface WebhookSignal { - name: string; - weight: number; -} - -export interface WebhookTrafficSource { +/** Where the visit came from. Every value is a string, "" when absent. */ +export interface TrafficSource { channel: string; referrer_domain: string; landing_url: string; @@ -25,7 +40,18 @@ export interface WebhookTrafficSource { utm_term: string; } -export interface WebhookDetectionFlags { +/** One weighted risk signal behind the Risk Score. Display and log these; branch on detection_flags. */ +export interface IdentificationSignal { + /** Signal slug, for example "vpn" or "antidetect_browser". An open set: see SIGNALS for known values. */ + name: string; + /** Weight of the signal. Can be negative. Never sum weights yourself. */ + weight: number; + /** Server description of the signal (History API rows only; null for webhooks). */ + description: string | null; +} + +/** The 19 stable detection flags. A flag the server did not send is false. */ +export interface DetectionFlags { vpn: boolean; privacy_relay: boolean; browser_vpn_proxy: boolean; @@ -43,34 +69,146 @@ export interface WebhookDetectionFlags { search_bot: boolean; suspicious_paid_click: boolean; javascript_disabled: boolean; - stun_request_seen: boolean; stun_not_checked: boolean; + check_incomplete: boolean; } -export interface WebhookScoredData { +/** + * One identification (one run of the ShieldLabs agent in one browser), normalized from a + * webhook `data` object or a History API row. Property names follow the webhook JSON. + */ +export interface Identification { + /** UUID of the identification, created in the browser. */ request_id: string; + /** Server-side visitor identifier (sticky to the device). */ visitor_id: string; + /** Server-side device identifier. The all-zero UUID means no usable device signals. */ device_id: string; + /** One visit on one origin. */ session_id: string; + /** First-party browser identifier kept by the agent. */ cookie_id: string; + /** Your User HID as sent by the browser: "anonymous" for anonymous checks, null when it was empty. */ user_hid: string | null; + /** Registered domain of the site. */ domain: string; - public_ip: WebhookIPAddress; - local_ip: WebhookIPAddress; - connection_type: string; + public_ip: IpInfo; + local_ip: IpInfo; + connection_type: ConnectionType; os: string; browser: string; + /** "desktop", "mobile", "tablet" or "unknown". */ device_type: string; - traffic_source: WebhookTrafficSource; + traffic_source: TrafficSource; + /** Integer 0-100. A value above 100 (999) is a rate-limit marker, never a score. */ risk_score: number; - signals: WebhookSignal[]; - detection_flags: WebhookDetectionFlags; - observed_at: string; + signals: IdentificationSignal[]; + detection_flags: DetectionFlags; + /** + * When the identification was observed, as an RFC 3339 UTC string with milliseconds + * (for example "2026-09-30T12:34:56.123Z"). null only when the server sent a timestamp + * that could not be parsed. + */ + observed_at: string | null; + /** Which payload this identification was built from. */ + source: 'webhook' | 'history'; + /** The original webhook `data` object or History row, including fields this model omits. */ + raw: Record; +} + +/** One page of History API results. */ +export interface HistoryPage { + /** Identifications, newest first. */ + data: Identification[]; + /** Total number of identifications that match the lookup. */ + total: number; +} + +/** Domain profile from the Management API. */ +export interface DomainProfile { + /** The registered domain. */ + domain: string; + /** Remaining included identifications. Negative when the account is over its included volume. */ + remaining_identifications: number; + /** Public Key with every character except the last 4 replaced by "*". */ + public_key_masked: string; + /** Secret Key with every character except the last 4 replaced by "*". */ + secret_key_masked: string; + /** When the domain was created, as an RFC 3339 UTC string with milliseconds, or null. */ + created_at: string | null; + /** The original response object. */ + raw: Record; +} + +// A declared (never emitted) string enum: unlike a plain string, its type does not overlap with +// the known event type literals, so a switch on event_type narrows them exactly. +declare enum UnknownEventTypeMarker { + /** Placeholder member. Never compare against it. */ + Unknown = 'unknown', } -export interface WebhookEvent { - event_type: WebhookEventType; - schema_version: typeof WEBHOOK_SCHEMA_VERSION | string; +/** + * Type of `event_type` for an event this SDK version does not model. At runtime the value is the + * plain string the server sent (for example "identification.refined"). This type only exists so + * that `switch (event.event_type)` narrows the known event types exactly; it is assignable to + * `string`, and `String(event.event_type)` compares it with other strings. A type only: there is + * no runtime value to import. + */ +export type UnknownEventType = UnknownEventTypeMarker; + +/** A verified `identification.scored` delivery. */ +export interface IdentificationScoredEvent { + event_type: 'identification.scored'; + schema_version: string; + /** Envelope timestamp as sent (RFC 3339). */ + created_at: string; + data: Identification; + /** The parsed envelope as received. */ + raw: Record; +} + +/** A verified `webhook.ping` delivery (sent by Verify in the analytics dashboard). */ +export interface WebhookPingEvent { + event_type: 'webhook.ping'; + schema_version: string; created_at: string; - data?: WebhookScoredData; + raw: Record; +} + +/** A verified delivery with an event type this SDK version does not model. */ +export interface UnknownWebhookEvent { + event_type: UnknownEventType; + schema_version: string; + created_at: string; + raw: Record; +} + +/** Every event `webhooks.constructEvent` can return. */ +export type WebhookEvent = IdentificationScoredEvent | WebhookPingEvent | UnknownWebhookEvent; + +/** Raw webhook body: the exact bytes received, or the same bytes decoded as UTF-8. */ +export type WebhookPayload = string | Uint8Array | ArrayBuffer; + +/** Value of the X-Shield-Signature header, as your framework exposes it. */ +export type SignatureHeader = string | readonly string[] | null | undefined; + +/** One endpoint signing secret, or several while you rotate secrets. */ +export type WebhookSecret = string | readonly string[]; + +/** Minimal response shape the SDK needs from a fetch implementation. */ +export interface FetchResponseLike { + status: number; + ok: boolean; + headers: { get(name: string): string | null }; + text(): Promise; +} + +/** Request options the SDK passes to a fetch implementation. */ +export interface FetchRequestInit { + method: 'GET'; + headers: Record; + signal: AbortSignal; } + +/** A fetch-compatible function (the global `fetch` satisfies it). */ +export type FetchLike = (url: string, init: FetchRequestInit) => Promise; diff --git a/src/validation.ts b/src/validation.ts new file mode 100644 index 0000000..472dcd2 --- /dev/null +++ b/src/validation.ts @@ -0,0 +1,166 @@ +import { LOOKUP_TYPES } from './constants.js'; +import { ValidationError } from './errors.js'; +import type { LookupType } from './types.js'; + +const UUID = /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/; +const IPV4_OCTET = '(?:25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)'; +const IPV4 = new RegExp(`^${IPV4_OCTET}(?:\\.${IPV4_OCTET}){3}$`); + +export interface Lookup { + type: LookupType; + value: string; + /** The value escaped as one URL path segment. */ + segment: string; +} + +function describe(value: unknown): string { + const text = typeof value === 'string' ? JSON.stringify(value) : String(value); + return text.length > 40 ? `${text.slice(0, 37)}...` : text; +} + +// Escapes that canonical path escaping leaves as plain characters: $ & + , : ; = @ +const PLAIN_IN_PATH = /%(?:24|26|2B|2C|3A|3B|3D|40)/g; +// Characters encodeURIComponent leaves as they are but canonical path escaping escapes. +const ESCAPED_IN_PATH = /[!'()*]/g; + +/** + * Escapes a User HID as one URL path segment in canonical form: A-Z, a-z, 0-9, "-", ".", "_", + * "~" and $ & + , : ; = @ stay as they are, every other character becomes uppercase %XX of its + * UTF-8 bytes. The History API decodes the value only when the path is escaped exactly this way + * and otherwise compares the escaped text, which finds nothing. + */ +export function encodePathSegment(value: string): string { + let escaped: string; + try { + escaped = encodeURIComponent(value); + } catch { + throw new ValidationError( + 'user_hid must be valid Unicode text (it has an unpaired surrogate).', + ); + } + return escaped + .replace(PLAIN_IN_PATH, (match) => decodeURIComponent(match)) + .replace(ESCAPED_IN_PATH, (char) => `%${char.charCodeAt(0).toString(16).toUpperCase()}`); +} + +/** + * Validates a History lookup before anything is sent. The server does not validate: an unknown + * type returns unfiltered rows and a malformed UUID or IP returns a 500. + */ +export function validateLookup(type: unknown, value: unknown): Lookup { + if (typeof type !== 'string' || !LOOKUP_TYPES.includes(type as LookupType)) { + throw new ValidationError( + `Unknown lookup type ${describe(type)}. Use one of: ${LOOKUP_TYPES.join(', ')}.`, + ); + } + const lookupType = type as LookupType; + if (typeof value !== 'string') { + throw new ValidationError(`The ${lookupType} value must be a string.`); + } + if (lookupType === 'user_hid') { + if (value === '') throw new ValidationError('user_hid must be a non-empty string.'); + if (value === '.' || value === '..') { + throw new ValidationError( + 'user_hid cannot be "." or "..": a URL path segment cannot carry it.', + ); + } + if (value.includes('/')) { + throw new ValidationError( + 'user_hid cannot contain "/": the History API cannot search a value with a slash. Use URL-safe User HIDs, such as the 64 hex characters of userHid().', + ); + } + return { type: lookupType, value, segment: encodePathSegment(value) }; + } + if (lookupType === 'ip') { + if (value.includes(':')) { + throw new ValidationError( + 'IPv6 addresses cannot be searched: the History API stores IPv4 addresses only.', + ); + } + if (!IPV4.test(value)) { + throw new ValidationError('ip must be a dotted IPv4 address such as 203.0.113.24.'); + } + return { type: lookupType, value, segment: value }; + } + if (!UUID.test(value)) { + throw new ValidationError(`${lookupType} must be a UUID (8-4-4-4-12 hexadecimal digits).`); + } + const uuid = value.toLowerCase(); + return { type: lookupType, value: uuid, segment: uuid }; +} + +/** A request ID must be a UUID; it is sent lowercase. */ +export function validateRequestId(requestId: unknown): string { + if (typeof requestId !== 'string' || !UUID.test(requestId)) { + throw new ValidationError('requestId must be a UUID (8-4-4-4-12 hexadecimal digits).'); + } + return requestId.toLowerCase(); +} + +/** A boolean option. */ +export function validateBoolean(value: unknown, name: string): boolean { + if (typeof value !== 'boolean') throw new ValidationError(`${name} must be a boolean.`); + return value; +} + +/** Integer page size from 1 to 100 (the server silently turns other values into 20). */ +export function validatePageSize(value: unknown, name: string): number { + if (typeof value !== 'number' || !Number.isInteger(value) || value < 1 || value > 100) { + throw new ValidationError(`${name} must be an integer from 1 to 100.`); + } + return value; +} + +/** Integer greater than or equal to 0. */ +export function validateNonNegativeInteger(value: unknown, name: string): number { + if (typeof value !== 'number' || !Number.isSafeInteger(value) || value < 0) { + throw new ValidationError(`${name} must be an integer greater than or equal to 0.`); + } + return value; +} + +/** Finite number of milliseconds, greater than 0 (or greater than or equal to 0 when allowZero). */ +export function validateMilliseconds(value: unknown, name: string, allowZero = false): number { + if ( + typeof value !== 'number' || + !Number.isFinite(value) || + value < 0 || + (!allowZero && value === 0) + ) { + const bound = allowZero ? 'greater than or equal to 0' : 'greater than 0'; + throw new ValidationError(`${name} must be a number of milliseconds ${bound}.`); + } + return value; +} + +const HEADER_SAFE = /^[\x21-\x7E]+$/; + +/** + * Throws when `text` cannot be sent as an HTTP header value. The check runs before any request, + * because the errors of the HTTP layer can echo the rejected value; the message never includes it. + */ +export function requireHeaderSafe(text: string, name: string): void { + if (!HEADER_SAFE.test(text)) { + throw new ValidationError( + `${name} contains characters that cannot be sent in an HTTP header (only visible ASCII characters are allowed).`, + ); + } +} + +/** True when every character is ASCII. */ +export function isAscii(text: string): boolean { + for (let index = 0; index < text.length; index++) { + if (text.charCodeAt(index) > 0x7f) return false; + } + return true; +} + +/** A non-empty string of visible ASCII characters (after trimming), returned trimmed. */ +export function validateCredential(value: unknown, name: string): string { + if (typeof value !== 'string' || value.trim() === '') { + throw new ValidationError(`${name} is required and must be a non-empty string.`); + } + const credential = value.trim(); + requireHeaderSafe(credential, name); + return credential; +} diff --git a/src/verifyWebhook.test.ts b/src/verifyWebhook.test.ts deleted file mode 100644 index ae363f1..0000000 --- a/src/verifyWebhook.test.ts +++ /dev/null @@ -1,46 +0,0 @@ -import assert from "node:assert/strict"; -import { createHmac } from "node:crypto"; -import { describe, it } from "node:test"; - -import { verifyWebhook } from "./verifyWebhook.js"; - -const SECRET = "whsec_test_secret"; -const BODY = Buffer.from( - JSON.stringify({ - event_type: "webhook.ping", - schema_version: "2026-06-01", - created_at: "2026-06-26T14:20:42Z", - }), -); - -function sign(secret: string, body: Buffer): string { - return "sha256=" + createHmac("sha256", secret).update(body).digest("hex"); -} - -describe("verifyWebhook", () => { - it("accepts a valid signature", () => { - assert.equal(verifyWebhook(BODY, sign(SECRET, BODY), SECRET), true); - }); - - it("accepts a string body matching the signed bytes", () => { - const text = BODY.toString("utf8"); - assert.equal(verifyWebhook(text, sign(SECRET, Buffer.from(text)), SECRET), true); - }); - - it("rejects a wrong secret", () => { - assert.equal(verifyWebhook(BODY, sign(SECRET, BODY), "other"), false); - }); - - it("rejects a tampered body", () => { - const tampered = Buffer.from(BODY.toString("utf8") + " "); - assert.equal(verifyWebhook(tampered, sign(SECRET, BODY), SECRET), false); - }); - - it("rejects a missing header", () => { - assert.equal(verifyWebhook(BODY, "", SECRET), false); - }); - - it("rejects a truncated signature (length mismatch)", () => { - assert.equal(verifyWebhook(BODY, "sha256=ab", SECRET), false); - }); -}); diff --git a/src/verifyWebhook.ts b/src/verifyWebhook.ts deleted file mode 100644 index 4915ba6..0000000 --- a/src/verifyWebhook.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { createHmac, timingSafeEqual } from "node:crypto"; - -/** - * Verify an inbound ShieldLabs webhook signature. - * - * Compares `X-Shield-Signature` to `sha256=` + hex(HMAC-SHA256(secret, rawBody)) - * using a constant-time comparison. Pass the raw request body bytes exactly as - * received — re-serializing parsed JSON will fail verification. - */ -export function verifyWebhook( - rawBody: string | Buffer | Uint8Array, - signatureHeader: string, - secret: string, -): boolean { - if (!signatureHeader || !secret) { - return false; - } - - const expected = - "sha256=" + - createHmac("sha256", secret).update(rawBody).digest("hex"); - - const a = Buffer.from(signatureHeader); - const b = Buffer.from(expected); - if (a.length !== b.length) { - return false; - } - return timingSafeEqual(a, b); -} diff --git a/src/version.ts b/src/version.ts new file mode 100644 index 0000000..202bc3d --- /dev/null +++ b/src/version.ts @@ -0,0 +1,2 @@ +/** Version of this SDK. Sent in the User-Agent header of every request. */ +export const VERSION = '1.0.0'; diff --git a/src/webhooks.ts b/src/webhooks.ts new file mode 100644 index 0000000..8e224da --- /dev/null +++ b/src/webhooks.ts @@ -0,0 +1,321 @@ +import { KNOWN_SCHEMA_VERSION } from './constants.js'; +import { + ShieldLabsError, + SignatureVerificationError, + ValidationError, + WebhookParseError, +} from './errors.js'; +import { fromWebhookData, isPlainObject } from './normalize.js'; +import { warnOnce } from './runtime.js'; +import type { + SignatureHeader, + UnknownEventType, + WebhookEvent, + WebhookPayload, + WebhookSecret, +} from './types.js'; + +/** Webhook signature verification and typed event parsing. */ +export interface Webhooks { + /** + * True when `signatureHeader` (X-Shield-Signature) is a valid signature of `payload` for any + * of the given secrets. Returns false for a malformed header, an empty secret or a payload that + * is not raw bytes or a string. Needs node:crypto: in edge runtimes use `verifySignatureAsync`. + */ + verifySignature( + payload: WebhookPayload, + signatureHeader: SignatureHeader, + secret: WebhookSecret, + ): boolean; + /** + * Verifies the signature, then parses the body into a typed event. + * Throws SignatureVerificationError or WebhookParseError. + * Needs node:crypto: in edge runtimes use `constructEventAsync`. + */ + constructEvent( + payload: WebhookPayload, + signatureHeader: SignatureHeader, + secret: WebhookSecret, + ): WebhookEvent; + /** Same as `verifySignature`, using WebCrypto where node:crypto is not available. */ + verifySignatureAsync( + payload: WebhookPayload, + signatureHeader: SignatureHeader, + secret: WebhookSecret, + ): Promise; + /** Same as `constructEvent`, using WebCrypto where node:crypto is not available. */ + constructEventAsync( + payload: WebhookPayload, + signatureHeader: SignatureHeader, + secret: WebhookSecret, + ): Promise; +} + +/** HMAC-SHA256 over `message` with the UTF-8 bytes of `key`. */ +export type HmacSync = (key: string, message: Uint8Array) => Uint8Array; +export type HmacAsync = (key: string, message: Uint8Array) => Promise; + +export interface CryptoBackend { + /** Synchronous HMAC, or null where node:crypto is not loaded. */ + hmacSync: HmacSync | null; + hmacAsync: HmacAsync; + /** Constant-time comparison of two digests. */ + equal?: (a: Uint8Array, b: Uint8Array) => boolean; +} + +const SIGNATURE_PREFIX = 'sha256='; +const HEX_DIGEST = /^[0-9a-fA-F]{64}$/; +const MISMATCH = + 'The webhook signature does not match the payload. Check the endpoint signing secret (including its whsec_ prefix) and verify the raw body bytes, not re-serialized JSON.'; + +const encoder = new TextEncoder(); + +/** Lowercase hex digest from an X-Shield-Signature header value, or null when malformed. */ +export function parseSignatureHeader(header: unknown): string | null { + let value = header; + if (Array.isArray(value)) { + if (value.length !== 1) return null; + value = value[0]; + } + if (typeof value !== 'string') return null; + const trimmed = value.trim(); + if (!trimmed.startsWith(SIGNATURE_PREFIX)) return null; + const digest = trimmed.slice(SIGNATURE_PREFIX.length); + return HEX_DIGEST.test(digest) ? digest.toLowerCase() : null; +} + +/** The non-empty secrets of one secret or a list of secrets. */ +export function secretList(secret: unknown): string[] { + const list: unknown[] = Array.isArray(secret) ? secret : [secret]; + return list.filter((item): item is string => typeof item === 'string' && item !== ''); +} + +function isArrayBuffer(value: unknown): value is ArrayBuffer { + return ( + value instanceof ArrayBuffer || Object.prototype.toString.call(value) === '[object ArrayBuffer]' + ); +} + +/** The payload as bytes, or null when it is neither a string nor binary data. */ +export function payloadBytes(payload: unknown): Uint8Array | null { + if (typeof payload === 'string') return encoder.encode(payload); + if (payload instanceof Uint8Array) return payload; + if (ArrayBuffer.isView(payload)) { + return new Uint8Array(payload.buffer, payload.byteOffset, payload.byteLength); + } + if (isArrayBuffer(payload)) return new Uint8Array(payload); + return null; +} + +export function hexToBytes(hex: string): Uint8Array { + const bytes = new Uint8Array(hex.length / 2); + for (let i = 0; i < bytes.length; i++) { + bytes[i] = Number.parseInt(hex.slice(i * 2, i * 2 + 2), 16); + } + return bytes; +} + +export function bytesToHex(bytes: Uint8Array): string { + let hex = ''; + for (const byte of bytes) hex += byte.toString(16).padStart(2, '0'); + return hex; +} + +/** Compares two byte arrays without an early exit. */ +export function constantTimeEqual(a: Uint8Array, b: Uint8Array): boolean { + if (a.length !== b.length) return false; + let diff = 0; + for (let i = 0; i < a.length; i++) diff |= (a[i] as number) ^ (b[i] as number); + return diff === 0; +} + +type Prepared = + | { ok: true; bytes: Uint8Array; expected: Uint8Array; secrets: string[] } + | { ok: false; reason: string }; + +function prepare(payload: unknown, header: unknown, secret: unknown): Prepared { + const bytes = payloadBytes(payload); + if (bytes === null) { + return { + ok: false, + reason: + 'The payload must be the raw request body (a string, Uint8Array or ArrayBuffer). A parsed object cannot be verified because re-serializing changes the bytes.', + }; + } + const digest = parseSignatureHeader(header); + if (digest === null) { + return { + ok: false, + reason: + 'The X-Shield-Signature header is missing or malformed (expected sha256=<64 hex characters>).', + }; + } + const secrets = secretList(secret); + if (secrets.length === 0) { + return { ok: false, reason: 'No webhook signing secret was provided.' }; + } + return { ok: true, bytes, expected: hexToBytes(digest), secrets }; +} + +const decoder = new TextDecoder('utf-8'); + +function preview(text: string): string { + return text.length > 40 ? `${text.slice(0, 37)}...` : text; +} + +/** Parses verified webhook bytes into a typed event. */ +export function parseEvent(bytes: Uint8Array): WebhookEvent { + const text = decoder.decode(bytes); + let parsed: unknown; + try { + parsed = JSON.parse(text); + } catch (error) { + throw new WebhookParseError('The webhook body is not valid JSON.', { cause: error }); + } + if (!isPlainObject(parsed)) { + throw new WebhookParseError('The webhook body is not a JSON object.'); + } + const eventType = parsed.event_type; + if (typeof eventType !== 'string' || eventType === '') { + throw new WebhookParseError('The webhook body has no event_type.'); + } + const schemaVersion = typeof parsed.schema_version === 'string' ? parsed.schema_version : ''; + const createdAt = typeof parsed.created_at === 'string' ? parsed.created_at : ''; + if (schemaVersion !== KNOWN_SCHEMA_VERSION) { + warnOnce( + `schema-version:${schemaVersion}`, + `Received a webhook with schema_version "${preview(schemaVersion)}" while this SDK version targets "${KNOWN_SCHEMA_VERSION}". The event was parsed; update @shieldlabs-ai/node for typed support of newer fields.`, + ); + } + if (eventType === 'identification.scored') { + if (!isPlainObject(parsed.data)) { + throw new WebhookParseError('The identification.scored event has no data object.'); + } + return { + event_type: 'identification.scored', + schema_version: schemaVersion, + created_at: createdAt, + data: fromWebhookData(parsed.data), + raw: parsed, + }; + } + if (eventType === 'webhook.ping') { + return { + event_type: 'webhook.ping', + schema_version: schemaVersion, + created_at: createdAt, + raw: parsed, + }; + } + return { + event_type: eventType as UnknownEventType, + schema_version: schemaVersion, + created_at: createdAt, + raw: parsed, + }; +} + +export function syncUnavailable(fn: string, alternative: string): ShieldLabsError { + return new ShieldLabsError( + `${fn}() is synchronous and needs node:crypto, which the edge build of @shieldlabs-ai/node does not load. Use await ${alternative}() instead.`, + ); +} + +/** Builds the `webhooks` helpers on top of a crypto backend. */ +export function createWebhooks(backend: CryptoBackend): Webhooks { + const equal = backend.equal ?? constantTimeEqual; + + function verifySync( + payload: unknown, + header: unknown, + secret: unknown, + hmac: HmacSync, + ): Prepared { + const prepared = prepare(payload, header, secret); + if (!prepared.ok) return prepared; + for (const key of prepared.secrets) { + if (equal(hmac(key, prepared.bytes), prepared.expected)) return prepared; + } + return { ok: false, reason: MISMATCH }; + } + + async function verifyAsync( + payload: unknown, + header: unknown, + secret: unknown, + ): Promise { + const prepared = prepare(payload, header, secret); + if (!prepared.ok) return prepared; + for (const key of prepared.secrets) { + if (equal(await backend.hmacAsync(key, prepared.bytes), prepared.expected)) return prepared; + } + return { ok: false, reason: MISMATCH }; + } + + function requireSync(fn: string, alternative: string): HmacSync { + if (backend.hmacSync === null) throw syncUnavailable(fn, alternative); + return backend.hmacSync; + } + + return Object.freeze({ + verifySignature( + payload: WebhookPayload, + signatureHeader: SignatureHeader, + secret: WebhookSecret, + ): boolean { + const hmac = requireSync('webhooks.verifySignature', 'webhooks.verifySignatureAsync'); + return verifySync(payload, signatureHeader, secret, hmac).ok; + }, + constructEvent( + payload: WebhookPayload, + signatureHeader: SignatureHeader, + secret: WebhookSecret, + ): WebhookEvent { + const hmac = requireSync('webhooks.constructEvent', 'webhooks.constructEventAsync'); + const result = verifySync(payload, signatureHeader, secret, hmac); + if (!result.ok) throw new SignatureVerificationError(result.reason); + return parseEvent(result.bytes); + }, + async verifySignatureAsync( + payload: WebhookPayload, + signatureHeader: SignatureHeader, + secret: WebhookSecret, + ): Promise { + return (await verifyAsync(payload, signatureHeader, secret)).ok; + }, + async constructEventAsync( + payload: WebhookPayload, + signatureHeader: SignatureHeader, + secret: WebhookSecret, + ): Promise { + const result = await verifyAsync(payload, signatureHeader, secret); + if (!result.ok) throw new SignatureVerificationError(result.reason); + return parseEvent(result.bytes); + }, + }); +} + +function validateUserHidInput(userId: unknown, secret: unknown): void { + if (typeof userId !== 'string' || userId === '') { + throw new ValidationError('userId must be a non-empty string.'); + } + if (typeof secret !== 'string' || secret === '') { + throw new ValidationError('secret must be a non-empty string.'); + } +} + +/** HMAC-SHA256(key = secret, message = userId) as 64 lowercase hex characters. */ +export function computeUserHid(backend: CryptoBackend, userId: string, secret: string): string { + if (backend.hmacSync === null) throw syncUnavailable('userHid', 'userHidAsync'); + validateUserHidInput(userId, secret); + return bytesToHex(backend.hmacSync(secret, encoder.encode(userId))); +} + +export async function computeUserHidAsync( + backend: CryptoBackend, + userId: string, + secret: string, +): Promise { + validateUserHidInput(userId, secret); + return bytesToHex(await backend.hmacAsync(secret, encoder.encode(userId))); +} diff --git a/test/data/README.md b/test/data/README.md new file mode 100644 index 0000000..6cafd21 --- /dev/null +++ b/test/data/README.md @@ -0,0 +1,19 @@ +# Shared test fixtures + +Shared test fixtures that every ShieldLabs server SDK passes. Do not edit them here. Only +`normalizer-edge-cases.json` is specific to this SDK. + +| File | Used for | +|---|---| +| `history-page.json`, `history-empty.json` | History API 200 bodies (5 rows: dangerous with a paid click, trusted anonymous, VPN with a local network leak and negative signals, the 999 rate-limit marker, a search bot) | +| `normalization-cases.json` | History rows and webhook `data` objects with the exact `Identification` each SDK must produce | +| `signal-slug-cases.json` | Description to signal slug pairs | +| `risk-band-cases.json` | Score to band | +| `webhook-identification-scored.json`, `.raw.txt` | Scored event (pretty, and the compact bytes as sent) | +| `webhook-rate-limited.json` | Scored event carrying the 999 marker | +| `webhook-ping.json`, `.raw.txt` | Verify ping, exact bytes | +| `webhook-test-delivery.json` | Test delivery from the analytics dashboard: 17 of 19 flags, second-precision timestamps | +| `webhook-signature-vectors.json` | 21 signature vectors (`secret` or a `secrets` list) | +| `management-profile.json`, `management-profile-expected.json` | Management API profile body and the normalized `DomainProfile` | +| `error-responses.json` | Error bodies per API and status, with the expected error class and whether it is retried | +| `normalizer-edge-cases.json` | Extra normalization cases for unusual input (this SDK) | diff --git a/test/data/error-responses.json b/test/data/error-responses.json new file mode 100644 index 0000000..2c96d9b --- /dev/null +++ b/test/data/error-responses.json @@ -0,0 +1,109 @@ +{ + "description": "Error responses exactly as the ShieldLabs servers send them. Bodies are not uniform: parse defensively and branch on status.", + "cases": [ + { + "surface": "history", + "status": 401, + "content_type": "text/plain; charset=utf-8", + "body": "{\"error\":\"missing or invalid authorization header\"}\n", + "expected_error": "AuthenticationError", + "retry": false + }, + { + "surface": "history", + "status": 401, + "content_type": "text/plain; charset=utf-8", + "body": "{\"error\":\"invalid api key\"}\n", + "expected_error": "AuthenticationError", + "retry": false + }, + { + "surface": "history", + "status": 429, + "content_type": "application/json", + "body": "{\"error\":\"too many requests\"}\n", + "expected_error": "RateLimitError", + "retry": true + }, + { + "surface": "history", + "status": 500, + "content_type": "application/json", + "body": "{\"error\":\"code: 53, message: Cannot convert string 'abc' to type UUID\"}\n", + "expected_error": "ServerError", + "retry": true + }, + { + "surface": "history", + "status": 500, + "content_type": "text/plain; charset=utf-8", + "body": "{\"error\":\"internal error\"}\n", + "expected_error": "ServerError", + "retry": true + }, + { + "surface": "history", + "status": 404, + "content_type": "text/plain; charset=utf-8", + "body": "404 page not found", + "expected_error": "NotFoundError", + "retry": false + }, + { + "surface": "history", + "status": 502, + "content_type": "text/html", + "body": "

502 Bad Gateway

", + "expected_error": "ServerError", + "retry": true + }, + { + "surface": "management", + "status": 401, + "content_type": null, + "body": "", + "expected_error": "AuthenticationError", + "retry": false + }, + { + "surface": "management", + "status": 429, + "content_type": "application/json; charset=utf-8", + "body": "{\"error\":\"too many requests\"}", + "expected_error": "RateLimitError", + "retry": false + }, + { + "surface": "management", + "status": 503, + "content_type": "application/json; charset=utf-8", + "body": "{\"error\":\"server is busy\"}", + "expected_error": "ServerError", + "retry": true + }, + { + "surface": "management", + "status": 400, + "content_type": "application/json; charset=utf-8", + "body": "null", + "expected_error": "BadRequestError", + "retry": false + }, + { + "surface": "management", + "status": 402, + "content_type": null, + "body": "", + "expected_error": "QuotaExceededError", + "retry": false + }, + { + "surface": "management", + "status": 404, + "content_type": "text/plain; charset=utf-8", + "body": "404 page not found", + "expected_error": "NotFoundError", + "retry": false + } + ] +} diff --git a/test/data/history-empty.json b/test/data/history-empty.json new file mode 100644 index 0000000..e88e669 --- /dev/null +++ b/test/data/history-empty.json @@ -0,0 +1,4 @@ +{ + "data": [], + "total": 0 +} diff --git a/test/data/history-page.json b/test/data/history-page.json new file mode 100644 index 0000000..047866b --- /dev/null +++ b/test/data/history-page.json @@ -0,0 +1,278 @@ +{ + "data": [ + { + "request_id": "02f1d973-84db-4156-a7f7-e799e6bf389b", + "session_id": "bde78778-efd2-4c49-952f-1f11b9c05f35", + "cookie_id": "4449bb58-590c-444c-ae1f-d1ddc768dbdd", + "domain": "shop.example.com", + "site_domain": "example.com", + "user_hid": "9f86d081884c7d659a2feaa0c55ad015", + "device_id": "ac7c303d-971b-41d1-8e25-cd5b46b46aed", + "visitor_id": "bde0e249-20d8-4544-838c-ed9a0b6d7a36", + "ip": "203.0.113.24", + "os": "Windows", + "browser": "Chrome", + "device_type": "desktop", + "country": "Netherlands", + "connection_type": "proxy", + "score": 80, + "score_details": "[{\"Value\":10,\"Description\":\"Is proxy\"},{\"Value\":10,\"Description\":\"Is datacenter\"},{\"Value\":60,\"Description\":\"Antidetect browser (turn_block)\"},{\"Value\":0,\"Description\":\"Check Incomplete\"}]", + "created_at": "2026-09-30 12:34:56.123", + "ver": 1790771696123, + "web_rtc_ip": "198.51.100.23", + "web_rtc_country": "Germany", + "web_rtc_connection_type": "direct", + "scanner_web_rtc_ip": "0.0.0.0", + "scanner_web_rtc_country": "", + "scanner_web_rtc_connection_type": "", + "webrtc_leak_ip": "0.0.0.0", + "webrtc_leak_country": "", + "webrtc_leak_connection_type": "", + "webrtc_leak_source": "none", + "tcp_mss": 1460, + "mtu_value": 1500, + "mtu_hint": "direct", + "is_vpn": false, + "is_tor": false, + "is_proxy": true, + "is_datacenter": true, + "is_abuser": false, + "is_privacy_relay": false, + "is_stun_not_checked": false, + "check_incomplete": false, + "is_antidetect": true, + "is_os_mismatch": false, + "is_os_not_detected": false, + "is_timezone_mismatch": false, + "is_js_disabled": false, + "is_browser_automation": false, + "is_incognito": false, + "is_search_bot": false, + "stun_request_seen": true, + "is_scanner_stun_passed": false, + "stun_flow_status": "ok", + "entry_url": "https://shop.example.com/signup?utm_source=google&utm_medium=cpc&gclid=abc123", + "utm_source": "google", + "utm_medium": "cpc", + "traffic_channel": "Google Ads", + "traffic_channel_group": "Paid Search", + "traffic_reason": "gclid_present", + "click_id_type": "gclid", + "is_suspicious_paid_click": true + }, + { + "request_id": "7c1e2f4a-3b6d-4e8f-9a0b-1c2d3e4f5a6b", + "session_id": "a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d", + "cookie_id": "f0e1d2c3-b4a5-4968-8776-655443322110", + "domain": "example.com", + "user_hid": "anonymous", + "device_id": "5d9a1f3e-7b2c-5e4d-8f6a-9b0c1d2e3f4a", + "visitor_id": "3c4d5e6f-7a8b-5c9d-8e0f-1a2b3c4d5e6f", + "ip": "192.0.2.44", + "os": "Mac OS X", + "browser": "Safari", + "device_type": "desktop", + "country": "United States", + "connection_type": "direct", + "score": 10, + "score_details": "[{\"Value\":10,\"Description\":\"Browser timezone ≠ IP-timezone\"}]", + "created_at": "2026-09-30 12:40:01.007", + "ver": 1790772001007, + "web_rtc_ip": "192.0.2.44", + "web_rtc_country": "United States", + "web_rtc_connection_type": "direct", + "scanner_web_rtc_ip": "0.0.0.0", + "scanner_web_rtc_country": "", + "scanner_web_rtc_connection_type": "", + "webrtc_leak_ip": "0.0.0.0", + "webrtc_leak_country": "", + "webrtc_leak_connection_type": "", + "webrtc_leak_source": "", + "tcp_mss": 1460, + "mtu_value": 1500, + "mtu_hint": "direct", + "is_vpn": false, + "is_tor": false, + "is_proxy": false, + "is_datacenter": false, + "is_abuser": false, + "is_privacy_relay": false, + "is_stun_not_checked": false, + "check_incomplete": false, + "is_antidetect": false, + "is_os_mismatch": false, + "is_os_not_detected": false, + "is_timezone_mismatch": true, + "is_js_disabled": false, + "is_browser_automation": false, + "is_incognito": true, + "is_search_bot": false, + "stun_request_seen": true, + "is_scanner_stun_passed": false, + "stun_flow_status": "ok" + }, + { + "request_id": "9e8d7c6b-5a49-4382-9716-05f4e3d2c1b0", + "session_id": "b0c1d2e3-f4a5-4b6c-9d7e-8f9a0b1c2d3e", + "cookie_id": "c3d4e5f6-a7b8-4c9d-8e0f-a1b2c3d4e5f6", + "domain": "example.com", + "user_hid": "", + "device_id": "e1f2a3b4-c5d6-5e7f-8a9b-0c1d2e3f4a5b", + "visitor_id": "d2e3f4a5-b6c7-5d8e-9f0a-1b2c3d4e5f6a", + "ip": "198.51.100.7", + "os": "Android", + "browser": "Chrome", + "device_type": "mobile", + "country": "France", + "connection_type": "vpn", + "score": 45, + "score_details": "[{\"Value\":15,\"Description\":\"Is VPN\"},{\"Value\":30,\"Description\":\"Stun is not checked\"},{\"Value\":-30,\"Description\":\"Stun passed (late arrival, corrected)\"},{\"Value\":30,\"Description\":\"Sticky verdict: Stun is not checked (request 11111111-2222-4333-8444-555555555555)\"},{\"Value\":0,\"Description\":\"IP ≠ leakIP (198.51.100.7 ≠ 203.0.113.9, source=scanner)\"}]", + "created_at": "2026-09-30 13:05:12", + "ver": 1790773512000, + "web_rtc_ip": "0.0.0.0", + "web_rtc_country": "", + "web_rtc_connection_type": "", + "scanner_web_rtc_ip": "203.0.113.9", + "scanner_web_rtc_country": "Spain", + "scanner_web_rtc_connection_type": "direct", + "webrtc_leak_ip": "203.0.113.9", + "webrtc_leak_country": "Spain", + "webrtc_leak_connection_type": "direct", + "webrtc_leak_source": "scanner", + "tcp_mss": 1380, + "mtu_value": 1420, + "mtu_hint": "vpn_likely", + "is_vpn": true, + "is_tor": false, + "is_proxy": false, + "is_datacenter": false, + "is_abuser": false, + "is_privacy_relay": false, + "is_stun_not_checked": true, + "check_incomplete": true, + "is_antidetect": false, + "is_os_mismatch": false, + "is_os_not_detected": false, + "is_timezone_mismatch": false, + "is_js_disabled": false, + "is_browser_automation": false, + "is_incognito": false, + "is_search_bot": false, + "stun_request_seen": false, + "is_scanner_stun_passed": true, + "stun_flow_status": "reply_without_request", + "entry_url": "https://example.com/pricing", + "referrer_domain": "news.example.org", + "traffic_channel": "Referral", + "traffic_channel_group": "Referral", + "traffic_reason": "external_referrer" + }, + { + "request_id": "1a2b3c4d-5e6f-4a7b-8c9d-0e1f2a3b4c5d", + "session_id": "00000000-0000-0000-0000-000000000000", + "cookie_id": "00000000-0000-0000-0000-000000000000", + "domain": "example.com", + "user_hid": "-1", + "device_id": "00000000-0000-0000-0000-000000000000", + "visitor_id": "00000000-0000-0000-0000-000000000000", + "ip": "203.0.113.200", + "os": "Unknown", + "browser": "Unknown", + "device_type": "desktop", + "country": "", + "connection_type": "unknown", + "score": 999, + "score_details": "[{\"Value\":999,\"Description\":\"User has been banned 1H, to many requests\"}]", + "created_at": "2026-09-30 13:10:00.500", + "ver": 1790773800500, + "web_rtc_ip": "0.0.0.0", + "web_rtc_country": "", + "web_rtc_connection_type": "", + "scanner_web_rtc_ip": "0.0.0.0", + "scanner_web_rtc_country": "", + "scanner_web_rtc_connection_type": "", + "webrtc_leak_ip": "0.0.0.0", + "webrtc_leak_country": "", + "webrtc_leak_connection_type": "", + "webrtc_leak_source": "", + "tcp_mss": 0, + "mtu_value": 0, + "mtu_hint": "", + "is_vpn": false, + "is_tor": false, + "is_proxy": false, + "is_datacenter": false, + "is_abuser": false, + "is_privacy_relay": false, + "is_stun_not_checked": false, + "check_incomplete": false, + "is_antidetect": false, + "is_os_mismatch": false, + "is_os_not_detected": false, + "is_timezone_mismatch": false, + "is_js_disabled": false, + "is_browser_automation": false, + "is_incognito": false, + "is_search_bot": false, + "stun_request_seen": false, + "is_scanner_stun_passed": false, + "stun_flow_status": "" + }, + { + "request_id": "4f5e6d7c-8b9a-4c1d-9e2f-3a4b5c6d7e8f", + "session_id": "5a6b7c8d-9e0f-4a1b-8c2d-3e4f5a6b7c8d", + "cookie_id": "6b7c8d9e-0f1a-4b2c-9d3e-4f5a6b7c8d9e", + "domain": "example.com", + "user_hid": "anonymous", + "device_id": "7c8d9e0f-1a2b-5c3d-8e4f-5a6b7c8d9e0f", + "visitor_id": "8d9e0f1a-2b3c-5d4e-9f5a-6b7c8d9e0f1a", + "ip": "198.51.100.66", + "os": "Linux", + "browser": "Chrome", + "device_type": "desktop", + "country": "United States", + "connection_type": "proxy", + "score": 0, + "score_details": "", + "created_at": "2026-09-30 13:20:30.250", + "ver": 1790774430250, + "web_rtc_ip": "203.0.113.77", + "web_rtc_country": "United States", + "web_rtc_connection_type": "direct", + "scanner_web_rtc_ip": "0.0.0.0", + "scanner_web_rtc_country": "", + "scanner_web_rtc_connection_type": "", + "webrtc_leak_ip": "0.0.0.0", + "webrtc_leak_country": "", + "webrtc_leak_connection_type": "", + "webrtc_leak_source": "none", + "tcp_mss": 1460, + "mtu_value": 1500, + "mtu_hint": "direct", + "is_vpn": false, + "is_tor": false, + "is_proxy": false, + "is_datacenter": false, + "is_abuser": false, + "is_privacy_relay": false, + "is_stun_not_checked": false, + "check_incomplete": false, + "is_antidetect": false, + "is_os_mismatch": false, + "is_os_not_detected": false, + "is_timezone_mismatch": false, + "is_js_disabled": false, + "is_browser_automation": false, + "is_incognito": false, + "is_search_bot": true, + "stun_request_seen": false, + "is_scanner_stun_passed": false, + "stun_flow_status": "", + "referrer_domain": "GoogleBot", + "traffic_channel": "Search bot", + "traffic_channel_group": "Bot", + "traffic_reason": "ip_crawler_detected" + } + ], + "total": 37 +} diff --git a/test/data/management-profile-expected.json b/test/data/management-profile-expected.json new file mode 100644 index 0000000..99c5f3f --- /dev/null +++ b/test/data/management-profile-expected.json @@ -0,0 +1,7 @@ +{ + "domain": "example.com", + "remaining_identifications": 148230, + "public_key_masked": "****************************a3f8", + "secret_key_masked": "****************************9c2d", + "created_at": "2026-01-15T09:00:00.000Z" +} diff --git a/test/data/management-profile.json b/test/data/management-profile.json new file mode 100644 index 0000000..b0d1c74 --- /dev/null +++ b/test/data/management-profile.json @@ -0,0 +1,8 @@ +{ + "Domain": "example.com", + "Weight": 148230, + "Callback": "", + "PublicKey": "****************************a3f8", + "Secret": "****************************9c2d", + "CreatedAt": "2026-01-15T09:00:00Z" +} diff --git a/test/data/normalization-cases.json b/test/data/normalization-cases.json new file mode 100644 index 0000000..0ccaff3 --- /dev/null +++ b/test/data/normalization-cases.json @@ -0,0 +1,1050 @@ +{ + "description": "History API rows and webhook data objects with the Identification every SDK must produce. Compare observed_at at millisecond precision (truncate, never round). Keys not listed in expected (for example raw) are free.", + "cases": [ + { + "name": "history_02f1d973", + "source": "history", + "input": { + "request_id": "02f1d973-84db-4156-a7f7-e799e6bf389b", + "session_id": "bde78778-efd2-4c49-952f-1f11b9c05f35", + "cookie_id": "4449bb58-590c-444c-ae1f-d1ddc768dbdd", + "domain": "shop.example.com", + "site_domain": "example.com", + "user_hid": "9f86d081884c7d659a2feaa0c55ad015", + "device_id": "ac7c303d-971b-41d1-8e25-cd5b46b46aed", + "visitor_id": "bde0e249-20d8-4544-838c-ed9a0b6d7a36", + "ip": "203.0.113.24", + "os": "Windows", + "browser": "Chrome", + "device_type": "desktop", + "country": "Netherlands", + "connection_type": "proxy", + "score": 80, + "score_details": "[{\"Value\":10,\"Description\":\"Is proxy\"},{\"Value\":10,\"Description\":\"Is datacenter\"},{\"Value\":60,\"Description\":\"Antidetect browser (turn_block)\"},{\"Value\":0,\"Description\":\"Check Incomplete\"}]", + "created_at": "2026-09-30 12:34:56.123", + "ver": 1790771696123, + "web_rtc_ip": "198.51.100.23", + "web_rtc_country": "Germany", + "web_rtc_connection_type": "direct", + "scanner_web_rtc_ip": "0.0.0.0", + "scanner_web_rtc_country": "", + "scanner_web_rtc_connection_type": "", + "webrtc_leak_ip": "0.0.0.0", + "webrtc_leak_country": "", + "webrtc_leak_connection_type": "", + "webrtc_leak_source": "none", + "tcp_mss": 1460, + "mtu_value": 1500, + "mtu_hint": "direct", + "is_vpn": false, + "is_tor": false, + "is_proxy": true, + "is_datacenter": true, + "is_abuser": false, + "is_privacy_relay": false, + "is_stun_not_checked": false, + "check_incomplete": false, + "is_antidetect": true, + "is_os_mismatch": false, + "is_os_not_detected": false, + "is_timezone_mismatch": false, + "is_js_disabled": false, + "is_browser_automation": false, + "is_incognito": false, + "is_search_bot": false, + "stun_request_seen": true, + "is_scanner_stun_passed": false, + "stun_flow_status": "ok", + "entry_url": "https://shop.example.com/signup?utm_source=google&utm_medium=cpc&gclid=abc123", + "utm_source": "google", + "utm_medium": "cpc", + "traffic_channel": "Google Ads", + "traffic_channel_group": "Paid Search", + "traffic_reason": "gclid_present", + "click_id_type": "gclid", + "is_suspicious_paid_click": true + }, + "expected": { + "request_id": "02f1d973-84db-4156-a7f7-e799e6bf389b", + "visitor_id": "bde0e249-20d8-4544-838c-ed9a0b6d7a36", + "device_id": "ac7c303d-971b-41d1-8e25-cd5b46b46aed", + "session_id": "bde78778-efd2-4c49-952f-1f11b9c05f35", + "cookie_id": "4449bb58-590c-444c-ae1f-d1ddc768dbdd", + "user_hid": "9f86d081884c7d659a2feaa0c55ad015", + "domain": "example.com", + "public_ip": { + "ip": "203.0.113.24", + "country": "Netherlands" + }, + "local_ip": { + "ip": "198.51.100.23", + "country": "Germany" + }, + "connection_type": "proxy", + "os": "Windows", + "browser": "Chrome", + "device_type": "desktop", + "traffic_source": { + "channel": "Google Ads", + "referrer_domain": "", + "landing_url": "https://shop.example.com/signup?utm_source=google&utm_medium=cpc&gclid=abc123", + "click_id_type": "gclid", + "utm_source": "google", + "utm_medium": "cpc", + "utm_campaign": "", + "utm_content": "", + "utm_term": "" + }, + "risk_score": 80, + "signals": [ + { + "name": "proxy", + "weight": 10, + "description": "Is proxy" + }, + { + "name": "datacenter_ip", + "weight": 10, + "description": "Is datacenter" + }, + { + "name": "antidetect_browser", + "weight": 60, + "description": "Antidetect browser (turn_block)" + } + ], + "detection_flags": { + "vpn": false, + "privacy_relay": false, + "browser_vpn_proxy": false, + "tor": false, + "proxy": true, + "datacenter_ip": true, + "abuser": false, + "os_mismatch": false, + "os_not_detected": false, + "timezone_mismatch": false, + "anti_detect_browser": true, + "browser_automation": false, + "ip_mismatch": true, + "incognito": false, + "search_bot": false, + "suspicious_paid_click": true, + "javascript_disabled": false, + "stun_not_checked": false, + "check_incomplete": false + }, + "observed_at": "2026-09-30T12:34:56.123Z", + "source": "history" + } + }, + { + "name": "history_7c1e2f4a", + "source": "history", + "input": { + "request_id": "7c1e2f4a-3b6d-4e8f-9a0b-1c2d3e4f5a6b", + "session_id": "a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d", + "cookie_id": "f0e1d2c3-b4a5-4968-8776-655443322110", + "domain": "example.com", + "user_hid": "anonymous", + "device_id": "5d9a1f3e-7b2c-5e4d-8f6a-9b0c1d2e3f4a", + "visitor_id": "3c4d5e6f-7a8b-5c9d-8e0f-1a2b3c4d5e6f", + "ip": "192.0.2.44", + "os": "Mac OS X", + "browser": "Safari", + "device_type": "desktop", + "country": "United States", + "connection_type": "direct", + "score": 10, + "score_details": "[{\"Value\":10,\"Description\":\"Browser timezone ≠ IP-timezone\"}]", + "created_at": "2026-09-30 12:40:01.007", + "ver": 1790772001007, + "web_rtc_ip": "192.0.2.44", + "web_rtc_country": "United States", + "web_rtc_connection_type": "direct", + "scanner_web_rtc_ip": "0.0.0.0", + "scanner_web_rtc_country": "", + "scanner_web_rtc_connection_type": "", + "webrtc_leak_ip": "0.0.0.0", + "webrtc_leak_country": "", + "webrtc_leak_connection_type": "", + "webrtc_leak_source": "", + "tcp_mss": 1460, + "mtu_value": 1500, + "mtu_hint": "direct", + "is_vpn": false, + "is_tor": false, + "is_proxy": false, + "is_datacenter": false, + "is_abuser": false, + "is_privacy_relay": false, + "is_stun_not_checked": false, + "check_incomplete": false, + "is_antidetect": false, + "is_os_mismatch": false, + "is_os_not_detected": false, + "is_timezone_mismatch": true, + "is_js_disabled": false, + "is_browser_automation": false, + "is_incognito": true, + "is_search_bot": false, + "stun_request_seen": true, + "is_scanner_stun_passed": false, + "stun_flow_status": "ok" + }, + "expected": { + "request_id": "7c1e2f4a-3b6d-4e8f-9a0b-1c2d3e4f5a6b", + "visitor_id": "3c4d5e6f-7a8b-5c9d-8e0f-1a2b3c4d5e6f", + "device_id": "5d9a1f3e-7b2c-5e4d-8f6a-9b0c1d2e3f4a", + "session_id": "a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d", + "cookie_id": "f0e1d2c3-b4a5-4968-8776-655443322110", + "user_hid": "anonymous", + "domain": "example.com", + "public_ip": { + "ip": "192.0.2.44", + "country": "United States" + }, + "local_ip": { + "ip": "192.0.2.44", + "country": "United States" + }, + "connection_type": "direct", + "os": "Mac OS X", + "browser": "Safari", + "device_type": "desktop", + "traffic_source": { + "channel": "", + "referrer_domain": "", + "landing_url": "", + "click_id_type": "", + "utm_source": "", + "utm_medium": "", + "utm_campaign": "", + "utm_content": "", + "utm_term": "" + }, + "risk_score": 10, + "signals": [ + { + "name": "timezone_mismatch", + "weight": 10, + "description": "Browser timezone ≠ IP-timezone" + } + ], + "detection_flags": { + "vpn": false, + "privacy_relay": false, + "browser_vpn_proxy": false, + "tor": false, + "proxy": false, + "datacenter_ip": false, + "abuser": false, + "os_mismatch": false, + "os_not_detected": false, + "timezone_mismatch": true, + "anti_detect_browser": false, + "browser_automation": false, + "ip_mismatch": false, + "incognito": true, + "search_bot": false, + "suspicious_paid_click": false, + "javascript_disabled": false, + "stun_not_checked": false, + "check_incomplete": false + }, + "observed_at": "2026-09-30T12:40:01.007Z", + "source": "history" + } + }, + { + "name": "history_9e8d7c6b", + "source": "history", + "input": { + "request_id": "9e8d7c6b-5a49-4382-9716-05f4e3d2c1b0", + "session_id": "b0c1d2e3-f4a5-4b6c-9d7e-8f9a0b1c2d3e", + "cookie_id": "c3d4e5f6-a7b8-4c9d-8e0f-a1b2c3d4e5f6", + "domain": "example.com", + "user_hid": "", + "device_id": "e1f2a3b4-c5d6-5e7f-8a9b-0c1d2e3f4a5b", + "visitor_id": "d2e3f4a5-b6c7-5d8e-9f0a-1b2c3d4e5f6a", + "ip": "198.51.100.7", + "os": "Android", + "browser": "Chrome", + "device_type": "mobile", + "country": "France", + "connection_type": "vpn", + "score": 45, + "score_details": "[{\"Value\":15,\"Description\":\"Is VPN\"},{\"Value\":30,\"Description\":\"Stun is not checked\"},{\"Value\":-30,\"Description\":\"Stun passed (late arrival, corrected)\"},{\"Value\":30,\"Description\":\"Sticky verdict: Stun is not checked (request 11111111-2222-4333-8444-555555555555)\"},{\"Value\":0,\"Description\":\"IP ≠ leakIP (198.51.100.7 ≠ 203.0.113.9, source=scanner)\"}]", + "created_at": "2026-09-30 13:05:12", + "ver": 1790773512000, + "web_rtc_ip": "0.0.0.0", + "web_rtc_country": "", + "web_rtc_connection_type": "", + "scanner_web_rtc_ip": "203.0.113.9", + "scanner_web_rtc_country": "Spain", + "scanner_web_rtc_connection_type": "direct", + "webrtc_leak_ip": "203.0.113.9", + "webrtc_leak_country": "Spain", + "webrtc_leak_connection_type": "direct", + "webrtc_leak_source": "scanner", + "tcp_mss": 1380, + "mtu_value": 1420, + "mtu_hint": "vpn_likely", + "is_vpn": true, + "is_tor": false, + "is_proxy": false, + "is_datacenter": false, + "is_abuser": false, + "is_privacy_relay": false, + "is_stun_not_checked": true, + "check_incomplete": true, + "is_antidetect": false, + "is_os_mismatch": false, + "is_os_not_detected": false, + "is_timezone_mismatch": false, + "is_js_disabled": false, + "is_browser_automation": false, + "is_incognito": false, + "is_search_bot": false, + "stun_request_seen": false, + "is_scanner_stun_passed": true, + "stun_flow_status": "reply_without_request", + "entry_url": "https://example.com/pricing", + "referrer_domain": "news.example.org", + "traffic_channel": "Referral", + "traffic_channel_group": "Referral", + "traffic_reason": "external_referrer" + }, + "expected": { + "request_id": "9e8d7c6b-5a49-4382-9716-05f4e3d2c1b0", + "visitor_id": "d2e3f4a5-b6c7-5d8e-9f0a-1b2c3d4e5f6a", + "device_id": "e1f2a3b4-c5d6-5e7f-8a9b-0c1d2e3f4a5b", + "session_id": "b0c1d2e3-f4a5-4b6c-9d7e-8f9a0b1c2d3e", + "cookie_id": "c3d4e5f6-a7b8-4c9d-8e0f-a1b2c3d4e5f6", + "user_hid": null, + "domain": "example.com", + "public_ip": { + "ip": "198.51.100.7", + "country": "France" + }, + "local_ip": { + "ip": "203.0.113.9", + "country": "Spain" + }, + "connection_type": "vpn", + "os": "Android", + "browser": "Chrome", + "device_type": "mobile", + "traffic_source": { + "channel": "Referral", + "referrer_domain": "news.example.org", + "landing_url": "https://example.com/pricing", + "click_id_type": "", + "utm_source": "", + "utm_medium": "", + "utm_campaign": "", + "utm_content": "", + "utm_term": "" + }, + "risk_score": 45, + "signals": [ + { + "name": "vpn", + "weight": 15, + "description": "Is VPN" + }, + { + "name": "stun_not_checked", + "weight": 30, + "description": "Stun is not checked" + }, + { + "name": "stun_late_correction", + "weight": -30, + "description": "Stun passed (late arrival, corrected)" + }, + { + "name": "stun_is_not_checked", + "weight": 30, + "description": "Sticky verdict: Stun is not checked (request 11111111-2222-4333-8444-555555555555)" + } + ], + "detection_flags": { + "vpn": true, + "privacy_relay": false, + "browser_vpn_proxy": false, + "tor": false, + "proxy": false, + "datacenter_ip": false, + "abuser": false, + "os_mismatch": false, + "os_not_detected": false, + "timezone_mismatch": false, + "anti_detect_browser": false, + "browser_automation": false, + "ip_mismatch": true, + "incognito": false, + "search_bot": false, + "suspicious_paid_click": false, + "javascript_disabled": false, + "stun_not_checked": true, + "check_incomplete": true + }, + "observed_at": "2026-09-30T13:05:12.000Z", + "source": "history" + } + }, + { + "name": "history_1a2b3c4d", + "source": "history", + "input": { + "request_id": "1a2b3c4d-5e6f-4a7b-8c9d-0e1f2a3b4c5d", + "session_id": "00000000-0000-0000-0000-000000000000", + "cookie_id": "00000000-0000-0000-0000-000000000000", + "domain": "example.com", + "user_hid": "-1", + "device_id": "00000000-0000-0000-0000-000000000000", + "visitor_id": "00000000-0000-0000-0000-000000000000", + "ip": "203.0.113.200", + "os": "Unknown", + "browser": "Unknown", + "device_type": "desktop", + "country": "", + "connection_type": "unknown", + "score": 999, + "score_details": "[{\"Value\":999,\"Description\":\"User has been banned 1H, to many requests\"}]", + "created_at": "2026-09-30 13:10:00.500", + "ver": 1790773800500, + "web_rtc_ip": "0.0.0.0", + "web_rtc_country": "", + "web_rtc_connection_type": "", + "scanner_web_rtc_ip": "0.0.0.0", + "scanner_web_rtc_country": "", + "scanner_web_rtc_connection_type": "", + "webrtc_leak_ip": "0.0.0.0", + "webrtc_leak_country": "", + "webrtc_leak_connection_type": "", + "webrtc_leak_source": "", + "tcp_mss": 0, + "mtu_value": 0, + "mtu_hint": "", + "is_vpn": false, + "is_tor": false, + "is_proxy": false, + "is_datacenter": false, + "is_abuser": false, + "is_privacy_relay": false, + "is_stun_not_checked": false, + "check_incomplete": false, + "is_antidetect": false, + "is_os_mismatch": false, + "is_os_not_detected": false, + "is_timezone_mismatch": false, + "is_js_disabled": false, + "is_browser_automation": false, + "is_incognito": false, + "is_search_bot": false, + "stun_request_seen": false, + "is_scanner_stun_passed": false, + "stun_flow_status": "" + }, + "expected": { + "request_id": "1a2b3c4d-5e6f-4a7b-8c9d-0e1f2a3b4c5d", + "visitor_id": "00000000-0000-0000-0000-000000000000", + "device_id": "00000000-0000-0000-0000-000000000000", + "session_id": "00000000-0000-0000-0000-000000000000", + "cookie_id": "00000000-0000-0000-0000-000000000000", + "user_hid": "-1", + "domain": "example.com", + "public_ip": { + "ip": "203.0.113.200", + "country": "" + }, + "local_ip": { + "ip": "", + "country": "" + }, + "connection_type": "unknown", + "os": "Unknown", + "browser": "Unknown", + "device_type": "desktop", + "traffic_source": { + "channel": "", + "referrer_domain": "", + "landing_url": "", + "click_id_type": "", + "utm_source": "", + "utm_medium": "", + "utm_campaign": "", + "utm_content": "", + "utm_term": "" + }, + "risk_score": 999, + "signals": [ + { + "name": "rate_limited", + "weight": 999, + "description": "User has been banned 1H, to many requests" + } + ], + "detection_flags": { + "vpn": false, + "privacy_relay": false, + "browser_vpn_proxy": false, + "tor": false, + "proxy": false, + "datacenter_ip": false, + "abuser": false, + "os_mismatch": false, + "os_not_detected": false, + "timezone_mismatch": false, + "anti_detect_browser": false, + "browser_automation": false, + "ip_mismatch": false, + "incognito": false, + "search_bot": false, + "suspicious_paid_click": false, + "javascript_disabled": false, + "stun_not_checked": false, + "check_incomplete": false + }, + "observed_at": "2026-09-30T13:10:00.500Z", + "source": "history" + } + }, + { + "name": "history_4f5e6d7c", + "source": "history", + "input": { + "request_id": "4f5e6d7c-8b9a-4c1d-9e2f-3a4b5c6d7e8f", + "session_id": "5a6b7c8d-9e0f-4a1b-8c2d-3e4f5a6b7c8d", + "cookie_id": "6b7c8d9e-0f1a-4b2c-9d3e-4f5a6b7c8d9e", + "domain": "example.com", + "user_hid": "anonymous", + "device_id": "7c8d9e0f-1a2b-5c3d-8e4f-5a6b7c8d9e0f", + "visitor_id": "8d9e0f1a-2b3c-5d4e-9f5a-6b7c8d9e0f1a", + "ip": "198.51.100.66", + "os": "Linux", + "browser": "Chrome", + "device_type": "desktop", + "country": "United States", + "connection_type": "proxy", + "score": 0, + "score_details": "", + "created_at": "2026-09-30 13:20:30.250", + "ver": 1790774430250, + "web_rtc_ip": "203.0.113.77", + "web_rtc_country": "United States", + "web_rtc_connection_type": "direct", + "scanner_web_rtc_ip": "0.0.0.0", + "scanner_web_rtc_country": "", + "scanner_web_rtc_connection_type": "", + "webrtc_leak_ip": "0.0.0.0", + "webrtc_leak_country": "", + "webrtc_leak_connection_type": "", + "webrtc_leak_source": "none", + "tcp_mss": 1460, + "mtu_value": 1500, + "mtu_hint": "direct", + "is_vpn": false, + "is_tor": false, + "is_proxy": false, + "is_datacenter": false, + "is_abuser": false, + "is_privacy_relay": false, + "is_stun_not_checked": false, + "check_incomplete": false, + "is_antidetect": false, + "is_os_mismatch": false, + "is_os_not_detected": false, + "is_timezone_mismatch": false, + "is_js_disabled": false, + "is_browser_automation": false, + "is_incognito": false, + "is_search_bot": true, + "stun_request_seen": false, + "is_scanner_stun_passed": false, + "stun_flow_status": "", + "referrer_domain": "GoogleBot", + "traffic_channel": "Search bot", + "traffic_channel_group": "Bot", + "traffic_reason": "ip_crawler_detected" + }, + "expected": { + "request_id": "4f5e6d7c-8b9a-4c1d-9e2f-3a4b5c6d7e8f", + "visitor_id": "8d9e0f1a-2b3c-5d4e-9f5a-6b7c8d9e0f1a", + "device_id": "7c8d9e0f-1a2b-5c3d-8e4f-5a6b7c8d9e0f", + "session_id": "5a6b7c8d-9e0f-4a1b-8c2d-3e4f5a6b7c8d", + "cookie_id": "6b7c8d9e-0f1a-4b2c-9d3e-4f5a6b7c8d9e", + "user_hid": "anonymous", + "domain": "example.com", + "public_ip": { + "ip": "198.51.100.66", + "country": "United States" + }, + "local_ip": { + "ip": "203.0.113.77", + "country": "United States" + }, + "connection_type": "proxy", + "os": "Linux", + "browser": "Chrome", + "device_type": "desktop", + "traffic_source": { + "channel": "Search bot", + "referrer_domain": "GoogleBot", + "landing_url": "", + "click_id_type": "", + "utm_source": "", + "utm_medium": "", + "utm_campaign": "", + "utm_content": "", + "utm_term": "" + }, + "risk_score": 0, + "signals": [], + "detection_flags": { + "vpn": false, + "privacy_relay": false, + "browser_vpn_proxy": false, + "tor": false, + "proxy": false, + "datacenter_ip": false, + "abuser": false, + "os_mismatch": false, + "os_not_detected": false, + "timezone_mismatch": false, + "anti_detect_browser": false, + "browser_automation": false, + "ip_mismatch": false, + "incognito": false, + "search_bot": true, + "suspicious_paid_click": false, + "javascript_disabled": false, + "stun_not_checked": false, + "check_incomplete": false + }, + "observed_at": "2026-09-30T13:20:30.250Z", + "source": "history" + } + }, + { + "name": "webhook_scored", + "source": "webhook", + "input": { + "request_id": "02f1d973-84db-4156-a7f7-e799e6bf389b", + "visitor_id": "bde0e249-20d8-4544-838c-ed9a0b6d7a36", + "device_id": "ac7c303d-971b-41d1-8e25-cd5b46b46aed", + "session_id": "bde78778-efd2-4c49-952f-1f11b9c05f35", + "cookie_id": "4449bb58-590c-444c-ae1f-d1ddc768dbdd", + "user_hid": "9f86d081884c7d659a2feaa0c55ad015", + "domain": "example.com", + "public_ip": { + "ip": "203.0.113.24", + "country": "Netherlands" + }, + "local_ip": { + "ip": "198.51.100.23", + "country": "Germany" + }, + "connection_type": "proxy", + "os": "Windows", + "browser": "Chrome", + "device_type": "desktop", + "traffic_source": { + "channel": "Google Ads", + "referrer_domain": "google.com", + "landing_url": "https://shop.example.com/signup?utm_source=google&utm_medium=cpc&gclid=abc123", + "click_id_type": "gclid", + "utm_source": "google", + "utm_medium": "cpc", + "utm_campaign": "", + "utm_content": "", + "utm_term": "" + }, + "risk_score": 80, + "signals": [ + { + "name": "proxy", + "weight": 10 + }, + { + "name": "datacenter_ip", + "weight": 10 + }, + { + "name": "antidetect_browser", + "weight": 60 + } + ], + "detection_flags": { + "vpn": false, + "privacy_relay": false, + "browser_vpn_proxy": false, + "tor": false, + "proxy": true, + "datacenter_ip": true, + "abuser": false, + "os_mismatch": false, + "os_not_detected": false, + "timezone_mismatch": false, + "anti_detect_browser": true, + "browser_automation": false, + "ip_mismatch": true, + "incognito": false, + "search_bot": false, + "suspicious_paid_click": true, + "javascript_disabled": false, + "stun_not_checked": false, + "check_incomplete": false + }, + "observed_at": "2026-09-30T12:34:57.482913041Z" + }, + "expected": { + "request_id": "02f1d973-84db-4156-a7f7-e799e6bf389b", + "visitor_id": "bde0e249-20d8-4544-838c-ed9a0b6d7a36", + "device_id": "ac7c303d-971b-41d1-8e25-cd5b46b46aed", + "session_id": "bde78778-efd2-4c49-952f-1f11b9c05f35", + "cookie_id": "4449bb58-590c-444c-ae1f-d1ddc768dbdd", + "user_hid": "9f86d081884c7d659a2feaa0c55ad015", + "domain": "example.com", + "public_ip": { + "ip": "203.0.113.24", + "country": "Netherlands" + }, + "local_ip": { + "ip": "198.51.100.23", + "country": "Germany" + }, + "connection_type": "proxy", + "os": "Windows", + "browser": "Chrome", + "device_type": "desktop", + "traffic_source": { + "channel": "Google Ads", + "referrer_domain": "google.com", + "landing_url": "https://shop.example.com/signup?utm_source=google&utm_medium=cpc&gclid=abc123", + "click_id_type": "gclid", + "utm_source": "google", + "utm_medium": "cpc", + "utm_campaign": "", + "utm_content": "", + "utm_term": "" + }, + "risk_score": 80, + "signals": [ + { + "name": "proxy", + "weight": 10, + "description": null + }, + { + "name": "datacenter_ip", + "weight": 10, + "description": null + }, + { + "name": "antidetect_browser", + "weight": 60, + "description": null + } + ], + "detection_flags": { + "vpn": false, + "privacy_relay": false, + "browser_vpn_proxy": false, + "tor": false, + "proxy": true, + "datacenter_ip": true, + "abuser": false, + "os_mismatch": false, + "os_not_detected": false, + "timezone_mismatch": false, + "anti_detect_browser": true, + "browser_automation": false, + "ip_mismatch": true, + "incognito": false, + "search_bot": false, + "suspicious_paid_click": true, + "javascript_disabled": false, + "stun_not_checked": false, + "check_incomplete": false + }, + "observed_at": "2026-09-30T12:34:57.482Z", + "source": "webhook" + } + }, + { + "name": "webhook_rate_limited", + "source": "webhook", + "input": { + "request_id": "1a2b3c4d-5e6f-4a7b-8c9d-0e1f2a3b4c5d", + "visitor_id": "00000000-0000-0000-0000-000000000000", + "device_id": "00000000-0000-0000-0000-000000000000", + "session_id": "00000000-0000-0000-0000-000000000000", + "cookie_id": "00000000-0000-0000-0000-000000000000", + "user_hid": "-1", + "domain": "example.com", + "public_ip": { + "ip": "203.0.113.200", + "country": "" + }, + "local_ip": { + "ip": "", + "country": "" + }, + "connection_type": "unknown", + "os": "Unknown", + "browser": "Unknown", + "device_type": "desktop", + "traffic_source": { + "channel": "", + "referrer_domain": "", + "landing_url": "", + "click_id_type": "", + "utm_source": "", + "utm_medium": "", + "utm_campaign": "", + "utm_content": "", + "utm_term": "" + }, + "risk_score": 999, + "signals": [ + { + "name": "rate_limited", + "weight": 999 + } + ], + "detection_flags": { + "vpn": false, + "privacy_relay": false, + "browser_vpn_proxy": false, + "tor": false, + "proxy": false, + "datacenter_ip": false, + "abuser": false, + "os_mismatch": false, + "os_not_detected": false, + "timezone_mismatch": false, + "anti_detect_browser": false, + "browser_automation": false, + "ip_mismatch": false, + "incognito": false, + "search_bot": false, + "suspicious_paid_click": false, + "javascript_disabled": false, + "stun_not_checked": false, + "check_incomplete": false + }, + "observed_at": "2026-09-30T13:10:00.5Z" + }, + "expected": { + "request_id": "1a2b3c4d-5e6f-4a7b-8c9d-0e1f2a3b4c5d", + "visitor_id": "00000000-0000-0000-0000-000000000000", + "device_id": "00000000-0000-0000-0000-000000000000", + "session_id": "00000000-0000-0000-0000-000000000000", + "cookie_id": "00000000-0000-0000-0000-000000000000", + "user_hid": "-1", + "domain": "example.com", + "public_ip": { + "ip": "203.0.113.200", + "country": "" + }, + "local_ip": { + "ip": "", + "country": "" + }, + "connection_type": "unknown", + "os": "Unknown", + "browser": "Unknown", + "device_type": "desktop", + "traffic_source": { + "channel": "", + "referrer_domain": "", + "landing_url": "", + "click_id_type": "", + "utm_source": "", + "utm_medium": "", + "utm_campaign": "", + "utm_content": "", + "utm_term": "" + }, + "risk_score": 999, + "signals": [ + { + "name": "rate_limited", + "weight": 999, + "description": null + } + ], + "detection_flags": { + "vpn": false, + "privacy_relay": false, + "browser_vpn_proxy": false, + "tor": false, + "proxy": false, + "datacenter_ip": false, + "abuser": false, + "os_mismatch": false, + "os_not_detected": false, + "timezone_mismatch": false, + "anti_detect_browser": false, + "browser_automation": false, + "ip_mismatch": false, + "incognito": false, + "search_bot": false, + "suspicious_paid_click": false, + "javascript_disabled": false, + "stun_not_checked": false, + "check_incomplete": false + }, + "observed_at": "2026-09-30T13:10:00.500Z", + "source": "webhook" + } + }, + { + "name": "webhook_test_delivery", + "source": "webhook", + "input": { + "browser": "Chrome", + "connection_type": "proxy", + "cookie_id": "2c9d1e8f-4b7a-4c3e-9d2f-1a8b7c6d5e4f", + "detection_flags": { + "abuser": true, + "anti_detect_browser": false, + "check_incomplete": false, + "datacenter_ip": true, + "incognito": false, + "ip_mismatch": false, + "javascript_disabled": false, + "os_mismatch": false, + "os_not_detected": false, + "privacy_relay": false, + "proxy": true, + "stun_not_checked": false, + "suspicious_paid_click": false, + "timezone_mismatch": false, + "tor": false, + "vpn": false, + "browser_vpn_proxy": false + }, + "device_id": "6f1e2d3c-4b5a-5968-8776-655443322110", + "device_type": "desktop", + "domain": "example.com", + "local_ip": { + "country": "BY", + "ip": "198.51.100.10" + }, + "observed_at": "2026-09-30T12:34:56Z", + "os": "Windows", + "public_ip": { + "country": "BY", + "ip": "203.0.113.10" + }, + "request_id": "13f84f05-7c2a-4e9b-9f1d-2a6b8c0e4d11", + "risk_score": 30, + "session_id": "3a2b1c0d-9e8f-4a7b-8c6d-5e4f3a2b1c0d", + "signals": [ + { + "name": "proxy", + "weight": 10 + }, + { + "name": "datacenter_ip", + "weight": 10 + }, + { + "name": "abuser", + "weight": 10 + } + ], + "traffic_source": { + "channel": "Direct", + "click_id_type": "", + "landing_url": "https://example.com/", + "referrer_domain": "", + "utm_campaign": "", + "utm_content": "", + "utm_medium": "", + "utm_source": "", + "utm_term": "" + }, + "user_hid": null, + "visitor_id": "7a6b5c4d-3e2f-5a1b-9c8d-7e6f5a4b3c2d" + }, + "expected": { + "request_id": "13f84f05-7c2a-4e9b-9f1d-2a6b8c0e4d11", + "visitor_id": "7a6b5c4d-3e2f-5a1b-9c8d-7e6f5a4b3c2d", + "device_id": "6f1e2d3c-4b5a-5968-8776-655443322110", + "session_id": "3a2b1c0d-9e8f-4a7b-8c6d-5e4f3a2b1c0d", + "cookie_id": "2c9d1e8f-4b7a-4c3e-9d2f-1a8b7c6d5e4f", + "user_hid": null, + "domain": "example.com", + "public_ip": { + "ip": "203.0.113.10", + "country": "BY" + }, + "local_ip": { + "ip": "198.51.100.10", + "country": "BY" + }, + "connection_type": "proxy", + "os": "Windows", + "browser": "Chrome", + "device_type": "desktop", + "traffic_source": { + "channel": "Direct", + "referrer_domain": "", + "landing_url": "https://example.com/", + "click_id_type": "", + "utm_source": "", + "utm_medium": "", + "utm_campaign": "", + "utm_content": "", + "utm_term": "" + }, + "risk_score": 30, + "signals": [ + { + "name": "proxy", + "weight": 10, + "description": null + }, + { + "name": "datacenter_ip", + "weight": 10, + "description": null + }, + { + "name": "abuser", + "weight": 10, + "description": null + } + ], + "detection_flags": { + "vpn": false, + "privacy_relay": false, + "browser_vpn_proxy": false, + "tor": false, + "proxy": true, + "datacenter_ip": true, + "abuser": true, + "os_mismatch": false, + "os_not_detected": false, + "timezone_mismatch": false, + "anti_detect_browser": false, + "browser_automation": false, + "ip_mismatch": false, + "incognito": false, + "search_bot": false, + "suspicious_paid_click": false, + "javascript_disabled": false, + "stun_not_checked": false, + "check_incomplete": false + }, + "observed_at": "2026-09-30T12:34:56.000Z", + "source": "webhook" + } + } + ] +} diff --git a/test/data/normalizer-edge-cases.json b/test/data/normalizer-edge-cases.json new file mode 100644 index 0000000..04ff181 --- /dev/null +++ b/test/data/normalizer-edge-cases.json @@ -0,0 +1,4918 @@ +{ + "description": "Extra normalization cases for unusual input. slug_cases, history_time_cases and rfc3339_cases test the helpers directly; history_rows and webhook_data compare full Identification objects (raw excluded).", + "slug_cases": [ + { + "description": "\tIs tor", + "slug": "is_tor" + }, + { + "description": "Is tor\u001c", + "slug": "is_tor" + }, + { + "description": "\u001dIs tor\u001f", + "slug": "is_tor" + }, + { + "description": "\ufeffIs VPN", + "slug": "is_vpn" + }, + { + "description": "\u0085Is proxy\u0085", + "slug": "is_proxy" + }, + { + "description": "Is tor ", + "slug": "is_tor" + }, + { + "description": "os_mismatch (lower)", + "slug": "osmismatch" + }, + { + "description": "Sticky verdict: ", + "slug": "unknown" + }, + { + "description": "Sticky verdict:Antidetect", + "slug": "sticky_verdictantidetect" + }, + { + "description": "Sticky verdict: Is tor (request 11111111-2222-4333-8444-555555555555)", + "slug": "is_tor" + }, + { + "description": "Sticky verdict: Browser VPN/Proxy (request 1)", + "slug": "browser_vpn_proxy" + }, + { + "description": "Sticky verdict: IP \u2260 leakIP (192.0.2.1)", + "slug": "ip__neq__leakip" + }, + { + "description": "\u2260", + "slug": "neq" + }, + { + "description": "a\u2260b", + "slug": "a_neq_b" + }, + { + "description": "\u2260 edge \u2260", + "slug": "neq__edge__neq" + }, + { + "description": " -- ", + "slug": "unknown" + }, + { + "description": "A / B - C", + "slug": "a_b_c" + }, + { + "description": "A // B", + "slug": "a_b" + }, + { + "description": "Cr\u00e8me Br\u00fbl\u00e9e", + "slug": "cr\u00e8me_br\u00fbl\u00e9e" + }, + { + "description": "Cre\u0300me", + "slug": "creme" + }, + { + "description": "\u65e5\u672c\u8a9e \u30c6\u30b9\u30c8", + "slug": "\u65e5\u672c\u8a9e_\u30c6\u30b9\u30c8" + }, + { + "description": "Value 123 (x) (y)", + "slug": "value_123" + }, + { + "description": "Latency test", + "slug": "ws_tcp_latency" + }, + { + "description": "JavaScript disabled", + "slug": "javascript_disabled" + }, + { + "description": "JavaScript Disabled", + "slug": "javascript_disabled" + }, + { + "description": "\u0130stanbul", + "slug": "i\u0307stanbul" + }, + { + "description": "\u01c5", + "slug": "\u01c6" + }, + { + "description": "\u216b", + "slug": "unknown" + }, + { + "description": "x\u00b2", + "slug": "x" + }, + { + "description": "\u0663", + "slug": "\u0663" + }, + { + "description": "Tab\there", + "slug": "tabhere" + }, + { + "description": "under_score", + "slug": "underscore" + }, + { + "description": "(", + "slug": "unknown" + }, + { + "description": "Hello (", + "slug": "hello" + }, + { + "description": "IP \u2260 leakIP", + "slug": "ip__neq__leakip" + }, + { + "description": "Is VPN", + "slug": "is_vpn" + }, + { + "description": "\u00a0Is tor\u3000", + "slug": "is_tor" + }, + { + "description": "Browser Automation extra", + "slug": "browser_automation_extra" + }, + { + "description": "Antidetect browser", + "slug": "antidetect_browser" + }, + { + "description": "Antidetect", + "slug": "antidetect" + }, + { + "description": "OS mismatch2", + "slug": "os_mismatch2" + }, + { + "description": "Os_mismatch", + "slug": "os_mismatch" + }, + { + "description": "TCP handshake", + "slug": "tcp_handshake_v2" + }, + { + "description": "\ud83d\ude00 emoji \ud83d\ude00", + "slug": "emoji" + }, + { + "description": "Stra\u00dfe", + "slug": "stra\u00dfe" + }, + { + "description": "\u03a3IGMA", + "slug": "\u03c3igma" + } + ], + "history_time_cases": [ + { + "input": "2026-09-30 12:34:56", + "expected": "2026-09-30T12:34:56.000Z" + }, + { + "input": "2026-09-30T12:34:56.1", + "expected": "2026-09-30T12:34:56.100Z" + }, + { + "input": "2026-09-30 12:34:56.123456789", + "expected": "2026-09-30T12:34:56.123Z" + }, + { + "input": "2026-09-30 12:34:56.9999", + "expected": "2026-09-30T12:34:56.999Z" + }, + { + "input": " 2026-09-30 12:34:56.5 ", + "expected": "2026-09-30T12:34:56.500Z" + }, + { + "input": "\t2026-09-30 12:34:56.5\u0085", + "expected": "2026-09-30T12:34:56.500Z" + }, + { + "input": "2026-09-30 12:34:56+02:00", + "expected": "2026-09-30T12:34:56.000Z" + }, + { + "input": "2026-09-30 12:34:56Z", + "expected": "2026-09-30T12:34:56.000Z" + }, + { + "input": "2026-09-30 12:34:56-0530", + "expected": "2026-09-30T12:34:56.000Z" + }, + { + "input": "2024-02-29 00:00:00", + "expected": "2024-02-29T00:00:00.000Z" + }, + { + "input": "0001-01-01 00:00:00", + "expected": "0001-01-01T00:00:00.000Z" + }, + { + "input": "9999-12-31 23:59:59.999", + "expected": "9999-12-31T23:59:59.999Z" + }, + { + "input": "", + "expected": null + }, + { + "input": " ", + "expected": null + }, + { + "input": "not a date", + "expected": null + }, + { + "input": "2026-09-30", + "expected": null + }, + { + "input": "2026/09/30 12:34:56", + "expected": null + }, + { + "input": "2026-09-30 12:34:56.1234567890", + "expected": null + }, + { + "input": "2026-09-30 12:34", + "expected": null + }, + { + "input": "2026-09-30T12:34:56.123z", + "expected": null + } + ], + "rfc3339_cases": [ + { + "input": "2026-09-30T12:34:57.482913041Z", + "expected": "2026-09-30T12:34:57.482Z" + }, + { + "input": "2026-09-30T12:34:57Z", + "expected": "2026-09-30T12:34:57.000Z" + }, + { + "input": "2026-09-30T12:34:57.5+02:00", + "expected": "2026-09-30T10:34:57.500Z" + }, + { + "input": "2026-09-30T00:30:00-01:00", + "expected": "2026-09-30T01:30:00.000Z" + }, + { + "input": "2026-12-31T23:30:00-01:00", + "expected": "2027-01-01T00:30:00.000Z" + }, + { + "input": "2026-01-01T00:15:00+00:30", + "expected": "2025-12-31T23:45:00.000Z" + }, + { + "input": "2026-09-30T12:34:57Z\n", + "expected": "2026-09-30T12:34:57.000Z" + }, + { + "input": "2026-09-30 12:34:57Z", + "expected": null + }, + { + "input": "2026-09-30T12:34:57z", + "expected": null + }, + { + "input": "2026-09-30T12:34:57+0200", + "expected": null + }, + { + "input": "2026-09-30T12:34:57", + "expected": null + }, + { + "input": "", + "expected": null + }, + { + "input": "2026-09-30T12:34:57.1234567891Z", + "expected": null + }, + { + "input": " 2026-09-30T12:34:57Z", + "expected": null + }, + { + "input": "2024-02-29T23:59:59.999-00:01", + "expected": "2024-03-01T00:00:59.999Z" + }, + { + "input": "2026-01-15T09:00:00Z", + "expected": "2026-01-15T09:00:00.000Z" + } + ], + "history_rows": [ + { + "name": "details_mixed_entries", + "input": { + "request_id": "3b9c1e7a-5d2f-4a8b-9c6d-7e8f9a0b1c2d", + "session_id": "5c6d7e8f-9a0b-4c1d-8e2f-3a4b5c6d7e8f", + "cookie_id": "6d7e8f9a-0b1c-4d2e-9f3a-4b5c6d7e8f9a", + "domain": "example.com", + "user_hid": "anonymous", + "device_id": "7e8f9a0b-1c2d-5e3f-8a4b-5c6d7e8f9a0b", + "visitor_id": "8f9a0b1c-2d3e-5f4a-9b5c-6d7e8f9a0b1c", + "ip": "203.0.113.5", + "os": "Windows", + "browser": "Chrome", + "device_type": "desktop", + "country": "Germany", + "connection_type": "direct", + "score": 15, + "score_details": "[1, \"x\", null, {\"Value\": true, \"Description\": \"Is tor\"}, {\"Value\": 10.5, \"Description\": \"Is VPN\"}, {\"Value\": 15, \"Description\": \"Is VPN\"}, {\"Description\": \"Is proxy\"}, {\"Value\": 0, \"Description\": \"IP \u2260 leakIP (203.0.113.5 \u2260 198.51.100.9, source=shield)\"}, {\"Value\": -30, \"Description\": \"Stun passed (late arrival, corrected)\"}, {\"Value\": 5}]", + "created_at": "2026-09-30 10:00:00.250", + "ver": 1790762400250, + "web_rtc_ip": "203.0.113.5", + "web_rtc_country": "Germany", + "webrtc_leak_ip": "0.0.0.0", + "webrtc_leak_country": "", + "webrtc_leak_source": "none" + }, + "expected": { + "request_id": "3b9c1e7a-5d2f-4a8b-9c6d-7e8f9a0b1c2d", + "visitor_id": "8f9a0b1c-2d3e-5f4a-9b5c-6d7e8f9a0b1c", + "device_id": "7e8f9a0b-1c2d-5e3f-8a4b-5c6d7e8f9a0b", + "session_id": "5c6d7e8f-9a0b-4c1d-8e2f-3a4b5c6d7e8f", + "cookie_id": "6d7e8f9a-0b1c-4d2e-9f3a-4b5c6d7e8f9a", + "user_hid": "anonymous", + "domain": "example.com", + "public_ip": { + "ip": "203.0.113.5", + "country": "Germany" + }, + "local_ip": { + "ip": "203.0.113.5", + "country": "Germany" + }, + "connection_type": "direct", + "os": "Windows", + "browser": "Chrome", + "device_type": "desktop", + "traffic_source": { + "channel": "", + "referrer_domain": "", + "landing_url": "", + "click_id_type": "", + "utm_source": "", + "utm_medium": "", + "utm_campaign": "", + "utm_content": "", + "utm_term": "" + }, + "risk_score": 15, + "signals": [ + { + "name": "vpn", + "weight": 15, + "description": "Is VPN" + }, + { + "name": "stun_late_correction", + "weight": -30, + "description": "Stun passed (late arrival, corrected)" + }, + { + "name": "unknown", + "weight": 5, + "description": "" + } + ], + "detection_flags": { + "vpn": false, + "privacy_relay": false, + "browser_vpn_proxy": false, + "tor": false, + "proxy": false, + "datacenter_ip": false, + "abuser": false, + "os_mismatch": false, + "os_not_detected": false, + "timezone_mismatch": false, + "anti_detect_browser": false, + "browser_automation": false, + "ip_mismatch": true, + "incognito": false, + "search_bot": false, + "suspicious_paid_click": false, + "javascript_disabled": false, + "stun_not_checked": false, + "check_incomplete": false + }, + "observed_at": "2026-09-30T10:00:00.250Z", + "source": "history" + } + }, + { + "name": "details_invalid_json", + "input": { + "request_id": "3b9c1e7a-5d2f-4a8b-9c6d-7e8f9a0b1c2d", + "session_id": "5c6d7e8f-9a0b-4c1d-8e2f-3a4b5c6d7e8f", + "cookie_id": "6d7e8f9a-0b1c-4d2e-9f3a-4b5c6d7e8f9a", + "domain": "example.com", + "user_hid": "anonymous", + "device_id": "7e8f9a0b-1c2d-5e3f-8a4b-5c6d7e8f9a0b", + "visitor_id": "8f9a0b1c-2d3e-5f4a-9b5c-6d7e8f9a0b1c", + "ip": "203.0.113.5", + "os": "Windows", + "browser": "Chrome", + "device_type": "desktop", + "country": "Germany", + "connection_type": "direct", + "score": 15, + "score_details": "not json", + "created_at": "2026-09-30 10:00:00.250", + "ver": 1790762400250, + "web_rtc_ip": "203.0.113.5", + "web_rtc_country": "Germany", + "webrtc_leak_ip": "0.0.0.0", + "webrtc_leak_country": "", + "webrtc_leak_source": "none" + }, + "expected": { + "request_id": "3b9c1e7a-5d2f-4a8b-9c6d-7e8f9a0b1c2d", + "visitor_id": "8f9a0b1c-2d3e-5f4a-9b5c-6d7e8f9a0b1c", + "device_id": "7e8f9a0b-1c2d-5e3f-8a4b-5c6d7e8f9a0b", + "session_id": "5c6d7e8f-9a0b-4c1d-8e2f-3a4b5c6d7e8f", + "cookie_id": "6d7e8f9a-0b1c-4d2e-9f3a-4b5c6d7e8f9a", + "user_hid": "anonymous", + "domain": "example.com", + "public_ip": { + "ip": "203.0.113.5", + "country": "Germany" + }, + "local_ip": { + "ip": "203.0.113.5", + "country": "Germany" + }, + "connection_type": "direct", + "os": "Windows", + "browser": "Chrome", + "device_type": "desktop", + "traffic_source": { + "channel": "", + "referrer_domain": "", + "landing_url": "", + "click_id_type": "", + "utm_source": "", + "utm_medium": "", + "utm_campaign": "", + "utm_content": "", + "utm_term": "" + }, + "risk_score": 15, + "signals": [], + "detection_flags": { + "vpn": false, + "privacy_relay": false, + "browser_vpn_proxy": false, + "tor": false, + "proxy": false, + "datacenter_ip": false, + "abuser": false, + "os_mismatch": false, + "os_not_detected": false, + "timezone_mismatch": false, + "anti_detect_browser": false, + "browser_automation": false, + "ip_mismatch": false, + "incognito": false, + "search_bot": false, + "suspicious_paid_click": false, + "javascript_disabled": false, + "stun_not_checked": false, + "check_incomplete": false + }, + "observed_at": "2026-09-30T10:00:00.250Z", + "source": "history" + } + }, + { + "name": "details_object_not_list", + "input": { + "request_id": "3b9c1e7a-5d2f-4a8b-9c6d-7e8f9a0b1c2d", + "session_id": "5c6d7e8f-9a0b-4c1d-8e2f-3a4b5c6d7e8f", + "cookie_id": "6d7e8f9a-0b1c-4d2e-9f3a-4b5c6d7e8f9a", + "domain": "example.com", + "user_hid": "anonymous", + "device_id": "7e8f9a0b-1c2d-5e3f-8a4b-5c6d7e8f9a0b", + "visitor_id": "8f9a0b1c-2d3e-5f4a-9b5c-6d7e8f9a0b1c", + "ip": "203.0.113.5", + "os": "Windows", + "browser": "Chrome", + "device_type": "desktop", + "country": "Germany", + "connection_type": "direct", + "score": 15, + "score_details": "{\"Value\": 1, \"Description\": \"Is tor\"}", + "created_at": "2026-09-30 10:00:00.250", + "ver": 1790762400250, + "web_rtc_ip": "203.0.113.5", + "web_rtc_country": "Germany", + "webrtc_leak_ip": "0.0.0.0", + "webrtc_leak_country": "", + "webrtc_leak_source": "none" + }, + "expected": { + "request_id": "3b9c1e7a-5d2f-4a8b-9c6d-7e8f9a0b1c2d", + "visitor_id": "8f9a0b1c-2d3e-5f4a-9b5c-6d7e8f9a0b1c", + "device_id": "7e8f9a0b-1c2d-5e3f-8a4b-5c6d7e8f9a0b", + "session_id": "5c6d7e8f-9a0b-4c1d-8e2f-3a4b5c6d7e8f", + "cookie_id": "6d7e8f9a-0b1c-4d2e-9f3a-4b5c6d7e8f9a", + "user_hid": "anonymous", + "domain": "example.com", + "public_ip": { + "ip": "203.0.113.5", + "country": "Germany" + }, + "local_ip": { + "ip": "203.0.113.5", + "country": "Germany" + }, + "connection_type": "direct", + "os": "Windows", + "browser": "Chrome", + "device_type": "desktop", + "traffic_source": { + "channel": "", + "referrer_domain": "", + "landing_url": "", + "click_id_type": "", + "utm_source": "", + "utm_medium": "", + "utm_campaign": "", + "utm_content": "", + "utm_term": "" + }, + "risk_score": 15, + "signals": [], + "detection_flags": { + "vpn": false, + "privacy_relay": false, + "browser_vpn_proxy": false, + "tor": false, + "proxy": false, + "datacenter_ip": false, + "abuser": false, + "os_mismatch": false, + "os_not_detected": false, + "timezone_mismatch": false, + "anti_detect_browser": false, + "browser_automation": false, + "ip_mismatch": false, + "incognito": false, + "search_bot": false, + "suspicious_paid_click": false, + "javascript_disabled": false, + "stun_not_checked": false, + "check_incomplete": false + }, + "observed_at": "2026-09-30T10:00:00.250Z", + "source": "history" + } + }, + { + "name": "details_missing", + "input": { + "request_id": "3b9c1e7a-5d2f-4a8b-9c6d-7e8f9a0b1c2d", + "session_id": "5c6d7e8f-9a0b-4c1d-8e2f-3a4b5c6d7e8f", + "cookie_id": "6d7e8f9a-0b1c-4d2e-9f3a-4b5c6d7e8f9a", + "domain": "example.com", + "user_hid": "anonymous", + "device_id": "7e8f9a0b-1c2d-5e3f-8a4b-5c6d7e8f9a0b", + "visitor_id": "8f9a0b1c-2d3e-5f4a-9b5c-6d7e8f9a0b1c", + "ip": "203.0.113.5", + "os": "Windows", + "browser": "Chrome", + "device_type": "desktop", + "country": "Germany", + "connection_type": "direct", + "score": 15, + "created_at": "2026-09-30 10:00:00.250", + "ver": 1790762400250, + "web_rtc_ip": "203.0.113.5", + "web_rtc_country": "Germany", + "webrtc_leak_ip": "0.0.0.0", + "webrtc_leak_country": "", + "webrtc_leak_source": "none" + }, + "expected": { + "request_id": "3b9c1e7a-5d2f-4a8b-9c6d-7e8f9a0b1c2d", + "visitor_id": "8f9a0b1c-2d3e-5f4a-9b5c-6d7e8f9a0b1c", + "device_id": "7e8f9a0b-1c2d-5e3f-8a4b-5c6d7e8f9a0b", + "session_id": "5c6d7e8f-9a0b-4c1d-8e2f-3a4b5c6d7e8f", + "cookie_id": "6d7e8f9a-0b1c-4d2e-9f3a-4b5c6d7e8f9a", + "user_hid": "anonymous", + "domain": "example.com", + "public_ip": { + "ip": "203.0.113.5", + "country": "Germany" + }, + "local_ip": { + "ip": "203.0.113.5", + "country": "Germany" + }, + "connection_type": "direct", + "os": "Windows", + "browser": "Chrome", + "device_type": "desktop", + "traffic_source": { + "channel": "", + "referrer_domain": "", + "landing_url": "", + "click_id_type": "", + "utm_source": "", + "utm_medium": "", + "utm_campaign": "", + "utm_content": "", + "utm_term": "" + }, + "risk_score": 15, + "signals": [], + "detection_flags": { + "vpn": false, + "privacy_relay": false, + "browser_vpn_proxy": false, + "tor": false, + "proxy": false, + "datacenter_ip": false, + "abuser": false, + "os_mismatch": false, + "os_not_detected": false, + "timezone_mismatch": false, + "anti_detect_browser": false, + "browser_automation": false, + "ip_mismatch": false, + "incognito": false, + "search_bot": false, + "suspicious_paid_click": false, + "javascript_disabled": false, + "stun_not_checked": false, + "check_incomplete": false + }, + "observed_at": "2026-09-30T10:00:00.250Z", + "source": "history" + } + }, + { + "name": "details_json_null", + "input": { + "request_id": "3b9c1e7a-5d2f-4a8b-9c6d-7e8f9a0b1c2d", + "session_id": "5c6d7e8f-9a0b-4c1d-8e2f-3a4b5c6d7e8f", + "cookie_id": "6d7e8f9a-0b1c-4d2e-9f3a-4b5c6d7e8f9a", + "domain": "example.com", + "user_hid": "anonymous", + "device_id": "7e8f9a0b-1c2d-5e3f-8a4b-5c6d7e8f9a0b", + "visitor_id": "8f9a0b1c-2d3e-5f4a-9b5c-6d7e8f9a0b1c", + "ip": "203.0.113.5", + "os": "Windows", + "browser": "Chrome", + "device_type": "desktop", + "country": "Germany", + "connection_type": "direct", + "score": 15, + "score_details": "null", + "created_at": "2026-09-30 10:00:00.250", + "ver": 1790762400250, + "web_rtc_ip": "203.0.113.5", + "web_rtc_country": "Germany", + "webrtc_leak_ip": "0.0.0.0", + "webrtc_leak_country": "", + "webrtc_leak_source": "none" + }, + "expected": { + "request_id": "3b9c1e7a-5d2f-4a8b-9c6d-7e8f9a0b1c2d", + "visitor_id": "8f9a0b1c-2d3e-5f4a-9b5c-6d7e8f9a0b1c", + "device_id": "7e8f9a0b-1c2d-5e3f-8a4b-5c6d7e8f9a0b", + "session_id": "5c6d7e8f-9a0b-4c1d-8e2f-3a4b5c6d7e8f", + "cookie_id": "6d7e8f9a-0b1c-4d2e-9f3a-4b5c6d7e8f9a", + "user_hid": "anonymous", + "domain": "example.com", + "public_ip": { + "ip": "203.0.113.5", + "country": "Germany" + }, + "local_ip": { + "ip": "203.0.113.5", + "country": "Germany" + }, + "connection_type": "direct", + "os": "Windows", + "browser": "Chrome", + "device_type": "desktop", + "traffic_source": { + "channel": "", + "referrer_domain": "", + "landing_url": "", + "click_id_type": "", + "utm_source": "", + "utm_medium": "", + "utm_campaign": "", + "utm_content": "", + "utm_term": "" + }, + "risk_score": 15, + "signals": [], + "detection_flags": { + "vpn": false, + "privacy_relay": false, + "browser_vpn_proxy": false, + "tor": false, + "proxy": false, + "datacenter_ip": false, + "abuser": false, + "os_mismatch": false, + "os_not_detected": false, + "timezone_mismatch": false, + "anti_detect_browser": false, + "browser_automation": false, + "ip_mismatch": false, + "incognito": false, + "search_bot": false, + "suspicious_paid_click": false, + "javascript_disabled": false, + "stun_not_checked": false, + "check_incomplete": false + }, + "observed_at": "2026-09-30T10:00:00.250Z", + "source": "history" + } + }, + { + "name": "flag_truthiness", + "input": { + "request_id": "3b9c1e7a-5d2f-4a8b-9c6d-7e8f9a0b1c2d", + "session_id": "5c6d7e8f-9a0b-4c1d-8e2f-3a4b5c6d7e8f", + "cookie_id": "6d7e8f9a-0b1c-4d2e-9f3a-4b5c6d7e8f9a", + "domain": "example.com", + "user_hid": "anonymous", + "device_id": "7e8f9a0b-1c2d-5e3f-8a4b-5c6d7e8f9a0b", + "visitor_id": "8f9a0b1c-2d3e-5f4a-9b5c-6d7e8f9a0b1c", + "ip": "203.0.113.5", + "os": "Windows", + "browser": "Chrome", + "device_type": "desktop", + "country": "Germany", + "connection_type": "direct", + "score": 15, + "score_details": "[]", + "created_at": "2026-09-30 10:00:00.250", + "ver": 1790762400250, + "web_rtc_ip": "203.0.113.5", + "web_rtc_country": "Germany", + "webrtc_leak_ip": "0.0.0.0", + "webrtc_leak_country": "", + "webrtc_leak_source": "none", + "is_vpn": 1, + "is_tor": "yes", + "is_proxy": "", + "is_datacenter": 0, + "is_abuser": [], + "is_antidetect": [ + 1 + ], + "is_incognito": {}, + "is_js_disabled": { + "a": 1 + }, + "is_browser_automation": null, + "check_incomplete": true, + "is_suspicious_paid_click": false + }, + "expected": { + "request_id": "3b9c1e7a-5d2f-4a8b-9c6d-7e8f9a0b1c2d", + "visitor_id": "8f9a0b1c-2d3e-5f4a-9b5c-6d7e8f9a0b1c", + "device_id": "7e8f9a0b-1c2d-5e3f-8a4b-5c6d7e8f9a0b", + "session_id": "5c6d7e8f-9a0b-4c1d-8e2f-3a4b5c6d7e8f", + "cookie_id": "6d7e8f9a-0b1c-4d2e-9f3a-4b5c6d7e8f9a", + "user_hid": "anonymous", + "domain": "example.com", + "public_ip": { + "ip": "203.0.113.5", + "country": "Germany" + }, + "local_ip": { + "ip": "203.0.113.5", + "country": "Germany" + }, + "connection_type": "direct", + "os": "Windows", + "browser": "Chrome", + "device_type": "desktop", + "traffic_source": { + "channel": "", + "referrer_domain": "", + "landing_url": "", + "click_id_type": "", + "utm_source": "", + "utm_medium": "", + "utm_campaign": "", + "utm_content": "", + "utm_term": "" + }, + "risk_score": 15, + "signals": [], + "detection_flags": { + "vpn": true, + "privacy_relay": false, + "browser_vpn_proxy": false, + "tor": true, + "proxy": false, + "datacenter_ip": false, + "abuser": false, + "os_mismatch": false, + "os_not_detected": false, + "timezone_mismatch": false, + "anti_detect_browser": true, + "browser_automation": false, + "ip_mismatch": false, + "incognito": false, + "search_bot": false, + "suspicious_paid_click": false, + "javascript_disabled": true, + "stun_not_checked": false, + "check_incomplete": true + }, + "observed_at": "2026-09-30T10:00:00.250Z", + "source": "history" + } + }, + { + "name": "leak_source_capitalized_none", + "input": { + "request_id": "3b9c1e7a-5d2f-4a8b-9c6d-7e8f9a0b1c2d", + "session_id": "5c6d7e8f-9a0b-4c1d-8e2f-3a4b5c6d7e8f", + "cookie_id": "6d7e8f9a-0b1c-4d2e-9f3a-4b5c6d7e8f9a", + "domain": "example.com", + "user_hid": "anonymous", + "device_id": "7e8f9a0b-1c2d-5e3f-8a4b-5c6d7e8f9a0b", + "visitor_id": "8f9a0b1c-2d3e-5f4a-9b5c-6d7e8f9a0b1c", + "ip": "203.0.113.5", + "os": "Windows", + "browser": "Chrome", + "device_type": "desktop", + "country": "Germany", + "connection_type": "direct", + "score": 15, + "score_details": "[]", + "created_at": "2026-09-30 10:00:00.250", + "ver": 1790762400250, + "web_rtc_ip": "203.0.113.5", + "web_rtc_country": "Germany", + "webrtc_leak_ip": "198.51.100.9", + "webrtc_leak_country": "Spain", + "webrtc_leak_source": "None" + }, + "expected": { + "request_id": "3b9c1e7a-5d2f-4a8b-9c6d-7e8f9a0b1c2d", + "visitor_id": "8f9a0b1c-2d3e-5f4a-9b5c-6d7e8f9a0b1c", + "device_id": "7e8f9a0b-1c2d-5e3f-8a4b-5c6d7e8f9a0b", + "session_id": "5c6d7e8f-9a0b-4c1d-8e2f-3a4b5c6d7e8f", + "cookie_id": "6d7e8f9a-0b1c-4d2e-9f3a-4b5c6d7e8f9a", + "user_hid": "anonymous", + "domain": "example.com", + "public_ip": { + "ip": "203.0.113.5", + "country": "Germany" + }, + "local_ip": { + "ip": "198.51.100.9", + "country": "Spain" + }, + "connection_type": "direct", + "os": "Windows", + "browser": "Chrome", + "device_type": "desktop", + "traffic_source": { + "channel": "", + "referrer_domain": "", + "landing_url": "", + "click_id_type": "", + "utm_source": "", + "utm_medium": "", + "utm_campaign": "", + "utm_content": "", + "utm_term": "" + }, + "risk_score": 15, + "signals": [], + "detection_flags": { + "vpn": false, + "privacy_relay": false, + "browser_vpn_proxy": false, + "tor": false, + "proxy": false, + "datacenter_ip": false, + "abuser": false, + "os_mismatch": false, + "os_not_detected": false, + "timezone_mismatch": false, + "anti_detect_browser": false, + "browser_automation": false, + "ip_mismatch": true, + "incognito": false, + "search_bot": false, + "suspicious_paid_click": false, + "javascript_disabled": false, + "stun_not_checked": false, + "check_incomplete": false + }, + "observed_at": "2026-09-30T10:00:00.250Z", + "source": "history" + } + }, + { + "name": "leak_source_padded_none", + "input": { + "request_id": "3b9c1e7a-5d2f-4a8b-9c6d-7e8f9a0b1c2d", + "session_id": "5c6d7e8f-9a0b-4c1d-8e2f-3a4b5c6d7e8f", + "cookie_id": "6d7e8f9a-0b1c-4d2e-9f3a-4b5c6d7e8f9a", + "domain": "example.com", + "user_hid": "anonymous", + "device_id": "7e8f9a0b-1c2d-5e3f-8a4b-5c6d7e8f9a0b", + "visitor_id": "8f9a0b1c-2d3e-5f4a-9b5c-6d7e8f9a0b1c", + "ip": "203.0.113.5", + "os": "Windows", + "browser": "Chrome", + "device_type": "desktop", + "country": "Germany", + "connection_type": "direct", + "score": 15, + "score_details": "[]", + "created_at": "2026-09-30 10:00:00.250", + "ver": 1790762400250, + "web_rtc_ip": "203.0.113.5", + "web_rtc_country": "Germany", + "webrtc_leak_ip": "198.51.100.9", + "webrtc_leak_country": "Spain", + "webrtc_leak_source": " none " + }, + "expected": { + "request_id": "3b9c1e7a-5d2f-4a8b-9c6d-7e8f9a0b1c2d", + "visitor_id": "8f9a0b1c-2d3e-5f4a-9b5c-6d7e8f9a0b1c", + "device_id": "7e8f9a0b-1c2d-5e3f-8a4b-5c6d7e8f9a0b", + "session_id": "5c6d7e8f-9a0b-4c1d-8e2f-3a4b5c6d7e8f", + "cookie_id": "6d7e8f9a-0b1c-4d2e-9f3a-4b5c6d7e8f9a", + "user_hid": "anonymous", + "domain": "example.com", + "public_ip": { + "ip": "203.0.113.5", + "country": "Germany" + }, + "local_ip": { + "ip": "203.0.113.5", + "country": "Germany" + }, + "connection_type": "direct", + "os": "Windows", + "browser": "Chrome", + "device_type": "desktop", + "traffic_source": { + "channel": "", + "referrer_domain": "", + "landing_url": "", + "click_id_type": "", + "utm_source": "", + "utm_medium": "", + "utm_campaign": "", + "utm_content": "", + "utm_term": "" + }, + "risk_score": 15, + "signals": [], + "detection_flags": { + "vpn": false, + "privacy_relay": false, + "browser_vpn_proxy": false, + "tor": false, + "proxy": false, + "datacenter_ip": false, + "abuser": false, + "os_mismatch": false, + "os_not_detected": false, + "timezone_mismatch": false, + "anti_detect_browser": false, + "browser_automation": false, + "ip_mismatch": false, + "incognito": false, + "search_bot": false, + "suspicious_paid_click": false, + "javascript_disabled": false, + "stun_not_checked": false, + "check_incomplete": false + }, + "observed_at": "2026-09-30T10:00:00.250Z", + "source": "history" + } + }, + { + "name": "leak_source_shield_no_ip", + "input": { + "request_id": "3b9c1e7a-5d2f-4a8b-9c6d-7e8f9a0b1c2d", + "session_id": "5c6d7e8f-9a0b-4c1d-8e2f-3a4b5c6d7e8f", + "cookie_id": "6d7e8f9a-0b1c-4d2e-9f3a-4b5c6d7e8f9a", + "domain": "example.com", + "user_hid": "anonymous", + "device_id": "7e8f9a0b-1c2d-5e3f-8a4b-5c6d7e8f9a0b", + "visitor_id": "8f9a0b1c-2d3e-5f4a-9b5c-6d7e8f9a0b1c", + "ip": "203.0.113.5", + "os": "Windows", + "browser": "Chrome", + "device_type": "desktop", + "country": "Germany", + "connection_type": "direct", + "score": 15, + "score_details": "[]", + "created_at": "2026-09-30 10:00:00.250", + "ver": 1790762400250, + "web_rtc_ip": "198.51.100.4", + "web_rtc_country": "Germany", + "webrtc_leak_ip": "0.0.0.0", + "webrtc_leak_country": "", + "webrtc_leak_source": "shield" + }, + "expected": { + "request_id": "3b9c1e7a-5d2f-4a8b-9c6d-7e8f9a0b1c2d", + "visitor_id": "8f9a0b1c-2d3e-5f4a-9b5c-6d7e8f9a0b1c", + "device_id": "7e8f9a0b-1c2d-5e3f-8a4b-5c6d7e8f9a0b", + "session_id": "5c6d7e8f-9a0b-4c1d-8e2f-3a4b5c6d7e8f", + "cookie_id": "6d7e8f9a-0b1c-4d2e-9f3a-4b5c6d7e8f9a", + "user_hid": "anonymous", + "domain": "example.com", + "public_ip": { + "ip": "203.0.113.5", + "country": "Germany" + }, + "local_ip": { + "ip": "", + "country": "" + }, + "connection_type": "direct", + "os": "Windows", + "browser": "Chrome", + "device_type": "desktop", + "traffic_source": { + "channel": "", + "referrer_domain": "", + "landing_url": "", + "click_id_type": "", + "utm_source": "", + "utm_medium": "", + "utm_campaign": "", + "utm_content": "", + "utm_term": "" + }, + "risk_score": 15, + "signals": [], + "detection_flags": { + "vpn": false, + "privacy_relay": false, + "browser_vpn_proxy": false, + "tor": false, + "proxy": false, + "datacenter_ip": false, + "abuser": false, + "os_mismatch": false, + "os_not_detected": false, + "timezone_mismatch": false, + "anti_detect_browser": false, + "browser_automation": false, + "ip_mismatch": false, + "incognito": false, + "search_bot": false, + "suspicious_paid_click": false, + "javascript_disabled": false, + "stun_not_checked": false, + "check_incomplete": false + }, + "observed_at": "2026-09-30T10:00:00.250Z", + "source": "history" + } + }, + { + "name": "ip_padding_and_sentinel", + "input": { + "request_id": "3b9c1e7a-5d2f-4a8b-9c6d-7e8f9a0b1c2d", + "session_id": "5c6d7e8f-9a0b-4c1d-8e2f-3a4b5c6d7e8f", + "cookie_id": "6d7e8f9a-0b1c-4d2e-9f3a-4b5c6d7e8f9a", + "domain": "example.com", + "user_hid": "anonymous", + "device_id": "7e8f9a0b-1c2d-5e3f-8a4b-5c6d7e8f9a0b", + "visitor_id": "8f9a0b1c-2d3e-5f4a-9b5c-6d7e8f9a0b1c", + "ip": " 203.0.113.5 ", + "os": "Windows", + "browser": "Chrome", + "device_type": "desktop", + "country": "Germany", + "connection_type": "direct", + "score": 15, + "score_details": "[]", + "created_at": "2026-09-30 10:00:00.250", + "ver": 1790762400250, + "web_rtc_ip": " 0.0.0.0 ", + "web_rtc_country": "Germany", + "webrtc_leak_ip": "0.0.0.0", + "webrtc_leak_country": "", + "webrtc_leak_source": "none" + }, + "expected": { + "request_id": "3b9c1e7a-5d2f-4a8b-9c6d-7e8f9a0b1c2d", + "visitor_id": "8f9a0b1c-2d3e-5f4a-9b5c-6d7e8f9a0b1c", + "device_id": "7e8f9a0b-1c2d-5e3f-8a4b-5c6d7e8f9a0b", + "session_id": "5c6d7e8f-9a0b-4c1d-8e2f-3a4b5c6d7e8f", + "cookie_id": "6d7e8f9a-0b1c-4d2e-9f3a-4b5c6d7e8f9a", + "user_hid": "anonymous", + "domain": "example.com", + "public_ip": { + "ip": "203.0.113.5", + "country": "Germany" + }, + "local_ip": { + "ip": "", + "country": "Germany" + }, + "connection_type": "direct", + "os": "Windows", + "browser": "Chrome", + "device_type": "desktop", + "traffic_source": { + "channel": "", + "referrer_domain": "", + "landing_url": "", + "click_id_type": "", + "utm_source": "", + "utm_medium": "", + "utm_campaign": "", + "utm_content": "", + "utm_term": "" + }, + "risk_score": 15, + "signals": [], + "detection_flags": { + "vpn": false, + "privacy_relay": false, + "browser_vpn_proxy": false, + "tor": false, + "proxy": false, + "datacenter_ip": false, + "abuser": false, + "os_mismatch": false, + "os_not_detected": false, + "timezone_mismatch": false, + "anti_detect_browser": false, + "browser_automation": false, + "ip_mismatch": false, + "incognito": false, + "search_bot": false, + "suspicious_paid_click": false, + "javascript_disabled": false, + "stun_not_checked": false, + "check_incomplete": false + }, + "observed_at": "2026-09-30T10:00:00.250Z", + "source": "history" + } + }, + { + "name": "search_bot_suppresses_ip_mismatch", + "input": { + "request_id": "3b9c1e7a-5d2f-4a8b-9c6d-7e8f9a0b1c2d", + "session_id": "5c6d7e8f-9a0b-4c1d-8e2f-3a4b5c6d7e8f", + "cookie_id": "6d7e8f9a-0b1c-4d2e-9f3a-4b5c6d7e8f9a", + "domain": "example.com", + "user_hid": "anonymous", + "device_id": "7e8f9a0b-1c2d-5e3f-8a4b-5c6d7e8f9a0b", + "visitor_id": "8f9a0b1c-2d3e-5f4a-9b5c-6d7e8f9a0b1c", + "ip": "203.0.113.5", + "os": "Windows", + "browser": "Chrome", + "device_type": "desktop", + "country": "Germany", + "connection_type": "direct", + "score": 15, + "score_details": "[{\"Value\": 0, \"Description\": \"IP \u2260 leakIP (x)\"}]", + "created_at": "2026-09-30 10:00:00.250", + "ver": 1790762400250, + "web_rtc_ip": "198.51.100.4", + "web_rtc_country": "Germany", + "webrtc_leak_ip": "0.0.0.0", + "webrtc_leak_country": "", + "webrtc_leak_source": "none", + "is_search_bot": true + }, + "expected": { + "request_id": "3b9c1e7a-5d2f-4a8b-9c6d-7e8f9a0b1c2d", + "visitor_id": "8f9a0b1c-2d3e-5f4a-9b5c-6d7e8f9a0b1c", + "device_id": "7e8f9a0b-1c2d-5e3f-8a4b-5c6d7e8f9a0b", + "session_id": "5c6d7e8f-9a0b-4c1d-8e2f-3a4b5c6d7e8f", + "cookie_id": "6d7e8f9a-0b1c-4d2e-9f3a-4b5c6d7e8f9a", + "user_hid": "anonymous", + "domain": "example.com", + "public_ip": { + "ip": "203.0.113.5", + "country": "Germany" + }, + "local_ip": { + "ip": "198.51.100.4", + "country": "Germany" + }, + "connection_type": "direct", + "os": "Windows", + "browser": "Chrome", + "device_type": "desktop", + "traffic_source": { + "channel": "", + "referrer_domain": "", + "landing_url": "", + "click_id_type": "", + "utm_source": "", + "utm_medium": "", + "utm_campaign": "", + "utm_content": "", + "utm_term": "" + }, + "risk_score": 15, + "signals": [], + "detection_flags": { + "vpn": false, + "privacy_relay": false, + "browser_vpn_proxy": false, + "tor": false, + "proxy": false, + "datacenter_ip": false, + "abuser": false, + "os_mismatch": false, + "os_not_detected": false, + "timezone_mismatch": false, + "anti_detect_browser": false, + "browser_automation": false, + "ip_mismatch": false, + "incognito": false, + "search_bot": true, + "suspicious_paid_click": false, + "javascript_disabled": false, + "stun_not_checked": false, + "check_incomplete": false + }, + "observed_at": "2026-09-30T10:00:00.250Z", + "source": "history" + } + }, + { + "name": "site_domain_empty", + "input": { + "request_id": "3b9c1e7a-5d2f-4a8b-9c6d-7e8f9a0b1c2d", + "session_id": "5c6d7e8f-9a0b-4c1d-8e2f-3a4b5c6d7e8f", + "cookie_id": "6d7e8f9a-0b1c-4d2e-9f3a-4b5c6d7e8f9a", + "domain": "shop.example.com", + "user_hid": "anonymous", + "device_id": "7e8f9a0b-1c2d-5e3f-8a4b-5c6d7e8f9a0b", + "visitor_id": "8f9a0b1c-2d3e-5f4a-9b5c-6d7e8f9a0b1c", + "ip": "203.0.113.5", + "os": "Windows", + "browser": "Chrome", + "device_type": "desktop", + "country": "Germany", + "connection_type": "direct", + "score": 15, + "score_details": "[]", + "created_at": "2026-09-30 10:00:00.250", + "ver": 1790762400250, + "web_rtc_ip": "203.0.113.5", + "web_rtc_country": "Germany", + "webrtc_leak_ip": "0.0.0.0", + "webrtc_leak_country": "", + "webrtc_leak_source": "none", + "site_domain": "" + }, + "expected": { + "request_id": "3b9c1e7a-5d2f-4a8b-9c6d-7e8f9a0b1c2d", + "visitor_id": "8f9a0b1c-2d3e-5f4a-9b5c-6d7e8f9a0b1c", + "device_id": "7e8f9a0b-1c2d-5e3f-8a4b-5c6d7e8f9a0b", + "session_id": "5c6d7e8f-9a0b-4c1d-8e2f-3a4b5c6d7e8f", + "cookie_id": "6d7e8f9a-0b1c-4d2e-9f3a-4b5c6d7e8f9a", + "user_hid": "anonymous", + "domain": "shop.example.com", + "public_ip": { + "ip": "203.0.113.5", + "country": "Germany" + }, + "local_ip": { + "ip": "203.0.113.5", + "country": "Germany" + }, + "connection_type": "direct", + "os": "Windows", + "browser": "Chrome", + "device_type": "desktop", + "traffic_source": { + "channel": "", + "referrer_domain": "", + "landing_url": "", + "click_id_type": "", + "utm_source": "", + "utm_medium": "", + "utm_campaign": "", + "utm_content": "", + "utm_term": "" + }, + "risk_score": 15, + "signals": [], + "detection_flags": { + "vpn": false, + "privacy_relay": false, + "browser_vpn_proxy": false, + "tor": false, + "proxy": false, + "datacenter_ip": false, + "abuser": false, + "os_mismatch": false, + "os_not_detected": false, + "timezone_mismatch": false, + "anti_detect_browser": false, + "browser_automation": false, + "ip_mismatch": false, + "incognito": false, + "search_bot": false, + "suspicious_paid_click": false, + "javascript_disabled": false, + "stun_not_checked": false, + "check_incomplete": false + }, + "observed_at": "2026-09-30T10:00:00.250Z", + "source": "history" + } + }, + { + "name": "site_domain_set", + "input": { + "request_id": "3b9c1e7a-5d2f-4a8b-9c6d-7e8f9a0b1c2d", + "session_id": "5c6d7e8f-9a0b-4c1d-8e2f-3a4b5c6d7e8f", + "cookie_id": "6d7e8f9a-0b1c-4d2e-9f3a-4b5c6d7e8f9a", + "domain": "shop.example.com", + "user_hid": "anonymous", + "device_id": "7e8f9a0b-1c2d-5e3f-8a4b-5c6d7e8f9a0b", + "visitor_id": "8f9a0b1c-2d3e-5f4a-9b5c-6d7e8f9a0b1c", + "ip": "203.0.113.5", + "os": "Windows", + "browser": "Chrome", + "device_type": "desktop", + "country": "Germany", + "connection_type": "direct", + "score": 15, + "score_details": "[]", + "created_at": "2026-09-30 10:00:00.250", + "ver": 1790762400250, + "web_rtc_ip": "203.0.113.5", + "web_rtc_country": "Germany", + "webrtc_leak_ip": "0.0.0.0", + "webrtc_leak_country": "", + "webrtc_leak_source": "none", + "site_domain": "example.com" + }, + "expected": { + "request_id": "3b9c1e7a-5d2f-4a8b-9c6d-7e8f9a0b1c2d", + "visitor_id": "8f9a0b1c-2d3e-5f4a-9b5c-6d7e8f9a0b1c", + "device_id": "7e8f9a0b-1c2d-5e3f-8a4b-5c6d7e8f9a0b", + "session_id": "5c6d7e8f-9a0b-4c1d-8e2f-3a4b5c6d7e8f", + "cookie_id": "6d7e8f9a-0b1c-4d2e-9f3a-4b5c6d7e8f9a", + "user_hid": "anonymous", + "domain": "example.com", + "public_ip": { + "ip": "203.0.113.5", + "country": "Germany" + }, + "local_ip": { + "ip": "203.0.113.5", + "country": "Germany" + }, + "connection_type": "direct", + "os": "Windows", + "browser": "Chrome", + "device_type": "desktop", + "traffic_source": { + "channel": "", + "referrer_domain": "", + "landing_url": "", + "click_id_type": "", + "utm_source": "", + "utm_medium": "", + "utm_campaign": "", + "utm_content": "", + "utm_term": "" + }, + "risk_score": 15, + "signals": [], + "detection_flags": { + "vpn": false, + "privacy_relay": false, + "browser_vpn_proxy": false, + "tor": false, + "proxy": false, + "datacenter_ip": false, + "abuser": false, + "os_mismatch": false, + "os_not_detected": false, + "timezone_mismatch": false, + "anti_detect_browser": false, + "browser_automation": false, + "ip_mismatch": false, + "incognito": false, + "search_bot": false, + "suspicious_paid_click": false, + "javascript_disabled": false, + "stun_not_checked": false, + "check_incomplete": false + }, + "observed_at": "2026-09-30T10:00:00.250Z", + "source": "history" + } + }, + { + "name": "user_hid_empty", + "input": { + "request_id": "3b9c1e7a-5d2f-4a8b-9c6d-7e8f9a0b1c2d", + "session_id": "5c6d7e8f-9a0b-4c1d-8e2f-3a4b5c6d7e8f", + "cookie_id": "6d7e8f9a-0b1c-4d2e-9f3a-4b5c6d7e8f9a", + "domain": "example.com", + "user_hid": "", + "device_id": "7e8f9a0b-1c2d-5e3f-8a4b-5c6d7e8f9a0b", + "visitor_id": "8f9a0b1c-2d3e-5f4a-9b5c-6d7e8f9a0b1c", + "ip": "203.0.113.5", + "os": "Windows", + "browser": "Chrome", + "device_type": "desktop", + "country": "Germany", + "connection_type": "direct", + "score": 15, + "score_details": "[]", + "created_at": "2026-09-30 10:00:00.250", + "ver": 1790762400250, + "web_rtc_ip": "203.0.113.5", + "web_rtc_country": "Germany", + "webrtc_leak_ip": "0.0.0.0", + "webrtc_leak_country": "", + "webrtc_leak_source": "none" + }, + "expected": { + "request_id": "3b9c1e7a-5d2f-4a8b-9c6d-7e8f9a0b1c2d", + "visitor_id": "8f9a0b1c-2d3e-5f4a-9b5c-6d7e8f9a0b1c", + "device_id": "7e8f9a0b-1c2d-5e3f-8a4b-5c6d7e8f9a0b", + "session_id": "5c6d7e8f-9a0b-4c1d-8e2f-3a4b5c6d7e8f", + "cookie_id": "6d7e8f9a-0b1c-4d2e-9f3a-4b5c6d7e8f9a", + "user_hid": null, + "domain": "example.com", + "public_ip": { + "ip": "203.0.113.5", + "country": "Germany" + }, + "local_ip": { + "ip": "203.0.113.5", + "country": "Germany" + }, + "connection_type": "direct", + "os": "Windows", + "browser": "Chrome", + "device_type": "desktop", + "traffic_source": { + "channel": "", + "referrer_domain": "", + "landing_url": "", + "click_id_type": "", + "utm_source": "", + "utm_medium": "", + "utm_campaign": "", + "utm_content": "", + "utm_term": "" + }, + "risk_score": 15, + "signals": [], + "detection_flags": { + "vpn": false, + "privacy_relay": false, + "browser_vpn_proxy": false, + "tor": false, + "proxy": false, + "datacenter_ip": false, + "abuser": false, + "os_mismatch": false, + "os_not_detected": false, + "timezone_mismatch": false, + "anti_detect_browser": false, + "browser_automation": false, + "ip_mismatch": false, + "incognito": false, + "search_bot": false, + "suspicious_paid_click": false, + "javascript_disabled": false, + "stun_not_checked": false, + "check_incomplete": false + }, + "observed_at": "2026-09-30T10:00:00.250Z", + "source": "history" + } + }, + { + "name": "user_hid_missing", + "input": { + "request_id": "3b9c1e7a-5d2f-4a8b-9c6d-7e8f9a0b1c2d", + "session_id": "5c6d7e8f-9a0b-4c1d-8e2f-3a4b5c6d7e8f", + "cookie_id": "6d7e8f9a-0b1c-4d2e-9f3a-4b5c6d7e8f9a", + "domain": "example.com", + "device_id": "7e8f9a0b-1c2d-5e3f-8a4b-5c6d7e8f9a0b", + "visitor_id": "8f9a0b1c-2d3e-5f4a-9b5c-6d7e8f9a0b1c", + "ip": "203.0.113.5", + "os": "Windows", + "browser": "Chrome", + "device_type": "desktop", + "country": "Germany", + "connection_type": "direct", + "score": 15, + "score_details": "[]", + "created_at": "2026-09-30 10:00:00.250", + "ver": 1790762400250, + "web_rtc_ip": "203.0.113.5", + "web_rtc_country": "Germany", + "webrtc_leak_ip": "0.0.0.0", + "webrtc_leak_country": "", + "webrtc_leak_source": "none" + }, + "expected": { + "request_id": "3b9c1e7a-5d2f-4a8b-9c6d-7e8f9a0b1c2d", + "visitor_id": "8f9a0b1c-2d3e-5f4a-9b5c-6d7e8f9a0b1c", + "device_id": "7e8f9a0b-1c2d-5e3f-8a4b-5c6d7e8f9a0b", + "session_id": "5c6d7e8f-9a0b-4c1d-8e2f-3a4b5c6d7e8f", + "cookie_id": "6d7e8f9a-0b1c-4d2e-9f3a-4b5c6d7e8f9a", + "user_hid": null, + "domain": "example.com", + "public_ip": { + "ip": "203.0.113.5", + "country": "Germany" + }, + "local_ip": { + "ip": "203.0.113.5", + "country": "Germany" + }, + "connection_type": "direct", + "os": "Windows", + "browser": "Chrome", + "device_type": "desktop", + "traffic_source": { + "channel": "", + "referrer_domain": "", + "landing_url": "", + "click_id_type": "", + "utm_source": "", + "utm_medium": "", + "utm_campaign": "", + "utm_content": "", + "utm_term": "" + }, + "risk_score": 15, + "signals": [], + "detection_flags": { + "vpn": false, + "privacy_relay": false, + "browser_vpn_proxy": false, + "tor": false, + "proxy": false, + "datacenter_ip": false, + "abuser": false, + "os_mismatch": false, + "os_not_detected": false, + "timezone_mismatch": false, + "anti_detect_browser": false, + "browser_automation": false, + "ip_mismatch": false, + "incognito": false, + "search_bot": false, + "suspicious_paid_click": false, + "javascript_disabled": false, + "stun_not_checked": false, + "check_incomplete": false + }, + "observed_at": "2026-09-30T10:00:00.250Z", + "source": "history" + } + }, + { + "name": "user_hid_fail_sentinel", + "input": { + "request_id": "3b9c1e7a-5d2f-4a8b-9c6d-7e8f9a0b1c2d", + "session_id": "5c6d7e8f-9a0b-4c1d-8e2f-3a4b5c6d7e8f", + "cookie_id": "6d7e8f9a-0b1c-4d2e-9f3a-4b5c6d7e8f9a", + "domain": "example.com", + "user_hid": "fail", + "device_id": "7e8f9a0b-1c2d-5e3f-8a4b-5c6d7e8f9a0b", + "visitor_id": "8f9a0b1c-2d3e-5f4a-9b5c-6d7e8f9a0b1c", + "ip": "203.0.113.5", + "os": "Windows", + "browser": "Chrome", + "device_type": "desktop", + "country": "Germany", + "connection_type": "direct", + "score": 15, + "score_details": "[]", + "created_at": "2026-09-30 10:00:00.250", + "ver": 1790762400250, + "web_rtc_ip": "203.0.113.5", + "web_rtc_country": "Germany", + "webrtc_leak_ip": "0.0.0.0", + "webrtc_leak_country": "", + "webrtc_leak_source": "none" + }, + "expected": { + "request_id": "3b9c1e7a-5d2f-4a8b-9c6d-7e8f9a0b1c2d", + "visitor_id": "8f9a0b1c-2d3e-5f4a-9b5c-6d7e8f9a0b1c", + "device_id": "7e8f9a0b-1c2d-5e3f-8a4b-5c6d7e8f9a0b", + "session_id": "5c6d7e8f-9a0b-4c1d-8e2f-3a4b5c6d7e8f", + "cookie_id": "6d7e8f9a-0b1c-4d2e-9f3a-4b5c6d7e8f9a", + "user_hid": "fail", + "domain": "example.com", + "public_ip": { + "ip": "203.0.113.5", + "country": "Germany" + }, + "local_ip": { + "ip": "203.0.113.5", + "country": "Germany" + }, + "connection_type": "direct", + "os": "Windows", + "browser": "Chrome", + "device_type": "desktop", + "traffic_source": { + "channel": "", + "referrer_domain": "", + "landing_url": "", + "click_id_type": "", + "utm_source": "", + "utm_medium": "", + "utm_campaign": "", + "utm_content": "", + "utm_term": "" + }, + "risk_score": 15, + "signals": [], + "detection_flags": { + "vpn": false, + "privacy_relay": false, + "browser_vpn_proxy": false, + "tor": false, + "proxy": false, + "datacenter_ip": false, + "abuser": false, + "os_mismatch": false, + "os_not_detected": false, + "timezone_mismatch": false, + "anti_detect_browser": false, + "browser_automation": false, + "ip_mismatch": false, + "incognito": false, + "search_bot": false, + "suspicious_paid_click": false, + "javascript_disabled": false, + "stun_not_checked": false, + "check_incomplete": false + }, + "observed_at": "2026-09-30T10:00:00.250Z", + "source": "history" + } + }, + { + "name": "connection_type_bvp", + "input": { + "request_id": "3b9c1e7a-5d2f-4a8b-9c6d-7e8f9a0b1c2d", + "session_id": "5c6d7e8f-9a0b-4c1d-8e2f-3a4b5c6d7e8f", + "cookie_id": "6d7e8f9a-0b1c-4d2e-9f3a-4b5c6d7e8f9a", + "domain": "example.com", + "user_hid": "anonymous", + "device_id": "7e8f9a0b-1c2d-5e3f-8a4b-5c6d7e8f9a0b", + "visitor_id": "8f9a0b1c-2d3e-5f4a-9b5c-6d7e8f9a0b1c", + "ip": "203.0.113.5", + "os": "Windows", + "browser": "Chrome", + "device_type": "desktop", + "country": "Germany", + "connection_type": "browser_vpn_proxy", + "score": 15, + "score_details": "[]", + "created_at": "2026-09-30 10:00:00.250", + "ver": 1790762400250, + "web_rtc_ip": "203.0.113.5", + "web_rtc_country": "Germany", + "webrtc_leak_ip": "0.0.0.0", + "webrtc_leak_country": "", + "webrtc_leak_source": "none" + }, + "expected": { + "request_id": "3b9c1e7a-5d2f-4a8b-9c6d-7e8f9a0b1c2d", + "visitor_id": "8f9a0b1c-2d3e-5f4a-9b5c-6d7e8f9a0b1c", + "device_id": "7e8f9a0b-1c2d-5e3f-8a4b-5c6d7e8f9a0b", + "session_id": "5c6d7e8f-9a0b-4c1d-8e2f-3a4b5c6d7e8f", + "cookie_id": "6d7e8f9a-0b1c-4d2e-9f3a-4b5c6d7e8f9a", + "user_hid": "anonymous", + "domain": "example.com", + "public_ip": { + "ip": "203.0.113.5", + "country": "Germany" + }, + "local_ip": { + "ip": "203.0.113.5", + "country": "Germany" + }, + "connection_type": "browser_vpn_proxy", + "os": "Windows", + "browser": "Chrome", + "device_type": "desktop", + "traffic_source": { + "channel": "", + "referrer_domain": "", + "landing_url": "", + "click_id_type": "", + "utm_source": "", + "utm_medium": "", + "utm_campaign": "", + "utm_content": "", + "utm_term": "" + }, + "risk_score": 15, + "signals": [], + "detection_flags": { + "vpn": false, + "privacy_relay": false, + "browser_vpn_proxy": true, + "tor": false, + "proxy": false, + "datacenter_ip": false, + "abuser": false, + "os_mismatch": false, + "os_not_detected": false, + "timezone_mismatch": false, + "anti_detect_browser": false, + "browser_automation": false, + "ip_mismatch": false, + "incognito": false, + "search_bot": false, + "suspicious_paid_click": false, + "javascript_disabled": false, + "stun_not_checked": false, + "check_incomplete": false + }, + "observed_at": "2026-09-30T10:00:00.250Z", + "source": "history" + } + }, + { + "name": "created_at_missing", + "input": { + "request_id": "3b9c1e7a-5d2f-4a8b-9c6d-7e8f9a0b1c2d", + "session_id": "5c6d7e8f-9a0b-4c1d-8e2f-3a4b5c6d7e8f", + "cookie_id": "6d7e8f9a-0b1c-4d2e-9f3a-4b5c6d7e8f9a", + "domain": "example.com", + "user_hid": "anonymous", + "device_id": "7e8f9a0b-1c2d-5e3f-8a4b-5c6d7e8f9a0b", + "visitor_id": "8f9a0b1c-2d3e-5f4a-9b5c-6d7e8f9a0b1c", + "ip": "203.0.113.5", + "os": "Windows", + "browser": "Chrome", + "device_type": "desktop", + "country": "Germany", + "connection_type": "direct", + "score": 15, + "score_details": "[]", + "ver": 1790762400250, + "web_rtc_ip": "203.0.113.5", + "web_rtc_country": "Germany", + "webrtc_leak_ip": "0.0.0.0", + "webrtc_leak_country": "", + "webrtc_leak_source": "none" + }, + "expected": { + "request_id": "3b9c1e7a-5d2f-4a8b-9c6d-7e8f9a0b1c2d", + "visitor_id": "8f9a0b1c-2d3e-5f4a-9b5c-6d7e8f9a0b1c", + "device_id": "7e8f9a0b-1c2d-5e3f-8a4b-5c6d7e8f9a0b", + "session_id": "5c6d7e8f-9a0b-4c1d-8e2f-3a4b5c6d7e8f", + "cookie_id": "6d7e8f9a-0b1c-4d2e-9f3a-4b5c6d7e8f9a", + "user_hid": "anonymous", + "domain": "example.com", + "public_ip": { + "ip": "203.0.113.5", + "country": "Germany" + }, + "local_ip": { + "ip": "203.0.113.5", + "country": "Germany" + }, + "connection_type": "direct", + "os": "Windows", + "browser": "Chrome", + "device_type": "desktop", + "traffic_source": { + "channel": "", + "referrer_domain": "", + "landing_url": "", + "click_id_type": "", + "utm_source": "", + "utm_medium": "", + "utm_campaign": "", + "utm_content": "", + "utm_term": "" + }, + "risk_score": 15, + "signals": [], + "detection_flags": { + "vpn": false, + "privacy_relay": false, + "browser_vpn_proxy": false, + "tor": false, + "proxy": false, + "datacenter_ip": false, + "abuser": false, + "os_mismatch": false, + "os_not_detected": false, + "timezone_mismatch": false, + "anti_detect_browser": false, + "browser_automation": false, + "ip_mismatch": false, + "incognito": false, + "search_bot": false, + "suspicious_paid_click": false, + "javascript_disabled": false, + "stun_not_checked": false, + "check_incomplete": false + }, + "observed_at": null, + "source": "history" + } + }, + { + "name": "score_missing", + "input": { + "request_id": "3b9c1e7a-5d2f-4a8b-9c6d-7e8f9a0b1c2d", + "session_id": "5c6d7e8f-9a0b-4c1d-8e2f-3a4b5c6d7e8f", + "cookie_id": "6d7e8f9a-0b1c-4d2e-9f3a-4b5c6d7e8f9a", + "domain": "example.com", + "user_hid": "anonymous", + "device_id": "7e8f9a0b-1c2d-5e3f-8a4b-5c6d7e8f9a0b", + "visitor_id": "8f9a0b1c-2d3e-5f4a-9b5c-6d7e8f9a0b1c", + "ip": "203.0.113.5", + "os": "Windows", + "browser": "Chrome", + "device_type": "desktop", + "country": "Germany", + "connection_type": "direct", + "score_details": "[]", + "created_at": "2026-09-30 10:00:00.250", + "ver": 1790762400250, + "web_rtc_ip": "203.0.113.5", + "web_rtc_country": "Germany", + "webrtc_leak_ip": "0.0.0.0", + "webrtc_leak_country": "", + "webrtc_leak_source": "none" + }, + "expected": { + "request_id": "3b9c1e7a-5d2f-4a8b-9c6d-7e8f9a0b1c2d", + "visitor_id": "8f9a0b1c-2d3e-5f4a-9b5c-6d7e8f9a0b1c", + "device_id": "7e8f9a0b-1c2d-5e3f-8a4b-5c6d7e8f9a0b", + "session_id": "5c6d7e8f-9a0b-4c1d-8e2f-3a4b5c6d7e8f", + "cookie_id": "6d7e8f9a-0b1c-4d2e-9f3a-4b5c6d7e8f9a", + "user_hid": "anonymous", + "domain": "example.com", + "public_ip": { + "ip": "203.0.113.5", + "country": "Germany" + }, + "local_ip": { + "ip": "203.0.113.5", + "country": "Germany" + }, + "connection_type": "direct", + "os": "Windows", + "browser": "Chrome", + "device_type": "desktop", + "traffic_source": { + "channel": "", + "referrer_domain": "", + "landing_url": "", + "click_id_type": "", + "utm_source": "", + "utm_medium": "", + "utm_campaign": "", + "utm_content": "", + "utm_term": "" + }, + "risk_score": 0, + "signals": [], + "detection_flags": { + "vpn": false, + "privacy_relay": false, + "browser_vpn_proxy": false, + "tor": false, + "proxy": false, + "datacenter_ip": false, + "abuser": false, + "os_mismatch": false, + "os_not_detected": false, + "timezone_mismatch": false, + "anti_detect_browser": false, + "browser_automation": false, + "ip_mismatch": false, + "incognito": false, + "search_bot": false, + "suspicious_paid_click": false, + "javascript_disabled": false, + "stun_not_checked": false, + "check_incomplete": false + }, + "observed_at": "2026-09-30T10:00:00.250Z", + "source": "history" + } + }, + { + "name": "unknown_fields_and_traffic", + "input": { + "request_id": "3b9c1e7a-5d2f-4a8b-9c6d-7e8f9a0b1c2d", + "session_id": "5c6d7e8f-9a0b-4c1d-8e2f-3a4b5c6d7e8f", + "cookie_id": "6d7e8f9a-0b1c-4d2e-9f3a-4b5c6d7e8f9a", + "domain": "example.com", + "user_hid": "anonymous", + "device_id": "7e8f9a0b-1c2d-5e3f-8a4b-5c6d7e8f9a0b", + "visitor_id": "8f9a0b1c-2d3e-5f4a-9b5c-6d7e8f9a0b1c", + "ip": "203.0.113.5", + "os": "Windows", + "browser": "Chrome", + "device_type": "desktop", + "country": "Germany", + "connection_type": "direct", + "score": 15, + "score_details": "[]", + "created_at": "2026-09-30 10:00:00.250", + "ver": 1790762400250, + "web_rtc_ip": "203.0.113.5", + "web_rtc_country": "Germany", + "webrtc_leak_ip": "0.0.0.0", + "webrtc_leak_country": "", + "webrtc_leak_source": "none", + "extra_field": { + "nested": [ + 1, + 2 + ] + }, + "traffic_channel": "Organic Search", + "referrer_domain": "search.example.org", + "entry_url": "https://example.com/?q=1", + "utm_source": "newsletter", + "utm_term": "plans", + "is_suspicious_paid_click": true + }, + "expected": { + "request_id": "3b9c1e7a-5d2f-4a8b-9c6d-7e8f9a0b1c2d", + "visitor_id": "8f9a0b1c-2d3e-5f4a-9b5c-6d7e8f9a0b1c", + "device_id": "7e8f9a0b-1c2d-5e3f-8a4b-5c6d7e8f9a0b", + "session_id": "5c6d7e8f-9a0b-4c1d-8e2f-3a4b5c6d7e8f", + "cookie_id": "6d7e8f9a-0b1c-4d2e-9f3a-4b5c6d7e8f9a", + "user_hid": "anonymous", + "domain": "example.com", + "public_ip": { + "ip": "203.0.113.5", + "country": "Germany" + }, + "local_ip": { + "ip": "203.0.113.5", + "country": "Germany" + }, + "connection_type": "direct", + "os": "Windows", + "browser": "Chrome", + "device_type": "desktop", + "traffic_source": { + "channel": "Organic Search", + "referrer_domain": "search.example.org", + "landing_url": "https://example.com/?q=1", + "click_id_type": "", + "utm_source": "newsletter", + "utm_medium": "", + "utm_campaign": "", + "utm_content": "", + "utm_term": "plans" + }, + "risk_score": 15, + "signals": [], + "detection_flags": { + "vpn": false, + "privacy_relay": false, + "browser_vpn_proxy": false, + "tor": false, + "proxy": false, + "datacenter_ip": false, + "abuser": false, + "os_mismatch": false, + "os_not_detected": false, + "timezone_mismatch": false, + "anti_detect_browser": false, + "browser_automation": false, + "ip_mismatch": false, + "incognito": false, + "search_bot": false, + "suspicious_paid_click": true, + "javascript_disabled": false, + "stun_not_checked": false, + "check_incomplete": false + }, + "observed_at": "2026-09-30T10:00:00.250Z", + "source": "history" + } + }, + { + "name": "country_missing", + "input": { + "request_id": "3b9c1e7a-5d2f-4a8b-9c6d-7e8f9a0b1c2d", + "session_id": "5c6d7e8f-9a0b-4c1d-8e2f-3a4b5c6d7e8f", + "cookie_id": "6d7e8f9a-0b1c-4d2e-9f3a-4b5c6d7e8f9a", + "domain": "example.com", + "user_hid": "anonymous", + "device_id": "7e8f9a0b-1c2d-5e3f-8a4b-5c6d7e8f9a0b", + "visitor_id": "8f9a0b1c-2d3e-5f4a-9b5c-6d7e8f9a0b1c", + "ip": "203.0.113.5", + "os": "Windows", + "browser": "Chrome", + "device_type": "desktop", + "connection_type": "direct", + "score": 15, + "score_details": "[]", + "created_at": "2026-09-30 10:00:00.250", + "ver": 1790762400250, + "web_rtc_ip": "203.0.113.5", + "web_rtc_country": "Germany", + "webrtc_leak_ip": "0.0.0.0", + "webrtc_leak_country": "", + "webrtc_leak_source": "none" + }, + "expected": { + "request_id": "3b9c1e7a-5d2f-4a8b-9c6d-7e8f9a0b1c2d", + "visitor_id": "8f9a0b1c-2d3e-5f4a-9b5c-6d7e8f9a0b1c", + "device_id": "7e8f9a0b-1c2d-5e3f-8a4b-5c6d7e8f9a0b", + "session_id": "5c6d7e8f-9a0b-4c1d-8e2f-3a4b5c6d7e8f", + "cookie_id": "6d7e8f9a-0b1c-4d2e-9f3a-4b5c6d7e8f9a", + "user_hid": "anonymous", + "domain": "example.com", + "public_ip": { + "ip": "203.0.113.5", + "country": "" + }, + "local_ip": { + "ip": "203.0.113.5", + "country": "Germany" + }, + "connection_type": "direct", + "os": "Windows", + "browser": "Chrome", + "device_type": "desktop", + "traffic_source": { + "channel": "", + "referrer_domain": "", + "landing_url": "", + "click_id_type": "", + "utm_source": "", + "utm_medium": "", + "utm_campaign": "", + "utm_content": "", + "utm_term": "" + }, + "risk_score": 15, + "signals": [], + "detection_flags": { + "vpn": false, + "privacy_relay": false, + "browser_vpn_proxy": false, + "tor": false, + "proxy": false, + "datacenter_ip": false, + "abuser": false, + "os_mismatch": false, + "os_not_detected": false, + "timezone_mismatch": false, + "anti_detect_browser": false, + "browser_automation": false, + "ip_mismatch": false, + "incognito": false, + "search_bot": false, + "suspicious_paid_click": false, + "javascript_disabled": false, + "stun_not_checked": false, + "check_incomplete": false + }, + "observed_at": "2026-09-30T10:00:00.250Z", + "source": "history" + } + }, + { + "name": "minimal_row", + "input": { + "request_id": "4c0d2f8b-6e3a-4b9c-8d7e-0f1a2b3c4d5e" + }, + "expected": { + "request_id": "4c0d2f8b-6e3a-4b9c-8d7e-0f1a2b3c4d5e", + "visitor_id": "", + "device_id": "", + "session_id": "", + "cookie_id": "", + "user_hid": null, + "domain": "", + "public_ip": { + "ip": "", + "country": "" + }, + "local_ip": { + "ip": "", + "country": "" + }, + "connection_type": "", + "os": "", + "browser": "", + "device_type": "", + "traffic_source": { + "channel": "", + "referrer_domain": "", + "landing_url": "", + "click_id_type": "", + "utm_source": "", + "utm_medium": "", + "utm_campaign": "", + "utm_content": "", + "utm_term": "" + }, + "risk_score": 0, + "signals": [], + "detection_flags": { + "vpn": false, + "privacy_relay": false, + "browser_vpn_proxy": false, + "tor": false, + "proxy": false, + "datacenter_ip": false, + "abuser": false, + "os_mismatch": false, + "os_not_detected": false, + "timezone_mismatch": false, + "anti_detect_browser": false, + "browser_automation": false, + "ip_mismatch": false, + "incognito": false, + "search_bot": false, + "suspicious_paid_click": false, + "javascript_disabled": false, + "stun_not_checked": false, + "check_incomplete": false + }, + "observed_at": null, + "source": "history" + } + }, + { + "name": "empty_row", + "input": {}, + "expected": { + "request_id": "", + "visitor_id": "", + "device_id": "", + "session_id": "", + "cookie_id": "", + "user_hid": null, + "domain": "", + "public_ip": { + "ip": "", + "country": "" + }, + "local_ip": { + "ip": "", + "country": "" + }, + "connection_type": "", + "os": "", + "browser": "", + "device_type": "", + "traffic_source": { + "channel": "", + "referrer_domain": "", + "landing_url": "", + "click_id_type": "", + "utm_source": "", + "utm_medium": "", + "utm_campaign": "", + "utm_content": "", + "utm_term": "" + }, + "risk_score": 0, + "signals": [], + "detection_flags": { + "vpn": false, + "privacy_relay": false, + "browser_vpn_proxy": false, + "tor": false, + "proxy": false, + "datacenter_ip": false, + "abuser": false, + "os_mismatch": false, + "os_not_detected": false, + "timezone_mismatch": false, + "anti_detect_browser": false, + "browser_automation": false, + "ip_mismatch": false, + "incognito": false, + "search_bot": false, + "suspicious_paid_click": false, + "javascript_disabled": false, + "stun_not_checked": false, + "check_incomplete": false + }, + "observed_at": null, + "source": "history" + } + }, + { + "name": "nil_ids_rate_limited", + "input": { + "request_id": "3b9c1e7a-5d2f-4a8b-9c6d-7e8f9a0b1c2d", + "session_id": "00000000-0000-0000-0000-000000000000", + "cookie_id": "00000000-0000-0000-0000-000000000000", + "domain": "example.com", + "user_hid": "anonymous", + "device_id": "00000000-0000-0000-0000-000000000000", + "visitor_id": "00000000-0000-0000-0000-000000000000", + "ip": "203.0.113.5", + "os": "Windows", + "browser": "Chrome", + "device_type": "desktop", + "country": "Germany", + "connection_type": "direct", + "score": 999, + "score_details": "[{\"Value\": 999, \"Description\": \"User has been banned 1H, to many requests\"}]", + "created_at": "2026-09-30 10:00:00.250", + "ver": 1790762400250, + "web_rtc_ip": "203.0.113.5", + "web_rtc_country": "Germany", + "webrtc_leak_ip": "0.0.0.0", + "webrtc_leak_country": "", + "webrtc_leak_source": "none" + }, + "expected": { + "request_id": "3b9c1e7a-5d2f-4a8b-9c6d-7e8f9a0b1c2d", + "visitor_id": "00000000-0000-0000-0000-000000000000", + "device_id": "00000000-0000-0000-0000-000000000000", + "session_id": "00000000-0000-0000-0000-000000000000", + "cookie_id": "00000000-0000-0000-0000-000000000000", + "user_hid": "anonymous", + "domain": "example.com", + "public_ip": { + "ip": "203.0.113.5", + "country": "Germany" + }, + "local_ip": { + "ip": "203.0.113.5", + "country": "Germany" + }, + "connection_type": "direct", + "os": "Windows", + "browser": "Chrome", + "device_type": "desktop", + "traffic_source": { + "channel": "", + "referrer_domain": "", + "landing_url": "", + "click_id_type": "", + "utm_source": "", + "utm_medium": "", + "utm_campaign": "", + "utm_content": "", + "utm_term": "" + }, + "risk_score": 999, + "signals": [ + { + "name": "rate_limited", + "weight": 999, + "description": "User has been banned 1H, to many requests" + } + ], + "detection_flags": { + "vpn": false, + "privacy_relay": false, + "browser_vpn_proxy": false, + "tor": false, + "proxy": false, + "datacenter_ip": false, + "abuser": false, + "os_mismatch": false, + "os_not_detected": false, + "timezone_mismatch": false, + "anti_detect_browser": false, + "browser_automation": false, + "ip_mismatch": false, + "incognito": false, + "search_bot": false, + "suspicious_paid_click": false, + "javascript_disabled": false, + "stun_not_checked": false, + "check_incomplete": false + }, + "observed_at": "2026-09-30T10:00:00.250Z", + "source": "history" + } + } + ], + "webhook_data": [ + { + "name": "flags_missing", + "input": { + "request_id": "02f1d973-84db-4156-a7f7-e799e6bf389b", + "visitor_id": "bde0e249-20d8-4544-838c-ed9a0b6d7a36", + "device_id": "ac7c303d-971b-41d1-8e25-cd5b46b46aed", + "session_id": "bde78778-efd2-4c49-952f-1f11b9c05f35", + "cookie_id": "4449bb58-590c-444c-ae1f-d1ddc768dbdd", + "user_hid": "9f86d081884c7d659a2feaa0c55ad015", + "domain": "example.com", + "public_ip": { + "ip": "203.0.113.24", + "country": "Netherlands" + }, + "local_ip": { + "ip": "198.51.100.23", + "country": "Germany" + }, + "connection_type": "proxy", + "os": "Windows", + "browser": "Chrome", + "device_type": "desktop", + "traffic_source": { + "channel": "Google Ads", + "referrer_domain": "google.com", + "landing_url": "https://shop.example.com/signup?utm_source=google&utm_medium=cpc&gclid=abc123", + "click_id_type": "gclid", + "utm_source": "google", + "utm_medium": "cpc", + "utm_campaign": "", + "utm_content": "", + "utm_term": "" + }, + "risk_score": 80, + "signals": [ + { + "name": "proxy", + "weight": 10 + }, + { + "name": "datacenter_ip", + "weight": 10 + }, + { + "name": "antidetect_browser", + "weight": 60 + } + ], + "observed_at": "2026-09-30T12:34:57.482913041Z" + }, + "expected": { + "request_id": "02f1d973-84db-4156-a7f7-e799e6bf389b", + "visitor_id": "bde0e249-20d8-4544-838c-ed9a0b6d7a36", + "device_id": "ac7c303d-971b-41d1-8e25-cd5b46b46aed", + "session_id": "bde78778-efd2-4c49-952f-1f11b9c05f35", + "cookie_id": "4449bb58-590c-444c-ae1f-d1ddc768dbdd", + "user_hid": "9f86d081884c7d659a2feaa0c55ad015", + "domain": "example.com", + "public_ip": { + "ip": "203.0.113.24", + "country": "Netherlands" + }, + "local_ip": { + "ip": "198.51.100.23", + "country": "Germany" + }, + "connection_type": "proxy", + "os": "Windows", + "browser": "Chrome", + "device_type": "desktop", + "traffic_source": { + "channel": "Google Ads", + "referrer_domain": "google.com", + "landing_url": "https://shop.example.com/signup?utm_source=google&utm_medium=cpc&gclid=abc123", + "click_id_type": "gclid", + "utm_source": "google", + "utm_medium": "cpc", + "utm_campaign": "", + "utm_content": "", + "utm_term": "" + }, + "risk_score": 80, + "signals": [ + { + "name": "proxy", + "weight": 10, + "description": null + }, + { + "name": "datacenter_ip", + "weight": 10, + "description": null + }, + { + "name": "antidetect_browser", + "weight": 60, + "description": null + } + ], + "detection_flags": { + "vpn": false, + "privacy_relay": false, + "browser_vpn_proxy": false, + "tor": false, + "proxy": false, + "datacenter_ip": false, + "abuser": false, + "os_mismatch": false, + "os_not_detected": false, + "timezone_mismatch": false, + "anti_detect_browser": false, + "browser_automation": false, + "ip_mismatch": false, + "incognito": false, + "search_bot": false, + "suspicious_paid_click": false, + "javascript_disabled": false, + "stun_not_checked": false, + "check_incomplete": false + }, + "observed_at": "2026-09-30T12:34:57.482Z", + "source": "webhook" + } + }, + { + "name": "flags_null", + "input": { + "request_id": "02f1d973-84db-4156-a7f7-e799e6bf389b", + "visitor_id": "bde0e249-20d8-4544-838c-ed9a0b6d7a36", + "device_id": "ac7c303d-971b-41d1-8e25-cd5b46b46aed", + "session_id": "bde78778-efd2-4c49-952f-1f11b9c05f35", + "cookie_id": "4449bb58-590c-444c-ae1f-d1ddc768dbdd", + "user_hid": "9f86d081884c7d659a2feaa0c55ad015", + "domain": "example.com", + "public_ip": { + "ip": "203.0.113.24", + "country": "Netherlands" + }, + "local_ip": { + "ip": "198.51.100.23", + "country": "Germany" + }, + "connection_type": "proxy", + "os": "Windows", + "browser": "Chrome", + "device_type": "desktop", + "traffic_source": { + "channel": "Google Ads", + "referrer_domain": "google.com", + "landing_url": "https://shop.example.com/signup?utm_source=google&utm_medium=cpc&gclid=abc123", + "click_id_type": "gclid", + "utm_source": "google", + "utm_medium": "cpc", + "utm_campaign": "", + "utm_content": "", + "utm_term": "" + }, + "risk_score": 80, + "signals": [ + { + "name": "proxy", + "weight": 10 + }, + { + "name": "datacenter_ip", + "weight": 10 + }, + { + "name": "antidetect_browser", + "weight": 60 + } + ], + "detection_flags": null, + "observed_at": "2026-09-30T12:34:57.482913041Z" + }, + "expected": { + "request_id": "02f1d973-84db-4156-a7f7-e799e6bf389b", + "visitor_id": "bde0e249-20d8-4544-838c-ed9a0b6d7a36", + "device_id": "ac7c303d-971b-41d1-8e25-cd5b46b46aed", + "session_id": "bde78778-efd2-4c49-952f-1f11b9c05f35", + "cookie_id": "4449bb58-590c-444c-ae1f-d1ddc768dbdd", + "user_hid": "9f86d081884c7d659a2feaa0c55ad015", + "domain": "example.com", + "public_ip": { + "ip": "203.0.113.24", + "country": "Netherlands" + }, + "local_ip": { + "ip": "198.51.100.23", + "country": "Germany" + }, + "connection_type": "proxy", + "os": "Windows", + "browser": "Chrome", + "device_type": "desktop", + "traffic_source": { + "channel": "Google Ads", + "referrer_domain": "google.com", + "landing_url": "https://shop.example.com/signup?utm_source=google&utm_medium=cpc&gclid=abc123", + "click_id_type": "gclid", + "utm_source": "google", + "utm_medium": "cpc", + "utm_campaign": "", + "utm_content": "", + "utm_term": "" + }, + "risk_score": 80, + "signals": [ + { + "name": "proxy", + "weight": 10, + "description": null + }, + { + "name": "datacenter_ip", + "weight": 10, + "description": null + }, + { + "name": "antidetect_browser", + "weight": 60, + "description": null + } + ], + "detection_flags": { + "vpn": false, + "privacy_relay": false, + "browser_vpn_proxy": false, + "tor": false, + "proxy": false, + "datacenter_ip": false, + "abuser": false, + "os_mismatch": false, + "os_not_detected": false, + "timezone_mismatch": false, + "anti_detect_browser": false, + "browser_automation": false, + "ip_mismatch": false, + "incognito": false, + "search_bot": false, + "suspicious_paid_click": false, + "javascript_disabled": false, + "stun_not_checked": false, + "check_incomplete": false + }, + "observed_at": "2026-09-30T12:34:57.482Z", + "source": "webhook" + } + }, + { + "name": "flags_truthiness", + "input": { + "request_id": "02f1d973-84db-4156-a7f7-e799e6bf389b", + "visitor_id": "bde0e249-20d8-4544-838c-ed9a0b6d7a36", + "device_id": "ac7c303d-971b-41d1-8e25-cd5b46b46aed", + "session_id": "bde78778-efd2-4c49-952f-1f11b9c05f35", + "cookie_id": "4449bb58-590c-444c-ae1f-d1ddc768dbdd", + "user_hid": "9f86d081884c7d659a2feaa0c55ad015", + "domain": "example.com", + "public_ip": { + "ip": "203.0.113.24", + "country": "Netherlands" + }, + "local_ip": { + "ip": "198.51.100.23", + "country": "Germany" + }, + "connection_type": "proxy", + "os": "Windows", + "browser": "Chrome", + "device_type": "desktop", + "traffic_source": { + "channel": "Google Ads", + "referrer_domain": "google.com", + "landing_url": "https://shop.example.com/signup?utm_source=google&utm_medium=cpc&gclid=abc123", + "click_id_type": "gclid", + "utm_source": "google", + "utm_medium": "cpc", + "utm_campaign": "", + "utm_content": "", + "utm_term": "" + }, + "risk_score": 80, + "signals": [ + { + "name": "proxy", + "weight": 10 + }, + { + "name": "datacenter_ip", + "weight": 10 + }, + { + "name": "antidetect_browser", + "weight": 60 + } + ], + "detection_flags": { + "vpn": 1, + "tor": "x", + "proxy": 0, + "abuser": "", + "incognito": [ + 0 + ], + "search_bot": {}, + "unknown_flag": true + }, + "observed_at": "2026-09-30T12:34:57.482913041Z" + }, + "expected": { + "request_id": "02f1d973-84db-4156-a7f7-e799e6bf389b", + "visitor_id": "bde0e249-20d8-4544-838c-ed9a0b6d7a36", + "device_id": "ac7c303d-971b-41d1-8e25-cd5b46b46aed", + "session_id": "bde78778-efd2-4c49-952f-1f11b9c05f35", + "cookie_id": "4449bb58-590c-444c-ae1f-d1ddc768dbdd", + "user_hid": "9f86d081884c7d659a2feaa0c55ad015", + "domain": "example.com", + "public_ip": { + "ip": "203.0.113.24", + "country": "Netherlands" + }, + "local_ip": { + "ip": "198.51.100.23", + "country": "Germany" + }, + "connection_type": "proxy", + "os": "Windows", + "browser": "Chrome", + "device_type": "desktop", + "traffic_source": { + "channel": "Google Ads", + "referrer_domain": "google.com", + "landing_url": "https://shop.example.com/signup?utm_source=google&utm_medium=cpc&gclid=abc123", + "click_id_type": "gclid", + "utm_source": "google", + "utm_medium": "cpc", + "utm_campaign": "", + "utm_content": "", + "utm_term": "" + }, + "risk_score": 80, + "signals": [ + { + "name": "proxy", + "weight": 10, + "description": null + }, + { + "name": "datacenter_ip", + "weight": 10, + "description": null + }, + { + "name": "antidetect_browser", + "weight": 60, + "description": null + } + ], + "detection_flags": { + "vpn": true, + "privacy_relay": false, + "browser_vpn_proxy": false, + "tor": true, + "proxy": false, + "datacenter_ip": false, + "abuser": false, + "os_mismatch": false, + "os_not_detected": false, + "timezone_mismatch": false, + "anti_detect_browser": false, + "browser_automation": false, + "ip_mismatch": false, + "incognito": true, + "search_bot": false, + "suspicious_paid_click": false, + "javascript_disabled": false, + "stun_not_checked": false, + "check_incomplete": false + }, + "observed_at": "2026-09-30T12:34:57.482Z", + "source": "webhook" + } + }, + { + "name": "traffic_missing", + "input": { + "request_id": "02f1d973-84db-4156-a7f7-e799e6bf389b", + "visitor_id": "bde0e249-20d8-4544-838c-ed9a0b6d7a36", + "device_id": "ac7c303d-971b-41d1-8e25-cd5b46b46aed", + "session_id": "bde78778-efd2-4c49-952f-1f11b9c05f35", + "cookie_id": "4449bb58-590c-444c-ae1f-d1ddc768dbdd", + "user_hid": "9f86d081884c7d659a2feaa0c55ad015", + "domain": "example.com", + "public_ip": { + "ip": "203.0.113.24", + "country": "Netherlands" + }, + "local_ip": { + "ip": "198.51.100.23", + "country": "Germany" + }, + "connection_type": "proxy", + "os": "Windows", + "browser": "Chrome", + "device_type": "desktop", + "risk_score": 80, + "signals": [ + { + "name": "proxy", + "weight": 10 + }, + { + "name": "datacenter_ip", + "weight": 10 + }, + { + "name": "antidetect_browser", + "weight": 60 + } + ], + "detection_flags": { + "vpn": false, + "privacy_relay": false, + "browser_vpn_proxy": false, + "tor": false, + "proxy": true, + "datacenter_ip": true, + "abuser": false, + "os_mismatch": false, + "os_not_detected": false, + "timezone_mismatch": false, + "anti_detect_browser": true, + "browser_automation": false, + "ip_mismatch": true, + "incognito": false, + "search_bot": false, + "suspicious_paid_click": true, + "javascript_disabled": false, + "stun_not_checked": false, + "check_incomplete": false + }, + "observed_at": "2026-09-30T12:34:57.482913041Z" + }, + "expected": { + "request_id": "02f1d973-84db-4156-a7f7-e799e6bf389b", + "visitor_id": "bde0e249-20d8-4544-838c-ed9a0b6d7a36", + "device_id": "ac7c303d-971b-41d1-8e25-cd5b46b46aed", + "session_id": "bde78778-efd2-4c49-952f-1f11b9c05f35", + "cookie_id": "4449bb58-590c-444c-ae1f-d1ddc768dbdd", + "user_hid": "9f86d081884c7d659a2feaa0c55ad015", + "domain": "example.com", + "public_ip": { + "ip": "203.0.113.24", + "country": "Netherlands" + }, + "local_ip": { + "ip": "198.51.100.23", + "country": "Germany" + }, + "connection_type": "proxy", + "os": "Windows", + "browser": "Chrome", + "device_type": "desktop", + "traffic_source": { + "channel": "", + "referrer_domain": "", + "landing_url": "", + "click_id_type": "", + "utm_source": "", + "utm_medium": "", + "utm_campaign": "", + "utm_content": "", + "utm_term": "" + }, + "risk_score": 80, + "signals": [ + { + "name": "proxy", + "weight": 10, + "description": null + }, + { + "name": "datacenter_ip", + "weight": 10, + "description": null + }, + { + "name": "antidetect_browser", + "weight": 60, + "description": null + } + ], + "detection_flags": { + "vpn": false, + "privacy_relay": false, + "browser_vpn_proxy": false, + "tor": false, + "proxy": true, + "datacenter_ip": true, + "abuser": false, + "os_mismatch": false, + "os_not_detected": false, + "timezone_mismatch": false, + "anti_detect_browser": true, + "browser_automation": false, + "ip_mismatch": true, + "incognito": false, + "search_bot": false, + "suspicious_paid_click": true, + "javascript_disabled": false, + "stun_not_checked": false, + "check_incomplete": false + }, + "observed_at": "2026-09-30T12:34:57.482Z", + "source": "webhook" + } + }, + { + "name": "traffic_partial", + "input": { + "request_id": "02f1d973-84db-4156-a7f7-e799e6bf389b", + "visitor_id": "bde0e249-20d8-4544-838c-ed9a0b6d7a36", + "device_id": "ac7c303d-971b-41d1-8e25-cd5b46b46aed", + "session_id": "bde78778-efd2-4c49-952f-1f11b9c05f35", + "cookie_id": "4449bb58-590c-444c-ae1f-d1ddc768dbdd", + "user_hid": "9f86d081884c7d659a2feaa0c55ad015", + "domain": "example.com", + "public_ip": { + "ip": "203.0.113.24", + "country": "Netherlands" + }, + "local_ip": { + "ip": "198.51.100.23", + "country": "Germany" + }, + "connection_type": "proxy", + "os": "Windows", + "browser": "Chrome", + "device_type": "desktop", + "traffic_source": { + "channel": "Direct", + "utm_source": null, + "referrer_domain": "" + }, + "risk_score": 80, + "signals": [ + { + "name": "proxy", + "weight": 10 + }, + { + "name": "datacenter_ip", + "weight": 10 + }, + { + "name": "antidetect_browser", + "weight": 60 + } + ], + "detection_flags": { + "vpn": false, + "privacy_relay": false, + "browser_vpn_proxy": false, + "tor": false, + "proxy": true, + "datacenter_ip": true, + "abuser": false, + "os_mismatch": false, + "os_not_detected": false, + "timezone_mismatch": false, + "anti_detect_browser": true, + "browser_automation": false, + "ip_mismatch": true, + "incognito": false, + "search_bot": false, + "suspicious_paid_click": true, + "javascript_disabled": false, + "stun_not_checked": false, + "check_incomplete": false + }, + "observed_at": "2026-09-30T12:34:57.482913041Z" + }, + "expected": { + "request_id": "02f1d973-84db-4156-a7f7-e799e6bf389b", + "visitor_id": "bde0e249-20d8-4544-838c-ed9a0b6d7a36", + "device_id": "ac7c303d-971b-41d1-8e25-cd5b46b46aed", + "session_id": "bde78778-efd2-4c49-952f-1f11b9c05f35", + "cookie_id": "4449bb58-590c-444c-ae1f-d1ddc768dbdd", + "user_hid": "9f86d081884c7d659a2feaa0c55ad015", + "domain": "example.com", + "public_ip": { + "ip": "203.0.113.24", + "country": "Netherlands" + }, + "local_ip": { + "ip": "198.51.100.23", + "country": "Germany" + }, + "connection_type": "proxy", + "os": "Windows", + "browser": "Chrome", + "device_type": "desktop", + "traffic_source": { + "channel": "Direct", + "referrer_domain": "", + "landing_url": "", + "click_id_type": "", + "utm_source": "", + "utm_medium": "", + "utm_campaign": "", + "utm_content": "", + "utm_term": "" + }, + "risk_score": 80, + "signals": [ + { + "name": "proxy", + "weight": 10, + "description": null + }, + { + "name": "datacenter_ip", + "weight": 10, + "description": null + }, + { + "name": "antidetect_browser", + "weight": 60, + "description": null + } + ], + "detection_flags": { + "vpn": false, + "privacy_relay": false, + "browser_vpn_proxy": false, + "tor": false, + "proxy": true, + "datacenter_ip": true, + "abuser": false, + "os_mismatch": false, + "os_not_detected": false, + "timezone_mismatch": false, + "anti_detect_browser": true, + "browser_automation": false, + "ip_mismatch": true, + "incognito": false, + "search_bot": false, + "suspicious_paid_click": true, + "javascript_disabled": false, + "stun_not_checked": false, + "check_incomplete": false + }, + "observed_at": "2026-09-30T12:34:57.482Z", + "source": "webhook" + } + }, + { + "name": "signals_missing", + "input": { + "request_id": "02f1d973-84db-4156-a7f7-e799e6bf389b", + "visitor_id": "bde0e249-20d8-4544-838c-ed9a0b6d7a36", + "device_id": "ac7c303d-971b-41d1-8e25-cd5b46b46aed", + "session_id": "bde78778-efd2-4c49-952f-1f11b9c05f35", + "cookie_id": "4449bb58-590c-444c-ae1f-d1ddc768dbdd", + "user_hid": "9f86d081884c7d659a2feaa0c55ad015", + "domain": "example.com", + "public_ip": { + "ip": "203.0.113.24", + "country": "Netherlands" + }, + "local_ip": { + "ip": "198.51.100.23", + "country": "Germany" + }, + "connection_type": "proxy", + "os": "Windows", + "browser": "Chrome", + "device_type": "desktop", + "traffic_source": { + "channel": "Google Ads", + "referrer_domain": "google.com", + "landing_url": "https://shop.example.com/signup?utm_source=google&utm_medium=cpc&gclid=abc123", + "click_id_type": "gclid", + "utm_source": "google", + "utm_medium": "cpc", + "utm_campaign": "", + "utm_content": "", + "utm_term": "" + }, + "risk_score": 80, + "detection_flags": { + "vpn": false, + "privacy_relay": false, + "browser_vpn_proxy": false, + "tor": false, + "proxy": true, + "datacenter_ip": true, + "abuser": false, + "os_mismatch": false, + "os_not_detected": false, + "timezone_mismatch": false, + "anti_detect_browser": true, + "browser_automation": false, + "ip_mismatch": true, + "incognito": false, + "search_bot": false, + "suspicious_paid_click": true, + "javascript_disabled": false, + "stun_not_checked": false, + "check_incomplete": false + }, + "observed_at": "2026-09-30T12:34:57.482913041Z" + }, + "expected": { + "request_id": "02f1d973-84db-4156-a7f7-e799e6bf389b", + "visitor_id": "bde0e249-20d8-4544-838c-ed9a0b6d7a36", + "device_id": "ac7c303d-971b-41d1-8e25-cd5b46b46aed", + "session_id": "bde78778-efd2-4c49-952f-1f11b9c05f35", + "cookie_id": "4449bb58-590c-444c-ae1f-d1ddc768dbdd", + "user_hid": "9f86d081884c7d659a2feaa0c55ad015", + "domain": "example.com", + "public_ip": { + "ip": "203.0.113.24", + "country": "Netherlands" + }, + "local_ip": { + "ip": "198.51.100.23", + "country": "Germany" + }, + "connection_type": "proxy", + "os": "Windows", + "browser": "Chrome", + "device_type": "desktop", + "traffic_source": { + "channel": "Google Ads", + "referrer_domain": "google.com", + "landing_url": "https://shop.example.com/signup?utm_source=google&utm_medium=cpc&gclid=abc123", + "click_id_type": "gclid", + "utm_source": "google", + "utm_medium": "cpc", + "utm_campaign": "", + "utm_content": "", + "utm_term": "" + }, + "risk_score": 80, + "signals": [], + "detection_flags": { + "vpn": false, + "privacy_relay": false, + "browser_vpn_proxy": false, + "tor": false, + "proxy": true, + "datacenter_ip": true, + "abuser": false, + "os_mismatch": false, + "os_not_detected": false, + "timezone_mismatch": false, + "anti_detect_browser": true, + "browser_automation": false, + "ip_mismatch": true, + "incognito": false, + "search_bot": false, + "suspicious_paid_click": true, + "javascript_disabled": false, + "stun_not_checked": false, + "check_incomplete": false + }, + "observed_at": "2026-09-30T12:34:57.482Z", + "source": "webhook" + } + }, + { + "name": "signals_null", + "input": { + "request_id": "02f1d973-84db-4156-a7f7-e799e6bf389b", + "visitor_id": "bde0e249-20d8-4544-838c-ed9a0b6d7a36", + "device_id": "ac7c303d-971b-41d1-8e25-cd5b46b46aed", + "session_id": "bde78778-efd2-4c49-952f-1f11b9c05f35", + "cookie_id": "4449bb58-590c-444c-ae1f-d1ddc768dbdd", + "user_hid": "9f86d081884c7d659a2feaa0c55ad015", + "domain": "example.com", + "public_ip": { + "ip": "203.0.113.24", + "country": "Netherlands" + }, + "local_ip": { + "ip": "198.51.100.23", + "country": "Germany" + }, + "connection_type": "proxy", + "os": "Windows", + "browser": "Chrome", + "device_type": "desktop", + "traffic_source": { + "channel": "Google Ads", + "referrer_domain": "google.com", + "landing_url": "https://shop.example.com/signup?utm_source=google&utm_medium=cpc&gclid=abc123", + "click_id_type": "gclid", + "utm_source": "google", + "utm_medium": "cpc", + "utm_campaign": "", + "utm_content": "", + "utm_term": "" + }, + "risk_score": 80, + "signals": null, + "detection_flags": { + "vpn": false, + "privacy_relay": false, + "browser_vpn_proxy": false, + "tor": false, + "proxy": true, + "datacenter_ip": true, + "abuser": false, + "os_mismatch": false, + "os_not_detected": false, + "timezone_mismatch": false, + "anti_detect_browser": true, + "browser_automation": false, + "ip_mismatch": true, + "incognito": false, + "search_bot": false, + "suspicious_paid_click": true, + "javascript_disabled": false, + "stun_not_checked": false, + "check_incomplete": false + }, + "observed_at": "2026-09-30T12:34:57.482913041Z" + }, + "expected": { + "request_id": "02f1d973-84db-4156-a7f7-e799e6bf389b", + "visitor_id": "bde0e249-20d8-4544-838c-ed9a0b6d7a36", + "device_id": "ac7c303d-971b-41d1-8e25-cd5b46b46aed", + "session_id": "bde78778-efd2-4c49-952f-1f11b9c05f35", + "cookie_id": "4449bb58-590c-444c-ae1f-d1ddc768dbdd", + "user_hid": "9f86d081884c7d659a2feaa0c55ad015", + "domain": "example.com", + "public_ip": { + "ip": "203.0.113.24", + "country": "Netherlands" + }, + "local_ip": { + "ip": "198.51.100.23", + "country": "Germany" + }, + "connection_type": "proxy", + "os": "Windows", + "browser": "Chrome", + "device_type": "desktop", + "traffic_source": { + "channel": "Google Ads", + "referrer_domain": "google.com", + "landing_url": "https://shop.example.com/signup?utm_source=google&utm_medium=cpc&gclid=abc123", + "click_id_type": "gclid", + "utm_source": "google", + "utm_medium": "cpc", + "utm_campaign": "", + "utm_content": "", + "utm_term": "" + }, + "risk_score": 80, + "signals": [], + "detection_flags": { + "vpn": false, + "privacy_relay": false, + "browser_vpn_proxy": false, + "tor": false, + "proxy": true, + "datacenter_ip": true, + "abuser": false, + "os_mismatch": false, + "os_not_detected": false, + "timezone_mismatch": false, + "anti_detect_browser": true, + "browser_automation": false, + "ip_mismatch": true, + "incognito": false, + "search_bot": false, + "suspicious_paid_click": true, + "javascript_disabled": false, + "stun_not_checked": false, + "check_incomplete": false + }, + "observed_at": "2026-09-30T12:34:57.482Z", + "source": "webhook" + } + }, + { + "name": "signals_mixed", + "input": { + "request_id": "02f1d973-84db-4156-a7f7-e799e6bf389b", + "visitor_id": "bde0e249-20d8-4544-838c-ed9a0b6d7a36", + "device_id": "ac7c303d-971b-41d1-8e25-cd5b46b46aed", + "session_id": "bde78778-efd2-4c49-952f-1f11b9c05f35", + "cookie_id": "4449bb58-590c-444c-ae1f-d1ddc768dbdd", + "user_hid": "9f86d081884c7d659a2feaa0c55ad015", + "domain": "example.com", + "public_ip": { + "ip": "203.0.113.24", + "country": "Netherlands" + }, + "local_ip": { + "ip": "198.51.100.23", + "country": "Germany" + }, + "connection_type": "proxy", + "os": "Windows", + "browser": "Chrome", + "device_type": "desktop", + "traffic_source": { + "channel": "Google Ads", + "referrer_domain": "google.com", + "landing_url": "https://shop.example.com/signup?utm_source=google&utm_medium=cpc&gclid=abc123", + "click_id_type": "gclid", + "utm_source": "google", + "utm_medium": "cpc", + "utm_campaign": "", + "utm_content": "", + "utm_term": "" + }, + "risk_score": 80, + "signals": [ + { + "name": "vpn", + "weight": 15 + }, + "bad", + null, + 5, + { + "weight": 10 + }, + { + "name": "tor" + }, + { + "name": "stun_late_correction", + "weight": -30 + }, + { + "name": "vpn", + "weight": 15 + } + ], + "detection_flags": { + "vpn": false, + "privacy_relay": false, + "browser_vpn_proxy": false, + "tor": false, + "proxy": true, + "datacenter_ip": true, + "abuser": false, + "os_mismatch": false, + "os_not_detected": false, + "timezone_mismatch": false, + "anti_detect_browser": true, + "browser_automation": false, + "ip_mismatch": true, + "incognito": false, + "search_bot": false, + "suspicious_paid_click": true, + "javascript_disabled": false, + "stun_not_checked": false, + "check_incomplete": false + }, + "observed_at": "2026-09-30T12:34:57.482913041Z" + }, + "expected": { + "request_id": "02f1d973-84db-4156-a7f7-e799e6bf389b", + "visitor_id": "bde0e249-20d8-4544-838c-ed9a0b6d7a36", + "device_id": "ac7c303d-971b-41d1-8e25-cd5b46b46aed", + "session_id": "bde78778-efd2-4c49-952f-1f11b9c05f35", + "cookie_id": "4449bb58-590c-444c-ae1f-d1ddc768dbdd", + "user_hid": "9f86d081884c7d659a2feaa0c55ad015", + "domain": "example.com", + "public_ip": { + "ip": "203.0.113.24", + "country": "Netherlands" + }, + "local_ip": { + "ip": "198.51.100.23", + "country": "Germany" + }, + "connection_type": "proxy", + "os": "Windows", + "browser": "Chrome", + "device_type": "desktop", + "traffic_source": { + "channel": "Google Ads", + "referrer_domain": "google.com", + "landing_url": "https://shop.example.com/signup?utm_source=google&utm_medium=cpc&gclid=abc123", + "click_id_type": "gclid", + "utm_source": "google", + "utm_medium": "cpc", + "utm_campaign": "", + "utm_content": "", + "utm_term": "" + }, + "risk_score": 80, + "signals": [ + { + "name": "vpn", + "weight": 15, + "description": null + }, + { + "name": "", + "weight": 10, + "description": null + }, + { + "name": "tor", + "weight": 0, + "description": null + }, + { + "name": "stun_late_correction", + "weight": -30, + "description": null + }, + { + "name": "vpn", + "weight": 15, + "description": null + } + ], + "detection_flags": { + "vpn": false, + "privacy_relay": false, + "browser_vpn_proxy": false, + "tor": false, + "proxy": true, + "datacenter_ip": true, + "abuser": false, + "os_mismatch": false, + "os_not_detected": false, + "timezone_mismatch": false, + "anti_detect_browser": true, + "browser_automation": false, + "ip_mismatch": true, + "incognito": false, + "search_bot": false, + "suspicious_paid_click": true, + "javascript_disabled": false, + "stun_not_checked": false, + "check_incomplete": false + }, + "observed_at": "2026-09-30T12:34:57.482Z", + "source": "webhook" + } + }, + { + "name": "public_ip_missing", + "input": { + "request_id": "02f1d973-84db-4156-a7f7-e799e6bf389b", + "visitor_id": "bde0e249-20d8-4544-838c-ed9a0b6d7a36", + "device_id": "ac7c303d-971b-41d1-8e25-cd5b46b46aed", + "session_id": "bde78778-efd2-4c49-952f-1f11b9c05f35", + "cookie_id": "4449bb58-590c-444c-ae1f-d1ddc768dbdd", + "user_hid": "9f86d081884c7d659a2feaa0c55ad015", + "domain": "example.com", + "local_ip": { + "ip": "198.51.100.23", + "country": "Germany" + }, + "connection_type": "proxy", + "os": "Windows", + "browser": "Chrome", + "device_type": "desktop", + "traffic_source": { + "channel": "Google Ads", + "referrer_domain": "google.com", + "landing_url": "https://shop.example.com/signup?utm_source=google&utm_medium=cpc&gclid=abc123", + "click_id_type": "gclid", + "utm_source": "google", + "utm_medium": "cpc", + "utm_campaign": "", + "utm_content": "", + "utm_term": "" + }, + "risk_score": 80, + "signals": [ + { + "name": "proxy", + "weight": 10 + }, + { + "name": "datacenter_ip", + "weight": 10 + }, + { + "name": "antidetect_browser", + "weight": 60 + } + ], + "detection_flags": { + "vpn": false, + "privacy_relay": false, + "browser_vpn_proxy": false, + "tor": false, + "proxy": true, + "datacenter_ip": true, + "abuser": false, + "os_mismatch": false, + "os_not_detected": false, + "timezone_mismatch": false, + "anti_detect_browser": true, + "browser_automation": false, + "ip_mismatch": true, + "incognito": false, + "search_bot": false, + "suspicious_paid_click": true, + "javascript_disabled": false, + "stun_not_checked": false, + "check_incomplete": false + }, + "observed_at": "2026-09-30T12:34:57.482913041Z" + }, + "expected": { + "request_id": "02f1d973-84db-4156-a7f7-e799e6bf389b", + "visitor_id": "bde0e249-20d8-4544-838c-ed9a0b6d7a36", + "device_id": "ac7c303d-971b-41d1-8e25-cd5b46b46aed", + "session_id": "bde78778-efd2-4c49-952f-1f11b9c05f35", + "cookie_id": "4449bb58-590c-444c-ae1f-d1ddc768dbdd", + "user_hid": "9f86d081884c7d659a2feaa0c55ad015", + "domain": "example.com", + "public_ip": { + "ip": "", + "country": "" + }, + "local_ip": { + "ip": "198.51.100.23", + "country": "Germany" + }, + "connection_type": "proxy", + "os": "Windows", + "browser": "Chrome", + "device_type": "desktop", + "traffic_source": { + "channel": "Google Ads", + "referrer_domain": "google.com", + "landing_url": "https://shop.example.com/signup?utm_source=google&utm_medium=cpc&gclid=abc123", + "click_id_type": "gclid", + "utm_source": "google", + "utm_medium": "cpc", + "utm_campaign": "", + "utm_content": "", + "utm_term": "" + }, + "risk_score": 80, + "signals": [ + { + "name": "proxy", + "weight": 10, + "description": null + }, + { + "name": "datacenter_ip", + "weight": 10, + "description": null + }, + { + "name": "antidetect_browser", + "weight": 60, + "description": null + } + ], + "detection_flags": { + "vpn": false, + "privacy_relay": false, + "browser_vpn_proxy": false, + "tor": false, + "proxy": true, + "datacenter_ip": true, + "abuser": false, + "os_mismatch": false, + "os_not_detected": false, + "timezone_mismatch": false, + "anti_detect_browser": true, + "browser_automation": false, + "ip_mismatch": true, + "incognito": false, + "search_bot": false, + "suspicious_paid_click": true, + "javascript_disabled": false, + "stun_not_checked": false, + "check_incomplete": false + }, + "observed_at": "2026-09-30T12:34:57.482Z", + "source": "webhook" + } + }, + { + "name": "public_ip_null", + "input": { + "request_id": "02f1d973-84db-4156-a7f7-e799e6bf389b", + "visitor_id": "bde0e249-20d8-4544-838c-ed9a0b6d7a36", + "device_id": "ac7c303d-971b-41d1-8e25-cd5b46b46aed", + "session_id": "bde78778-efd2-4c49-952f-1f11b9c05f35", + "cookie_id": "4449bb58-590c-444c-ae1f-d1ddc768dbdd", + "user_hid": "9f86d081884c7d659a2feaa0c55ad015", + "domain": "example.com", + "public_ip": null, + "local_ip": { + "ip": "198.51.100.23", + "country": "Germany" + }, + "connection_type": "proxy", + "os": "Windows", + "browser": "Chrome", + "device_type": "desktop", + "traffic_source": { + "channel": "Google Ads", + "referrer_domain": "google.com", + "landing_url": "https://shop.example.com/signup?utm_source=google&utm_medium=cpc&gclid=abc123", + "click_id_type": "gclid", + "utm_source": "google", + "utm_medium": "cpc", + "utm_campaign": "", + "utm_content": "", + "utm_term": "" + }, + "risk_score": 80, + "signals": [ + { + "name": "proxy", + "weight": 10 + }, + { + "name": "datacenter_ip", + "weight": 10 + }, + { + "name": "antidetect_browser", + "weight": 60 + } + ], + "detection_flags": { + "vpn": false, + "privacy_relay": false, + "browser_vpn_proxy": false, + "tor": false, + "proxy": true, + "datacenter_ip": true, + "abuser": false, + "os_mismatch": false, + "os_not_detected": false, + "timezone_mismatch": false, + "anti_detect_browser": true, + "browser_automation": false, + "ip_mismatch": true, + "incognito": false, + "search_bot": false, + "suspicious_paid_click": true, + "javascript_disabled": false, + "stun_not_checked": false, + "check_incomplete": false + }, + "observed_at": "2026-09-30T12:34:57.482913041Z" + }, + "expected": { + "request_id": "02f1d973-84db-4156-a7f7-e799e6bf389b", + "visitor_id": "bde0e249-20d8-4544-838c-ed9a0b6d7a36", + "device_id": "ac7c303d-971b-41d1-8e25-cd5b46b46aed", + "session_id": "bde78778-efd2-4c49-952f-1f11b9c05f35", + "cookie_id": "4449bb58-590c-444c-ae1f-d1ddc768dbdd", + "user_hid": "9f86d081884c7d659a2feaa0c55ad015", + "domain": "example.com", + "public_ip": { + "ip": "", + "country": "" + }, + "local_ip": { + "ip": "198.51.100.23", + "country": "Germany" + }, + "connection_type": "proxy", + "os": "Windows", + "browser": "Chrome", + "device_type": "desktop", + "traffic_source": { + "channel": "Google Ads", + "referrer_domain": "google.com", + "landing_url": "https://shop.example.com/signup?utm_source=google&utm_medium=cpc&gclid=abc123", + "click_id_type": "gclid", + "utm_source": "google", + "utm_medium": "cpc", + "utm_campaign": "", + "utm_content": "", + "utm_term": "" + }, + "risk_score": 80, + "signals": [ + { + "name": "proxy", + "weight": 10, + "description": null + }, + { + "name": "datacenter_ip", + "weight": 10, + "description": null + }, + { + "name": "antidetect_browser", + "weight": 60, + "description": null + } + ], + "detection_flags": { + "vpn": false, + "privacy_relay": false, + "browser_vpn_proxy": false, + "tor": false, + "proxy": true, + "datacenter_ip": true, + "abuser": false, + "os_mismatch": false, + "os_not_detected": false, + "timezone_mismatch": false, + "anti_detect_browser": true, + "browser_automation": false, + "ip_mismatch": true, + "incognito": false, + "search_bot": false, + "suspicious_paid_click": true, + "javascript_disabled": false, + "stun_not_checked": false, + "check_incomplete": false + }, + "observed_at": "2026-09-30T12:34:57.482Z", + "source": "webhook" + } + }, + { + "name": "ip_sentinels", + "input": { + "request_id": "02f1d973-84db-4156-a7f7-e799e6bf389b", + "visitor_id": "bde0e249-20d8-4544-838c-ed9a0b6d7a36", + "device_id": "ac7c303d-971b-41d1-8e25-cd5b46b46aed", + "session_id": "bde78778-efd2-4c49-952f-1f11b9c05f35", + "cookie_id": "4449bb58-590c-444c-ae1f-d1ddc768dbdd", + "user_hid": "9f86d081884c7d659a2feaa0c55ad015", + "domain": "example.com", + "public_ip": { + "ip": "0.0.0.0", + "country": "" + }, + "local_ip": { + "ip": " 198.51.100.1 ", + "country": null + }, + "connection_type": "proxy", + "os": "Windows", + "browser": "Chrome", + "device_type": "desktop", + "traffic_source": { + "channel": "Google Ads", + "referrer_domain": "google.com", + "landing_url": "https://shop.example.com/signup?utm_source=google&utm_medium=cpc&gclid=abc123", + "click_id_type": "gclid", + "utm_source": "google", + "utm_medium": "cpc", + "utm_campaign": "", + "utm_content": "", + "utm_term": "" + }, + "risk_score": 80, + "signals": [ + { + "name": "proxy", + "weight": 10 + }, + { + "name": "datacenter_ip", + "weight": 10 + }, + { + "name": "antidetect_browser", + "weight": 60 + } + ], + "detection_flags": { + "vpn": false, + "privacy_relay": false, + "browser_vpn_proxy": false, + "tor": false, + "proxy": true, + "datacenter_ip": true, + "abuser": false, + "os_mismatch": false, + "os_not_detected": false, + "timezone_mismatch": false, + "anti_detect_browser": true, + "browser_automation": false, + "ip_mismatch": true, + "incognito": false, + "search_bot": false, + "suspicious_paid_click": true, + "javascript_disabled": false, + "stun_not_checked": false, + "check_incomplete": false + }, + "observed_at": "2026-09-30T12:34:57.482913041Z" + }, + "expected": { + "request_id": "02f1d973-84db-4156-a7f7-e799e6bf389b", + "visitor_id": "bde0e249-20d8-4544-838c-ed9a0b6d7a36", + "device_id": "ac7c303d-971b-41d1-8e25-cd5b46b46aed", + "session_id": "bde78778-efd2-4c49-952f-1f11b9c05f35", + "cookie_id": "4449bb58-590c-444c-ae1f-d1ddc768dbdd", + "user_hid": "9f86d081884c7d659a2feaa0c55ad015", + "domain": "example.com", + "public_ip": { + "ip": "", + "country": "" + }, + "local_ip": { + "ip": "198.51.100.1", + "country": "" + }, + "connection_type": "proxy", + "os": "Windows", + "browser": "Chrome", + "device_type": "desktop", + "traffic_source": { + "channel": "Google Ads", + "referrer_domain": "google.com", + "landing_url": "https://shop.example.com/signup?utm_source=google&utm_medium=cpc&gclid=abc123", + "click_id_type": "gclid", + "utm_source": "google", + "utm_medium": "cpc", + "utm_campaign": "", + "utm_content": "", + "utm_term": "" + }, + "risk_score": 80, + "signals": [ + { + "name": "proxy", + "weight": 10, + "description": null + }, + { + "name": "datacenter_ip", + "weight": 10, + "description": null + }, + { + "name": "antidetect_browser", + "weight": 60, + "description": null + } + ], + "detection_flags": { + "vpn": false, + "privacy_relay": false, + "browser_vpn_proxy": false, + "tor": false, + "proxy": true, + "datacenter_ip": true, + "abuser": false, + "os_mismatch": false, + "os_not_detected": false, + "timezone_mismatch": false, + "anti_detect_browser": true, + "browser_automation": false, + "ip_mismatch": true, + "incognito": false, + "search_bot": false, + "suspicious_paid_click": true, + "javascript_disabled": false, + "stun_not_checked": false, + "check_incomplete": false + }, + "observed_at": "2026-09-30T12:34:57.482Z", + "source": "webhook" + } + }, + { + "name": "user_hid_empty", + "input": { + "request_id": "02f1d973-84db-4156-a7f7-e799e6bf389b", + "visitor_id": "bde0e249-20d8-4544-838c-ed9a0b6d7a36", + "device_id": "ac7c303d-971b-41d1-8e25-cd5b46b46aed", + "session_id": "bde78778-efd2-4c49-952f-1f11b9c05f35", + "cookie_id": "4449bb58-590c-444c-ae1f-d1ddc768dbdd", + "user_hid": "", + "domain": "example.com", + "public_ip": { + "ip": "203.0.113.24", + "country": "Netherlands" + }, + "local_ip": { + "ip": "198.51.100.23", + "country": "Germany" + }, + "connection_type": "proxy", + "os": "Windows", + "browser": "Chrome", + "device_type": "desktop", + "traffic_source": { + "channel": "Google Ads", + "referrer_domain": "google.com", + "landing_url": "https://shop.example.com/signup?utm_source=google&utm_medium=cpc&gclid=abc123", + "click_id_type": "gclid", + "utm_source": "google", + "utm_medium": "cpc", + "utm_campaign": "", + "utm_content": "", + "utm_term": "" + }, + "risk_score": 80, + "signals": [ + { + "name": "proxy", + "weight": 10 + }, + { + "name": "datacenter_ip", + "weight": 10 + }, + { + "name": "antidetect_browser", + "weight": 60 + } + ], + "detection_flags": { + "vpn": false, + "privacy_relay": false, + "browser_vpn_proxy": false, + "tor": false, + "proxy": true, + "datacenter_ip": true, + "abuser": false, + "os_mismatch": false, + "os_not_detected": false, + "timezone_mismatch": false, + "anti_detect_browser": true, + "browser_automation": false, + "ip_mismatch": true, + "incognito": false, + "search_bot": false, + "suspicious_paid_click": true, + "javascript_disabled": false, + "stun_not_checked": false, + "check_incomplete": false + }, + "observed_at": "2026-09-30T12:34:57.482913041Z" + }, + "expected": { + "request_id": "02f1d973-84db-4156-a7f7-e799e6bf389b", + "visitor_id": "bde0e249-20d8-4544-838c-ed9a0b6d7a36", + "device_id": "ac7c303d-971b-41d1-8e25-cd5b46b46aed", + "session_id": "bde78778-efd2-4c49-952f-1f11b9c05f35", + "cookie_id": "4449bb58-590c-444c-ae1f-d1ddc768dbdd", + "user_hid": null, + "domain": "example.com", + "public_ip": { + "ip": "203.0.113.24", + "country": "Netherlands" + }, + "local_ip": { + "ip": "198.51.100.23", + "country": "Germany" + }, + "connection_type": "proxy", + "os": "Windows", + "browser": "Chrome", + "device_type": "desktop", + "traffic_source": { + "channel": "Google Ads", + "referrer_domain": "google.com", + "landing_url": "https://shop.example.com/signup?utm_source=google&utm_medium=cpc&gclid=abc123", + "click_id_type": "gclid", + "utm_source": "google", + "utm_medium": "cpc", + "utm_campaign": "", + "utm_content": "", + "utm_term": "" + }, + "risk_score": 80, + "signals": [ + { + "name": "proxy", + "weight": 10, + "description": null + }, + { + "name": "datacenter_ip", + "weight": 10, + "description": null + }, + { + "name": "antidetect_browser", + "weight": 60, + "description": null + } + ], + "detection_flags": { + "vpn": false, + "privacy_relay": false, + "browser_vpn_proxy": false, + "tor": false, + "proxy": true, + "datacenter_ip": true, + "abuser": false, + "os_mismatch": false, + "os_not_detected": false, + "timezone_mismatch": false, + "anti_detect_browser": true, + "browser_automation": false, + "ip_mismatch": true, + "incognito": false, + "search_bot": false, + "suspicious_paid_click": true, + "javascript_disabled": false, + "stun_not_checked": false, + "check_incomplete": false + }, + "observed_at": "2026-09-30T12:34:57.482Z", + "source": "webhook" + } + }, + { + "name": "user_hid_missing", + "input": { + "request_id": "02f1d973-84db-4156-a7f7-e799e6bf389b", + "visitor_id": "bde0e249-20d8-4544-838c-ed9a0b6d7a36", + "device_id": "ac7c303d-971b-41d1-8e25-cd5b46b46aed", + "session_id": "bde78778-efd2-4c49-952f-1f11b9c05f35", + "cookie_id": "4449bb58-590c-444c-ae1f-d1ddc768dbdd", + "domain": "example.com", + "public_ip": { + "ip": "203.0.113.24", + "country": "Netherlands" + }, + "local_ip": { + "ip": "198.51.100.23", + "country": "Germany" + }, + "connection_type": "proxy", + "os": "Windows", + "browser": "Chrome", + "device_type": "desktop", + "traffic_source": { + "channel": "Google Ads", + "referrer_domain": "google.com", + "landing_url": "https://shop.example.com/signup?utm_source=google&utm_medium=cpc&gclid=abc123", + "click_id_type": "gclid", + "utm_source": "google", + "utm_medium": "cpc", + "utm_campaign": "", + "utm_content": "", + "utm_term": "" + }, + "risk_score": 80, + "signals": [ + { + "name": "proxy", + "weight": 10 + }, + { + "name": "datacenter_ip", + "weight": 10 + }, + { + "name": "antidetect_browser", + "weight": 60 + } + ], + "detection_flags": { + "vpn": false, + "privacy_relay": false, + "browser_vpn_proxy": false, + "tor": false, + "proxy": true, + "datacenter_ip": true, + "abuser": false, + "os_mismatch": false, + "os_not_detected": false, + "timezone_mismatch": false, + "anti_detect_browser": true, + "browser_automation": false, + "ip_mismatch": true, + "incognito": false, + "search_bot": false, + "suspicious_paid_click": true, + "javascript_disabled": false, + "stun_not_checked": false, + "check_incomplete": false + }, + "observed_at": "2026-09-30T12:34:57.482913041Z" + }, + "expected": { + "request_id": "02f1d973-84db-4156-a7f7-e799e6bf389b", + "visitor_id": "bde0e249-20d8-4544-838c-ed9a0b6d7a36", + "device_id": "ac7c303d-971b-41d1-8e25-cd5b46b46aed", + "session_id": "bde78778-efd2-4c49-952f-1f11b9c05f35", + "cookie_id": "4449bb58-590c-444c-ae1f-d1ddc768dbdd", + "user_hid": null, + "domain": "example.com", + "public_ip": { + "ip": "203.0.113.24", + "country": "Netherlands" + }, + "local_ip": { + "ip": "198.51.100.23", + "country": "Germany" + }, + "connection_type": "proxy", + "os": "Windows", + "browser": "Chrome", + "device_type": "desktop", + "traffic_source": { + "channel": "Google Ads", + "referrer_domain": "google.com", + "landing_url": "https://shop.example.com/signup?utm_source=google&utm_medium=cpc&gclid=abc123", + "click_id_type": "gclid", + "utm_source": "google", + "utm_medium": "cpc", + "utm_campaign": "", + "utm_content": "", + "utm_term": "" + }, + "risk_score": 80, + "signals": [ + { + "name": "proxy", + "weight": 10, + "description": null + }, + { + "name": "datacenter_ip", + "weight": 10, + "description": null + }, + { + "name": "antidetect_browser", + "weight": 60, + "description": null + } + ], + "detection_flags": { + "vpn": false, + "privacy_relay": false, + "browser_vpn_proxy": false, + "tor": false, + "proxy": true, + "datacenter_ip": true, + "abuser": false, + "os_mismatch": false, + "os_not_detected": false, + "timezone_mismatch": false, + "anti_detect_browser": true, + "browser_automation": false, + "ip_mismatch": true, + "incognito": false, + "search_bot": false, + "suspicious_paid_click": true, + "javascript_disabled": false, + "stun_not_checked": false, + "check_incomplete": false + }, + "observed_at": "2026-09-30T12:34:57.482Z", + "source": "webhook" + } + }, + { + "name": "user_hid_null", + "input": { + "request_id": "02f1d973-84db-4156-a7f7-e799e6bf389b", + "visitor_id": "bde0e249-20d8-4544-838c-ed9a0b6d7a36", + "device_id": "ac7c303d-971b-41d1-8e25-cd5b46b46aed", + "session_id": "bde78778-efd2-4c49-952f-1f11b9c05f35", + "cookie_id": "4449bb58-590c-444c-ae1f-d1ddc768dbdd", + "user_hid": null, + "domain": "example.com", + "public_ip": { + "ip": "203.0.113.24", + "country": "Netherlands" + }, + "local_ip": { + "ip": "198.51.100.23", + "country": "Germany" + }, + "connection_type": "proxy", + "os": "Windows", + "browser": "Chrome", + "device_type": "desktop", + "traffic_source": { + "channel": "Google Ads", + "referrer_domain": "google.com", + "landing_url": "https://shop.example.com/signup?utm_source=google&utm_medium=cpc&gclid=abc123", + "click_id_type": "gclid", + "utm_source": "google", + "utm_medium": "cpc", + "utm_campaign": "", + "utm_content": "", + "utm_term": "" + }, + "risk_score": 80, + "signals": [ + { + "name": "proxy", + "weight": 10 + }, + { + "name": "datacenter_ip", + "weight": 10 + }, + { + "name": "antidetect_browser", + "weight": 60 + } + ], + "detection_flags": { + "vpn": false, + "privacy_relay": false, + "browser_vpn_proxy": false, + "tor": false, + "proxy": true, + "datacenter_ip": true, + "abuser": false, + "os_mismatch": false, + "os_not_detected": false, + "timezone_mismatch": false, + "anti_detect_browser": true, + "browser_automation": false, + "ip_mismatch": true, + "incognito": false, + "search_bot": false, + "suspicious_paid_click": true, + "javascript_disabled": false, + "stun_not_checked": false, + "check_incomplete": false + }, + "observed_at": "2026-09-30T12:34:57.482913041Z" + }, + "expected": { + "request_id": "02f1d973-84db-4156-a7f7-e799e6bf389b", + "visitor_id": "bde0e249-20d8-4544-838c-ed9a0b6d7a36", + "device_id": "ac7c303d-971b-41d1-8e25-cd5b46b46aed", + "session_id": "bde78778-efd2-4c49-952f-1f11b9c05f35", + "cookie_id": "4449bb58-590c-444c-ae1f-d1ddc768dbdd", + "user_hid": null, + "domain": "example.com", + "public_ip": { + "ip": "203.0.113.24", + "country": "Netherlands" + }, + "local_ip": { + "ip": "198.51.100.23", + "country": "Germany" + }, + "connection_type": "proxy", + "os": "Windows", + "browser": "Chrome", + "device_type": "desktop", + "traffic_source": { + "channel": "Google Ads", + "referrer_domain": "google.com", + "landing_url": "https://shop.example.com/signup?utm_source=google&utm_medium=cpc&gclid=abc123", + "click_id_type": "gclid", + "utm_source": "google", + "utm_medium": "cpc", + "utm_campaign": "", + "utm_content": "", + "utm_term": "" + }, + "risk_score": 80, + "signals": [ + { + "name": "proxy", + "weight": 10, + "description": null + }, + { + "name": "datacenter_ip", + "weight": 10, + "description": null + }, + { + "name": "antidetect_browser", + "weight": 60, + "description": null + } + ], + "detection_flags": { + "vpn": false, + "privacy_relay": false, + "browser_vpn_proxy": false, + "tor": false, + "proxy": true, + "datacenter_ip": true, + "abuser": false, + "os_mismatch": false, + "os_not_detected": false, + "timezone_mismatch": false, + "anti_detect_browser": true, + "browser_automation": false, + "ip_mismatch": true, + "incognito": false, + "search_bot": false, + "suspicious_paid_click": true, + "javascript_disabled": false, + "stun_not_checked": false, + "check_incomplete": false + }, + "observed_at": "2026-09-30T12:34:57.482Z", + "source": "webhook" + } + }, + { + "name": "observed_at_offset", + "input": { + "request_id": "02f1d973-84db-4156-a7f7-e799e6bf389b", + "visitor_id": "bde0e249-20d8-4544-838c-ed9a0b6d7a36", + "device_id": "ac7c303d-971b-41d1-8e25-cd5b46b46aed", + "session_id": "bde78778-efd2-4c49-952f-1f11b9c05f35", + "cookie_id": "4449bb58-590c-444c-ae1f-d1ddc768dbdd", + "user_hid": "9f86d081884c7d659a2feaa0c55ad015", + "domain": "example.com", + "public_ip": { + "ip": "203.0.113.24", + "country": "Netherlands" + }, + "local_ip": { + "ip": "198.51.100.23", + "country": "Germany" + }, + "connection_type": "proxy", + "os": "Windows", + "browser": "Chrome", + "device_type": "desktop", + "traffic_source": { + "channel": "Google Ads", + "referrer_domain": "google.com", + "landing_url": "https://shop.example.com/signup?utm_source=google&utm_medium=cpc&gclid=abc123", + "click_id_type": "gclid", + "utm_source": "google", + "utm_medium": "cpc", + "utm_campaign": "", + "utm_content": "", + "utm_term": "" + }, + "risk_score": 80, + "signals": [ + { + "name": "proxy", + "weight": 10 + }, + { + "name": "datacenter_ip", + "weight": 10 + }, + { + "name": "antidetect_browser", + "weight": 60 + } + ], + "detection_flags": { + "vpn": false, + "privacy_relay": false, + "browser_vpn_proxy": false, + "tor": false, + "proxy": true, + "datacenter_ip": true, + "abuser": false, + "os_mismatch": false, + "os_not_detected": false, + "timezone_mismatch": false, + "anti_detect_browser": true, + "browser_automation": false, + "ip_mismatch": true, + "incognito": false, + "search_bot": false, + "suspicious_paid_click": true, + "javascript_disabled": false, + "stun_not_checked": false, + "check_incomplete": false + }, + "observed_at": "2026-09-30T14:34:57.482913041+02:00" + }, + "expected": { + "request_id": "02f1d973-84db-4156-a7f7-e799e6bf389b", + "visitor_id": "bde0e249-20d8-4544-838c-ed9a0b6d7a36", + "device_id": "ac7c303d-971b-41d1-8e25-cd5b46b46aed", + "session_id": "bde78778-efd2-4c49-952f-1f11b9c05f35", + "cookie_id": "4449bb58-590c-444c-ae1f-d1ddc768dbdd", + "user_hid": "9f86d081884c7d659a2feaa0c55ad015", + "domain": "example.com", + "public_ip": { + "ip": "203.0.113.24", + "country": "Netherlands" + }, + "local_ip": { + "ip": "198.51.100.23", + "country": "Germany" + }, + "connection_type": "proxy", + "os": "Windows", + "browser": "Chrome", + "device_type": "desktop", + "traffic_source": { + "channel": "Google Ads", + "referrer_domain": "google.com", + "landing_url": "https://shop.example.com/signup?utm_source=google&utm_medium=cpc&gclid=abc123", + "click_id_type": "gclid", + "utm_source": "google", + "utm_medium": "cpc", + "utm_campaign": "", + "utm_content": "", + "utm_term": "" + }, + "risk_score": 80, + "signals": [ + { + "name": "proxy", + "weight": 10, + "description": null + }, + { + "name": "datacenter_ip", + "weight": 10, + "description": null + }, + { + "name": "antidetect_browser", + "weight": 60, + "description": null + } + ], + "detection_flags": { + "vpn": false, + "privacy_relay": false, + "browser_vpn_proxy": false, + "tor": false, + "proxy": true, + "datacenter_ip": true, + "abuser": false, + "os_mismatch": false, + "os_not_detected": false, + "timezone_mismatch": false, + "anti_detect_browser": true, + "browser_automation": false, + "ip_mismatch": true, + "incognito": false, + "search_bot": false, + "suspicious_paid_click": true, + "javascript_disabled": false, + "stun_not_checked": false, + "check_incomplete": false + }, + "observed_at": "2026-09-30T12:34:57.482Z", + "source": "webhook" + } + }, + { + "name": "observed_at_invalid", + "input": { + "request_id": "02f1d973-84db-4156-a7f7-e799e6bf389b", + "visitor_id": "bde0e249-20d8-4544-838c-ed9a0b6d7a36", + "device_id": "ac7c303d-971b-41d1-8e25-cd5b46b46aed", + "session_id": "bde78778-efd2-4c49-952f-1f11b9c05f35", + "cookie_id": "4449bb58-590c-444c-ae1f-d1ddc768dbdd", + "user_hid": "9f86d081884c7d659a2feaa0c55ad015", + "domain": "example.com", + "public_ip": { + "ip": "203.0.113.24", + "country": "Netherlands" + }, + "local_ip": { + "ip": "198.51.100.23", + "country": "Germany" + }, + "connection_type": "proxy", + "os": "Windows", + "browser": "Chrome", + "device_type": "desktop", + "traffic_source": { + "channel": "Google Ads", + "referrer_domain": "google.com", + "landing_url": "https://shop.example.com/signup?utm_source=google&utm_medium=cpc&gclid=abc123", + "click_id_type": "gclid", + "utm_source": "google", + "utm_medium": "cpc", + "utm_campaign": "", + "utm_content": "", + "utm_term": "" + }, + "risk_score": 80, + "signals": [ + { + "name": "proxy", + "weight": 10 + }, + { + "name": "datacenter_ip", + "weight": 10 + }, + { + "name": "antidetect_browser", + "weight": 60 + } + ], + "detection_flags": { + "vpn": false, + "privacy_relay": false, + "browser_vpn_proxy": false, + "tor": false, + "proxy": true, + "datacenter_ip": true, + "abuser": false, + "os_mismatch": false, + "os_not_detected": false, + "timezone_mismatch": false, + "anti_detect_browser": true, + "browser_automation": false, + "ip_mismatch": true, + "incognito": false, + "search_bot": false, + "suspicious_paid_click": true, + "javascript_disabled": false, + "stun_not_checked": false, + "check_incomplete": false + }, + "observed_at": "yesterday" + }, + "expected": { + "request_id": "02f1d973-84db-4156-a7f7-e799e6bf389b", + "visitor_id": "bde0e249-20d8-4544-838c-ed9a0b6d7a36", + "device_id": "ac7c303d-971b-41d1-8e25-cd5b46b46aed", + "session_id": "bde78778-efd2-4c49-952f-1f11b9c05f35", + "cookie_id": "4449bb58-590c-444c-ae1f-d1ddc768dbdd", + "user_hid": "9f86d081884c7d659a2feaa0c55ad015", + "domain": "example.com", + "public_ip": { + "ip": "203.0.113.24", + "country": "Netherlands" + }, + "local_ip": { + "ip": "198.51.100.23", + "country": "Germany" + }, + "connection_type": "proxy", + "os": "Windows", + "browser": "Chrome", + "device_type": "desktop", + "traffic_source": { + "channel": "Google Ads", + "referrer_domain": "google.com", + "landing_url": "https://shop.example.com/signup?utm_source=google&utm_medium=cpc&gclid=abc123", + "click_id_type": "gclid", + "utm_source": "google", + "utm_medium": "cpc", + "utm_campaign": "", + "utm_content": "", + "utm_term": "" + }, + "risk_score": 80, + "signals": [ + { + "name": "proxy", + "weight": 10, + "description": null + }, + { + "name": "datacenter_ip", + "weight": 10, + "description": null + }, + { + "name": "antidetect_browser", + "weight": 60, + "description": null + } + ], + "detection_flags": { + "vpn": false, + "privacy_relay": false, + "browser_vpn_proxy": false, + "tor": false, + "proxy": true, + "datacenter_ip": true, + "abuser": false, + "os_mismatch": false, + "os_not_detected": false, + "timezone_mismatch": false, + "anti_detect_browser": true, + "browser_automation": false, + "ip_mismatch": true, + "incognito": false, + "search_bot": false, + "suspicious_paid_click": true, + "javascript_disabled": false, + "stun_not_checked": false, + "check_incomplete": false + }, + "observed_at": null, + "source": "webhook" + } + }, + { + "name": "risk_score_missing", + "input": { + "request_id": "02f1d973-84db-4156-a7f7-e799e6bf389b", + "visitor_id": "bde0e249-20d8-4544-838c-ed9a0b6d7a36", + "device_id": "ac7c303d-971b-41d1-8e25-cd5b46b46aed", + "session_id": "bde78778-efd2-4c49-952f-1f11b9c05f35", + "cookie_id": "4449bb58-590c-444c-ae1f-d1ddc768dbdd", + "user_hid": "9f86d081884c7d659a2feaa0c55ad015", + "domain": "example.com", + "public_ip": { + "ip": "203.0.113.24", + "country": "Netherlands" + }, + "local_ip": { + "ip": "198.51.100.23", + "country": "Germany" + }, + "connection_type": "proxy", + "os": "Windows", + "browser": "Chrome", + "device_type": "desktop", + "traffic_source": { + "channel": "Google Ads", + "referrer_domain": "google.com", + "landing_url": "https://shop.example.com/signup?utm_source=google&utm_medium=cpc&gclid=abc123", + "click_id_type": "gclid", + "utm_source": "google", + "utm_medium": "cpc", + "utm_campaign": "", + "utm_content": "", + "utm_term": "" + }, + "signals": [ + { + "name": "proxy", + "weight": 10 + }, + { + "name": "datacenter_ip", + "weight": 10 + }, + { + "name": "antidetect_browser", + "weight": 60 + } + ], + "detection_flags": { + "vpn": false, + "privacy_relay": false, + "browser_vpn_proxy": false, + "tor": false, + "proxy": true, + "datacenter_ip": true, + "abuser": false, + "os_mismatch": false, + "os_not_detected": false, + "timezone_mismatch": false, + "anti_detect_browser": true, + "browser_automation": false, + "ip_mismatch": true, + "incognito": false, + "search_bot": false, + "suspicious_paid_click": true, + "javascript_disabled": false, + "stun_not_checked": false, + "check_incomplete": false + }, + "observed_at": "2026-09-30T12:34:57.482913041Z" + }, + "expected": { + "request_id": "02f1d973-84db-4156-a7f7-e799e6bf389b", + "visitor_id": "bde0e249-20d8-4544-838c-ed9a0b6d7a36", + "device_id": "ac7c303d-971b-41d1-8e25-cd5b46b46aed", + "session_id": "bde78778-efd2-4c49-952f-1f11b9c05f35", + "cookie_id": "4449bb58-590c-444c-ae1f-d1ddc768dbdd", + "user_hid": "9f86d081884c7d659a2feaa0c55ad015", + "domain": "example.com", + "public_ip": { + "ip": "203.0.113.24", + "country": "Netherlands" + }, + "local_ip": { + "ip": "198.51.100.23", + "country": "Germany" + }, + "connection_type": "proxy", + "os": "Windows", + "browser": "Chrome", + "device_type": "desktop", + "traffic_source": { + "channel": "Google Ads", + "referrer_domain": "google.com", + "landing_url": "https://shop.example.com/signup?utm_source=google&utm_medium=cpc&gclid=abc123", + "click_id_type": "gclid", + "utm_source": "google", + "utm_medium": "cpc", + "utm_campaign": "", + "utm_content": "", + "utm_term": "" + }, + "risk_score": 0, + "signals": [ + { + "name": "proxy", + "weight": 10, + "description": null + }, + { + "name": "datacenter_ip", + "weight": 10, + "description": null + }, + { + "name": "antidetect_browser", + "weight": 60, + "description": null + } + ], + "detection_flags": { + "vpn": false, + "privacy_relay": false, + "browser_vpn_proxy": false, + "tor": false, + "proxy": true, + "datacenter_ip": true, + "abuser": false, + "os_mismatch": false, + "os_not_detected": false, + "timezone_mismatch": false, + "anti_detect_browser": true, + "browser_automation": false, + "ip_mismatch": true, + "incognito": false, + "search_bot": false, + "suspicious_paid_click": true, + "javascript_disabled": false, + "stun_not_checked": false, + "check_incomplete": false + }, + "observed_at": "2026-09-30T12:34:57.482Z", + "source": "webhook" + } + }, + { + "name": "unknown_keys", + "input": { + "request_id": "02f1d973-84db-4156-a7f7-e799e6bf389b", + "visitor_id": "bde0e249-20d8-4544-838c-ed9a0b6d7a36", + "device_id": "ac7c303d-971b-41d1-8e25-cd5b46b46aed", + "session_id": "bde78778-efd2-4c49-952f-1f11b9c05f35", + "cookie_id": "4449bb58-590c-444c-ae1f-d1ddc768dbdd", + "user_hid": "9f86d081884c7d659a2feaa0c55ad015", + "domain": "example.com", + "public_ip": { + "ip": "203.0.113.24", + "country": "Netherlands" + }, + "local_ip": { + "ip": "198.51.100.23", + "country": "Germany" + }, + "connection_type": "proxy", + "os": "Windows", + "browser": "Chrome", + "device_type": "desktop", + "traffic_source": { + "channel": "Google Ads", + "referrer_domain": "google.com", + "landing_url": "https://shop.example.com/signup?utm_source=google&utm_medium=cpc&gclid=abc123", + "click_id_type": "gclid", + "utm_source": "google", + "utm_medium": "cpc", + "utm_campaign": "", + "utm_content": "", + "utm_term": "" + }, + "risk_score": 80, + "signals": [ + { + "name": "proxy", + "weight": 10 + }, + { + "name": "datacenter_ip", + "weight": 10 + }, + { + "name": "antidetect_browser", + "weight": 60 + } + ], + "detection_flags": { + "vpn": false, + "privacy_relay": false, + "browser_vpn_proxy": false, + "tor": false, + "proxy": true, + "datacenter_ip": true, + "abuser": false, + "os_mismatch": false, + "os_not_detected": false, + "timezone_mismatch": false, + "anti_detect_browser": true, + "browser_automation": false, + "ip_mismatch": true, + "incognito": false, + "search_bot": false, + "suspicious_paid_click": true, + "javascript_disabled": false, + "stun_not_checked": false, + "check_incomplete": false + }, + "observed_at": "2026-09-30T12:34:57.482913041Z", + "high_value": { + "x": 1 + }, + "schema_extra": "y" + }, + "expected": { + "request_id": "02f1d973-84db-4156-a7f7-e799e6bf389b", + "visitor_id": "bde0e249-20d8-4544-838c-ed9a0b6d7a36", + "device_id": "ac7c303d-971b-41d1-8e25-cd5b46b46aed", + "session_id": "bde78778-efd2-4c49-952f-1f11b9c05f35", + "cookie_id": "4449bb58-590c-444c-ae1f-d1ddc768dbdd", + "user_hid": "9f86d081884c7d659a2feaa0c55ad015", + "domain": "example.com", + "public_ip": { + "ip": "203.0.113.24", + "country": "Netherlands" + }, + "local_ip": { + "ip": "198.51.100.23", + "country": "Germany" + }, + "connection_type": "proxy", + "os": "Windows", + "browser": "Chrome", + "device_type": "desktop", + "traffic_source": { + "channel": "Google Ads", + "referrer_domain": "google.com", + "landing_url": "https://shop.example.com/signup?utm_source=google&utm_medium=cpc&gclid=abc123", + "click_id_type": "gclid", + "utm_source": "google", + "utm_medium": "cpc", + "utm_campaign": "", + "utm_content": "", + "utm_term": "" + }, + "risk_score": 80, + "signals": [ + { + "name": "proxy", + "weight": 10, + "description": null + }, + { + "name": "datacenter_ip", + "weight": 10, + "description": null + }, + { + "name": "antidetect_browser", + "weight": 60, + "description": null + } + ], + "detection_flags": { + "vpn": false, + "privacy_relay": false, + "browser_vpn_proxy": false, + "tor": false, + "proxy": true, + "datacenter_ip": true, + "abuser": false, + "os_mismatch": false, + "os_not_detected": false, + "timezone_mismatch": false, + "anti_detect_browser": true, + "browser_automation": false, + "ip_mismatch": true, + "incognito": false, + "search_bot": false, + "suspicious_paid_click": true, + "javascript_disabled": false, + "stun_not_checked": false, + "check_incomplete": false + }, + "observed_at": "2026-09-30T12:34:57.482Z", + "source": "webhook" + } + }, + { + "name": "empty_data", + "input": {}, + "expected": { + "request_id": "", + "visitor_id": "", + "device_id": "", + "session_id": "", + "cookie_id": "", + "user_hid": null, + "domain": "", + "public_ip": { + "ip": "", + "country": "" + }, + "local_ip": { + "ip": "", + "country": "" + }, + "connection_type": "", + "os": "", + "browser": "", + "device_type": "", + "traffic_source": { + "channel": "", + "referrer_domain": "", + "landing_url": "", + "click_id_type": "", + "utm_source": "", + "utm_medium": "", + "utm_campaign": "", + "utm_content": "", + "utm_term": "" + }, + "risk_score": 0, + "signals": [], + "detection_flags": { + "vpn": false, + "privacy_relay": false, + "browser_vpn_proxy": false, + "tor": false, + "proxy": false, + "datacenter_ip": false, + "abuser": false, + "os_mismatch": false, + "os_not_detected": false, + "timezone_mismatch": false, + "anti_detect_browser": false, + "browser_automation": false, + "ip_mismatch": false, + "incognito": false, + "search_bot": false, + "suspicious_paid_click": false, + "javascript_disabled": false, + "stun_not_checked": false, + "check_incomplete": false + }, + "observed_at": null, + "source": "webhook" + } + } + ] +} diff --git a/test/data/risk-band-cases.json b/test/data/risk-band-cases.json new file mode 100644 index 0000000..98c7d1c --- /dev/null +++ b/test/data/risk-band-cases.json @@ -0,0 +1,44 @@ +{ + "cases": [ + { + "score": 0, + "band": "trusted" + }, + { + "score": 5, + "band": "trusted" + }, + { + "score": 29, + "band": "trusted" + }, + { + "score": 30, + "band": "suspicious" + }, + { + "score": 45, + "band": "suspicious" + }, + { + "score": 59, + "band": "suspicious" + }, + { + "score": 60, + "band": "dangerous" + }, + { + "score": 85, + "band": "dangerous" + }, + { + "score": 100, + "band": "dangerous" + }, + { + "score": 999, + "band": "rate_limited" + } + ] +} diff --git a/test/data/signal-slug-cases.json b/test/data/signal-slug-cases.json new file mode 100644 index 0000000..2a73957 --- /dev/null +++ b/test/data/signal-slug-cases.json @@ -0,0 +1,129 @@ +{ + "description": "Description (History score_details) to signal slug (webhook signals[].name). The slug function: exact map, then known prefixes, then 'Sticky verdict: ' prefix, then the fallback slugger.", + "cases": [ + { + "description": "Is tor", + "slug": "tor" + }, + { + "description": "Is VPN", + "slug": "vpn" + }, + { + "description": "Is privacy relay", + "slug": "privacy_relay" + }, + { + "description": "Is proxy", + "slug": "proxy" + }, + { + "description": "Is datacenter", + "slug": "datacenter_ip" + }, + { + "description": "Is abuser", + "slug": "abuser" + }, + { + "description": "Stun is not checked", + "slug": "stun_not_checked" + }, + { + "description": "Stun passed (late arrival, corrected)", + "slug": "stun_late_correction" + }, + { + "description": "UA OS is not detected", + "slug": "os_not_detected" + }, + { + "description": "Network OS is not detected", + "slug": "os_not_detected" + }, + { + "description": "Browser timezone ≠ IP-timezone", + "slug": "timezone_mismatch" + }, + { + "description": "Browser VPN/Proxy", + "slug": "browser_vpn_proxy" + }, + { + "description": "Browser Automation", + "slug": "browser_automation" + }, + { + "description": "Port scan routed via proxy (antidetect browser pattern)", + "slug": "proxy_routed_antidetect" + }, + { + "description": "User has been banned 1H, to many requests", + "slug": "rate_limited" + }, + { + "description": "Antidetect browser (turn_block)", + "slug": "antidetect_browser" + }, + { + "description": "Antidetect browser (port: 51234)", + "slug": "antidetect_browser" + }, + { + "description": "Os_mismatch (Fail by windows detect)", + "slug": "os_mismatch" + }, + { + "description": "OS mismatch2 (TCP behaviour: ttl)", + "slug": "os_mismatch2" + }, + { + "description": "TCP handshake (window)", + "slug": "tcp_handshake_v2" + }, + { + "description": "Latency test (WS vs TCP: 40ms)", + "slug": "ws_tcp_latency" + }, + { + "description": "JavaScript disabled (WebRTC, WebGL)", + "slug": "javascript_disabled" + }, + { + "description": "Sticky verdict: Antidetect browser (turn_block) (request 11111111-2222-4333-8444-555555555555)", + "slug": "antidetect_browser" + }, + { + "description": "Sticky verdict: Port scan routed via proxy (antidetect browser pattern) (request 11111111-2222-4333-8444-555555555555)", + "slug": "port_scan_routed_via_proxy" + }, + { + "description": "IP ≠ leakIP (198.51.100.7 ≠ 203.0.113.9, source=scanner)", + "slug": "ip__neq__leakip" + }, + { + "description": "Device spoofing", + "slug": "device_spoofing" + }, + { + "description": " Weird -- Name / Here ", + "slug": "weird_name_here" + }, + { + "description": "État Spécial (x)", + "slug": "état_spécial" + }, + { + "description": "(only parens)", + "slug": "unknown" + }, + { + "description": "!!!", + "slug": "unknown" + }, + { + "description": "", + "slug": "unknown" + } + ] +} diff --git a/test/data/webhook-identification-scored.json b/test/data/webhook-identification-scored.json new file mode 100644 index 0000000..f2de2d2 --- /dev/null +++ b/test/data/webhook-identification-scored.json @@ -0,0 +1,74 @@ +{ + "event_type": "identification.scored", + "schema_version": "2026-06-01", + "created_at": "2026-09-30T12:34:57.482913041Z", + "data": { + "request_id": "02f1d973-84db-4156-a7f7-e799e6bf389b", + "visitor_id": "bde0e249-20d8-4544-838c-ed9a0b6d7a36", + "device_id": "ac7c303d-971b-41d1-8e25-cd5b46b46aed", + "session_id": "bde78778-efd2-4c49-952f-1f11b9c05f35", + "cookie_id": "4449bb58-590c-444c-ae1f-d1ddc768dbdd", + "user_hid": "9f86d081884c7d659a2feaa0c55ad015", + "domain": "example.com", + "public_ip": { + "ip": "203.0.113.24", + "country": "Netherlands" + }, + "local_ip": { + "ip": "198.51.100.23", + "country": "Germany" + }, + "connection_type": "proxy", + "os": "Windows", + "browser": "Chrome", + "device_type": "desktop", + "traffic_source": { + "channel": "Google Ads", + "referrer_domain": "google.com", + "landing_url": "https://shop.example.com/signup?utm_source=google&utm_medium=cpc&gclid=abc123", + "click_id_type": "gclid", + "utm_source": "google", + "utm_medium": "cpc", + "utm_campaign": "", + "utm_content": "", + "utm_term": "" + }, + "risk_score": 80, + "signals": [ + { + "name": "proxy", + "weight": 10 + }, + { + "name": "datacenter_ip", + "weight": 10 + }, + { + "name": "antidetect_browser", + "weight": 60 + } + ], + "detection_flags": { + "vpn": false, + "privacy_relay": false, + "browser_vpn_proxy": false, + "tor": false, + "proxy": true, + "datacenter_ip": true, + "abuser": false, + "os_mismatch": false, + "os_not_detected": false, + "timezone_mismatch": false, + "anti_detect_browser": true, + "browser_automation": false, + "ip_mismatch": true, + "incognito": false, + "search_bot": false, + "suspicious_paid_click": true, + "javascript_disabled": false, + "stun_not_checked": false, + "check_incomplete": false + }, + "observed_at": "2026-09-30T12:34:57.482913041Z" + } +} diff --git a/test/data/webhook-identification-scored.raw.txt b/test/data/webhook-identification-scored.raw.txt new file mode 100644 index 0000000..a4da089 --- /dev/null +++ b/test/data/webhook-identification-scored.raw.txt @@ -0,0 +1 @@ +{"event_type":"identification.scored","schema_version":"2026-06-01","created_at":"2026-09-30T12:34:57.482913041Z","data":{"request_id":"02f1d973-84db-4156-a7f7-e799e6bf389b","visitor_id":"bde0e249-20d8-4544-838c-ed9a0b6d7a36","device_id":"ac7c303d-971b-41d1-8e25-cd5b46b46aed","session_id":"bde78778-efd2-4c49-952f-1f11b9c05f35","cookie_id":"4449bb58-590c-444c-ae1f-d1ddc768dbdd","user_hid":"9f86d081884c7d659a2feaa0c55ad015","domain":"example.com","public_ip":{"ip":"203.0.113.24","country":"Netherlands"},"local_ip":{"ip":"198.51.100.23","country":"Germany"},"connection_type":"proxy","os":"Windows","browser":"Chrome","device_type":"desktop","traffic_source":{"channel":"Google Ads","referrer_domain":"google.com","landing_url":"https://shop.example.com/signup?utm_source=google\u0026utm_medium=cpc\u0026gclid=abc123","click_id_type":"gclid","utm_source":"google","utm_medium":"cpc","utm_campaign":"","utm_content":"","utm_term":""},"risk_score":80,"signals":[{"name":"proxy","weight":10},{"name":"datacenter_ip","weight":10},{"name":"antidetect_browser","weight":60}],"detection_flags":{"vpn":false,"privacy_relay":false,"browser_vpn_proxy":false,"tor":false,"proxy":true,"datacenter_ip":true,"abuser":false,"os_mismatch":false,"os_not_detected":false,"timezone_mismatch":false,"anti_detect_browser":true,"browser_automation":false,"ip_mismatch":true,"incognito":false,"search_bot":false,"suspicious_paid_click":true,"javascript_disabled":false,"stun_not_checked":false,"check_incomplete":false},"observed_at":"2026-09-30T12:34:57.482913041Z"}} \ No newline at end of file diff --git a/test/data/webhook-ping.json b/test/data/webhook-ping.json new file mode 100644 index 0000000..94568cc --- /dev/null +++ b/test/data/webhook-ping.json @@ -0,0 +1,5 @@ +{ + "created_at": "2026-09-30T12:34:56Z", + "event_type": "webhook.ping", + "schema_version": "2026-06-01" +} diff --git a/test/data/webhook-ping.raw.txt b/test/data/webhook-ping.raw.txt new file mode 100644 index 0000000..0f9f73b --- /dev/null +++ b/test/data/webhook-ping.raw.txt @@ -0,0 +1 @@ +{"created_at":"2026-09-30T12:34:56Z","event_type":"webhook.ping","schema_version":"2026-06-01"} \ No newline at end of file diff --git a/test/data/webhook-rate-limited.json b/test/data/webhook-rate-limited.json new file mode 100644 index 0000000..b9edf47 --- /dev/null +++ b/test/data/webhook-rate-limited.json @@ -0,0 +1,66 @@ +{ + "event_type": "identification.scored", + "schema_version": "2026-06-01", + "created_at": "2026-09-30T13:10:00.5Z", + "data": { + "request_id": "1a2b3c4d-5e6f-4a7b-8c9d-0e1f2a3b4c5d", + "visitor_id": "00000000-0000-0000-0000-000000000000", + "device_id": "00000000-0000-0000-0000-000000000000", + "session_id": "00000000-0000-0000-0000-000000000000", + "cookie_id": "00000000-0000-0000-0000-000000000000", + "user_hid": "-1", + "domain": "example.com", + "public_ip": { + "ip": "203.0.113.200", + "country": "" + }, + "local_ip": { + "ip": "", + "country": "" + }, + "connection_type": "unknown", + "os": "Unknown", + "browser": "Unknown", + "device_type": "desktop", + "traffic_source": { + "channel": "", + "referrer_domain": "", + "landing_url": "", + "click_id_type": "", + "utm_source": "", + "utm_medium": "", + "utm_campaign": "", + "utm_content": "", + "utm_term": "" + }, + "risk_score": 999, + "signals": [ + { + "name": "rate_limited", + "weight": 999 + } + ], + "detection_flags": { + "vpn": false, + "privacy_relay": false, + "browser_vpn_proxy": false, + "tor": false, + "proxy": false, + "datacenter_ip": false, + "abuser": false, + "os_mismatch": false, + "os_not_detected": false, + "timezone_mismatch": false, + "anti_detect_browser": false, + "browser_automation": false, + "ip_mismatch": false, + "incognito": false, + "search_bot": false, + "suspicious_paid_click": false, + "javascript_disabled": false, + "stun_not_checked": false, + "check_incomplete": false + }, + "observed_at": "2026-09-30T13:10:00.5Z" + } +} diff --git a/test/data/webhook-signature-vectors.json b/test/data/webhook-signature-vectors.json new file mode 100644 index 0000000..7529cf0 --- /dev/null +++ b/test/data/webhook-signature-vectors.json @@ -0,0 +1,201 @@ +{ + "description": "Shared webhook signature test vectors for every ShieldLabs server SDK. Header format: X-Shield-Signature: sha256=. body_base64 holds the exact raw bytes; body is the same bytes decoded as UTF-8 for convenience. A vector has either 'secret' (string) or 'secrets' (list: valid when any matches).", + "header_name": "X-Shield-Signature", + "vectors": [ + { + "name": "valid_lowercase_hex", + "secret": "whsec_test_4f1a9c2e7b5d3a8f6e0c1b2d9a7e5f3c", + "body": "{\"event_type\":\"identification.scored\",\"schema_version\":\"2026-06-01\",\"created_at\":\"2026-09-30T10:15:00Z\",\"data\":{\"request_id\":\"3f2b8c1e-9d4a-4e6b-8a7c-2d1e0f9b6a53\",\"risk_score\":35,\"user_hid\":null}}", + "body_base64": "eyJldmVudF90eXBlIjoiaWRlbnRpZmljYXRpb24uc2NvcmVkIiwic2NoZW1hX3ZlcnNpb24iOiIyMDI2LTA2LTAxIiwiY3JlYXRlZF9hdCI6IjIwMjYtMDktMzBUMTA6MTU6MDBaIiwiZGF0YSI6eyJyZXF1ZXN0X2lkIjoiM2YyYjhjMWUtOWQ0YS00ZTZiLThhN2MtMmQxZTBmOWI2YTUzIiwicmlza19zY29yZSI6MzUsInVzZXJfaGlkIjpudWxsfX0=", + "signature_header": "sha256=1c2d0251d83eba5ed62c98d3758b805d227022850cdb8ddb1aa3754524345453", + "valid": true, + "note": "Canonical header as sent by ShieldLabs" + }, + { + "name": "valid_uppercase_hex", + "secret": "whsec_test_4f1a9c2e7b5d3a8f6e0c1b2d9a7e5f3c", + "body": "{\"event_type\":\"identification.scored\",\"schema_version\":\"2026-06-01\",\"created_at\":\"2026-09-30T10:15:00Z\",\"data\":{\"request_id\":\"3f2b8c1e-9d4a-4e6b-8a7c-2d1e0f9b6a53\",\"risk_score\":35,\"user_hid\":null}}", + "body_base64": "eyJldmVudF90eXBlIjoiaWRlbnRpZmljYXRpb24uc2NvcmVkIiwic2NoZW1hX3ZlcnNpb24iOiIyMDI2LTA2LTAxIiwiY3JlYXRlZF9hdCI6IjIwMjYtMDktMzBUMTA6MTU6MDBaIiwiZGF0YSI6eyJyZXF1ZXN0X2lkIjoiM2YyYjhjMWUtOWQ0YS00ZTZiLThhN2MtMmQxZTBmOWI2YTUzIiwicmlza19zY29yZSI6MzUsInVzZXJfaGlkIjpudWxsfX0=", + "signature_header": "sha256=1C2D0251D83EBA5ED62C98D3758B805D227022850CDB8DDB1AA3754524345453", + "valid": true, + "note": "Hex digest compared case-insensitively" + }, + { + "name": "valid_surrounding_whitespace", + "secret": "whsec_test_4f1a9c2e7b5d3a8f6e0c1b2d9a7e5f3c", + "body": "{\"event_type\":\"identification.scored\",\"schema_version\":\"2026-06-01\",\"created_at\":\"2026-09-30T10:15:00Z\",\"data\":{\"request_id\":\"3f2b8c1e-9d4a-4e6b-8a7c-2d1e0f9b6a53\",\"risk_score\":35,\"user_hid\":null}}", + "body_base64": "eyJldmVudF90eXBlIjoiaWRlbnRpZmljYXRpb24uc2NvcmVkIiwic2NoZW1hX3ZlcnNpb24iOiIyMDI2LTA2LTAxIiwiY3JlYXRlZF9hdCI6IjIwMjYtMDktMzBUMTA6MTU6MDBaIiwiZGF0YSI6eyJyZXF1ZXN0X2lkIjoiM2YyYjhjMWUtOWQ0YS00ZTZiLThhN2MtMmQxZTBmOWI2YTUzIiwicmlza19zY29yZSI6MzUsInVzZXJfaGlkIjpudWxsfX0=", + "signature_header": " sha256=1c2d0251d83eba5ed62c98d3758b805d227022850cdb8ddb1aa3754524345453 ", + "valid": true, + "note": "Leading/trailing whitespace is trimmed from the header value" + }, + { + "name": "valid_utf8_multibyte_body", + "secret": "whsec_test_4f1a9c2e7b5d3a8f6e0c1b2d9a7e5f3c", + "body": "{\"event_type\":\"webhook.ping\",\"schema_version\":\"2026-06-01\",\"created_at\":\"2026-09-30T10:15:00Z\",\"note\":\"проверка ✓ 日本\"}", + "body_base64": "eyJldmVudF90eXBlIjoid2ViaG9vay5waW5nIiwic2NoZW1hX3ZlcnNpb24iOiIyMDI2LTA2LTAxIiwiY3JlYXRlZF9hdCI6IjIwMjYtMDktMzBUMTA6MTU6MDBaIiwibm90ZSI6ItC/0YDQvtCy0LXRgNC60LAg4pyTIOaXpeacrCJ9", + "signature_header": "sha256=64a1212632b11b58dde55df7689bad609ae82f84681608ce3394da4d399b4362", + "valid": true, + "note": "HMAC is computed over the raw UTF-8 bytes" + }, + { + "name": "valid_pretty_printed_body", + "secret": "whsec_test_4f1a9c2e7b5d3a8f6e0c1b2d9a7e5f3c", + "body": "{\n \"event_type\": \"identification.scored\",\n \"schema_version\": \"2026-06-01\",\n \"created_at\": \"2026-09-30T10:15:00Z\",\n \"data\": {\n \"request_id\": \"3f2b8c1e-9d4a-4e6b-8a7c-2d1e0f9b6a53\",\n \"risk_score\": 35,\n \"user_hid\": null\n }\n}", + "body_base64": "ewogICJldmVudF90eXBlIjogImlkZW50aWZpY2F0aW9uLnNjb3JlZCIsCiAgInNjaGVtYV92ZXJzaW9uIjogIjIwMjYtMDYtMDEiLAogICJjcmVhdGVkX2F0IjogIjIwMjYtMDktMzBUMTA6MTU6MDBaIiwKICAiZGF0YSI6IHsKICAgICJyZXF1ZXN0X2lkIjogIjNmMmI4YzFlLTlkNGEtNGU2Yi04YTdjLTJkMWUwZjliNmE1MyIsCiAgICAicmlza19zY29yZSI6IDM1LAogICAgInVzZXJfaGlkIjogbnVsbAogIH0KfQ==", + "signature_header": "sha256=263308293bcccb571464f60e0f8ef6076f9d23559b1658b6dc6f74ac3fdffd35", + "valid": true, + "note": "Whatever bytes arrive are what was signed" + }, + { + "name": "invalid_reserialized_body", + "secret": "whsec_test_4f1a9c2e7b5d3a8f6e0c1b2d9a7e5f3c", + "body": "{\n \"event_type\": \"identification.scored\",\n \"schema_version\": \"2026-06-01\",\n \"created_at\": \"2026-09-30T10:15:00Z\",\n \"data\": {\n \"request_id\": \"3f2b8c1e-9d4a-4e6b-8a7c-2d1e0f9b6a53\",\n \"risk_score\": 35,\n \"user_hid\": null\n }\n}", + "body_base64": "ewogICJldmVudF90eXBlIjogImlkZW50aWZpY2F0aW9uLnNjb3JlZCIsCiAgInNjaGVtYV92ZXJzaW9uIjogIjIwMjYtMDYtMDEiLAogICJjcmVhdGVkX2F0IjogIjIwMjYtMDktMzBUMTA6MTU6MDBaIiwKICAiZGF0YSI6IHsKICAgICJyZXF1ZXN0X2lkIjogIjNmMmI4YzFlLTlkNGEtNGU2Yi04YTdjLTJkMWUwZjliNmE1MyIsCiAgICAicmlza19zY29yZSI6IDM1LAogICAgInVzZXJfaGlkIjogbnVsbAogIH0KfQ==", + "signature_header": "sha256=1c2d0251d83eba5ed62c98d3758b805d227022850cdb8ddb1aa3754524345453", + "valid": false, + "note": "Signature of the compact body does not match a re-serialized body: always verify raw bytes" + }, + { + "name": "invalid_tampered_body", + "secret": "whsec_test_4f1a9c2e7b5d3a8f6e0c1b2d9a7e5f3c", + "body": "{\"event_type\":\"identification.scored\",\"schema_version\":\"2026-06-01\",\"created_at\":\"2026-09-30T10:15:00Z\",\"data\":{\"request_id\":\"3f2b8c1e-9d4a-4e6b-8a7c-2d1e0f9b6a53\",\"risk_score\":5,\"user_hid\":null}}", + "body_base64": "eyJldmVudF90eXBlIjoiaWRlbnRpZmljYXRpb24uc2NvcmVkIiwic2NoZW1hX3ZlcnNpb24iOiIyMDI2LTA2LTAxIiwiY3JlYXRlZF9hdCI6IjIwMjYtMDktMzBUMTA6MTU6MDBaIiwiZGF0YSI6eyJyZXF1ZXN0X2lkIjoiM2YyYjhjMWUtOWQ0YS00ZTZiLThhN2MtMmQxZTBmOWI2YTUzIiwicmlza19zY29yZSI6NSwidXNlcl9oaWQiOm51bGx9fQ==", + "signature_header": "sha256=1c2d0251d83eba5ed62c98d3758b805d227022850cdb8ddb1aa3754524345453", + "valid": false, + "note": "Body changed after signing" + }, + { + "name": "invalid_wrong_secret", + "secret": "whsec_test_0000000000000000000000000000000", + "body": "{\"event_type\":\"identification.scored\",\"schema_version\":\"2026-06-01\",\"created_at\":\"2026-09-30T10:15:00Z\",\"data\":{\"request_id\":\"3f2b8c1e-9d4a-4e6b-8a7c-2d1e0f9b6a53\",\"risk_score\":35,\"user_hid\":null}}", + "body_base64": "eyJldmVudF90eXBlIjoiaWRlbnRpZmljYXRpb24uc2NvcmVkIiwic2NoZW1hX3ZlcnNpb24iOiIyMDI2LTA2LTAxIiwiY3JlYXRlZF9hdCI6IjIwMjYtMDktMzBUMTA6MTU6MDBaIiwiZGF0YSI6eyJyZXF1ZXN0X2lkIjoiM2YyYjhjMWUtOWQ0YS00ZTZiLThhN2MtMmQxZTBmOWI2YTUzIiwicmlza19zY29yZSI6MzUsInVzZXJfaGlkIjpudWxsfX0=", + "signature_header": "sha256=1c2d0251d83eba5ed62c98d3758b805d227022850cdb8ddb1aa3754524345453", + "valid": false, + "note": "Different endpoint secret" + }, + { + "name": "invalid_missing_prefix", + "secret": "whsec_test_4f1a9c2e7b5d3a8f6e0c1b2d9a7e5f3c", + "body": "{\"event_type\":\"identification.scored\",\"schema_version\":\"2026-06-01\",\"created_at\":\"2026-09-30T10:15:00Z\",\"data\":{\"request_id\":\"3f2b8c1e-9d4a-4e6b-8a7c-2d1e0f9b6a53\",\"risk_score\":35,\"user_hid\":null}}", + "body_base64": "eyJldmVudF90eXBlIjoiaWRlbnRpZmljYXRpb24uc2NvcmVkIiwic2NoZW1hX3ZlcnNpb24iOiIyMDI2LTA2LTAxIiwiY3JlYXRlZF9hdCI6IjIwMjYtMDktMzBUMTA6MTU6MDBaIiwiZGF0YSI6eyJyZXF1ZXN0X2lkIjoiM2YyYjhjMWUtOWQ0YS00ZTZiLThhN2MtMmQxZTBmOWI2YTUzIiwicmlza19zY29yZSI6MzUsInVzZXJfaGlkIjpudWxsfX0=", + "signature_header": "1c2d0251d83eba5ed62c98d3758b805d227022850cdb8ddb1aa3754524345453", + "valid": false, + "note": "The sha256= prefix is required" + }, + { + "name": "invalid_wrong_algorithm_prefix", + "secret": "whsec_test_4f1a9c2e7b5d3a8f6e0c1b2d9a7e5f3c", + "body": "{\"event_type\":\"identification.scored\",\"schema_version\":\"2026-06-01\",\"created_at\":\"2026-09-30T10:15:00Z\",\"data\":{\"request_id\":\"3f2b8c1e-9d4a-4e6b-8a7c-2d1e0f9b6a53\",\"risk_score\":35,\"user_hid\":null}}", + "body_base64": "eyJldmVudF90eXBlIjoiaWRlbnRpZmljYXRpb24uc2NvcmVkIiwic2NoZW1hX3ZlcnNpb24iOiIyMDI2LTA2LTAxIiwiY3JlYXRlZF9hdCI6IjIwMjYtMDktMzBUMTA6MTU6MDBaIiwiZGF0YSI6eyJyZXF1ZXN0X2lkIjoiM2YyYjhjMWUtOWQ0YS00ZTZiLThhN2MtMmQxZTBmOWI2YTUzIiwicmlza19zY29yZSI6MzUsInVzZXJfaGlkIjpudWxsfX0=", + "signature_header": "sha1=1c2d0251d83eba5ed62c98d3758b805d227022850cdb8ddb1aa3754524345453", + "valid": false, + "note": "Only sha256 is accepted" + }, + { + "name": "invalid_truncated_digest", + "secret": "whsec_test_4f1a9c2e7b5d3a8f6e0c1b2d9a7e5f3c", + "body": "{\"event_type\":\"identification.scored\",\"schema_version\":\"2026-06-01\",\"created_at\":\"2026-09-30T10:15:00Z\",\"data\":{\"request_id\":\"3f2b8c1e-9d4a-4e6b-8a7c-2d1e0f9b6a53\",\"risk_score\":35,\"user_hid\":null}}", + "body_base64": "eyJldmVudF90eXBlIjoiaWRlbnRpZmljYXRpb24uc2NvcmVkIiwic2NoZW1hX3ZlcnNpb24iOiIyMDI2LTA2LTAxIiwiY3JlYXRlZF9hdCI6IjIwMjYtMDktMzBUMTA6MTU6MDBaIiwiZGF0YSI6eyJyZXF1ZXN0X2lkIjoiM2YyYjhjMWUtOWQ0YS00ZTZiLThhN2MtMmQxZTBmOWI2YTUzIiwicmlza19zY29yZSI6MzUsInVzZXJfaGlkIjpudWxsfX0=", + "signature_header": "sha256=1c2d0251d83eba5ed62c98d3758b805d227022850cdb8ddb1aa37545243454", + "valid": false, + "note": "Digest must be 64 hex characters" + }, + { + "name": "invalid_non_hex_digest", + "secret": "whsec_test_4f1a9c2e7b5d3a8f6e0c1b2d9a7e5f3c", + "body": "{\"event_type\":\"identification.scored\",\"schema_version\":\"2026-06-01\",\"created_at\":\"2026-09-30T10:15:00Z\",\"data\":{\"request_id\":\"3f2b8c1e-9d4a-4e6b-8a7c-2d1e0f9b6a53\",\"risk_score\":35,\"user_hid\":null}}", + "body_base64": "eyJldmVudF90eXBlIjoiaWRlbnRpZmljYXRpb24uc2NvcmVkIiwic2NoZW1hX3ZlcnNpb24iOiIyMDI2LTA2LTAxIiwiY3JlYXRlZF9hdCI6IjIwMjYtMDktMzBUMTA6MTU6MDBaIiwiZGF0YSI6eyJyZXF1ZXN0X2lkIjoiM2YyYjhjMWUtOWQ0YS00ZTZiLThhN2MtMmQxZTBmOWI2YTUzIiwicmlza19zY29yZSI6MzUsInVzZXJfaGlkIjpudWxsfX0=", + "signature_header": "sha256=zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz", + "valid": false, + "note": "Digest must be hexadecimal" + }, + { + "name": "invalid_empty_header", + "secret": "whsec_test_4f1a9c2e7b5d3a8f6e0c1b2d9a7e5f3c", + "body": "{\"event_type\":\"identification.scored\",\"schema_version\":\"2026-06-01\",\"created_at\":\"2026-09-30T10:15:00Z\",\"data\":{\"request_id\":\"3f2b8c1e-9d4a-4e6b-8a7c-2d1e0f9b6a53\",\"risk_score\":35,\"user_hid\":null}}", + "body_base64": "eyJldmVudF90eXBlIjoiaWRlbnRpZmljYXRpb24uc2NvcmVkIiwic2NoZW1hX3ZlcnNpb24iOiIyMDI2LTA2LTAxIiwiY3JlYXRlZF9hdCI6IjIwMjYtMDktMzBUMTA6MTU6MDBaIiwiZGF0YSI6eyJyZXF1ZXN0X2lkIjoiM2YyYjhjMWUtOWQ0YS00ZTZiLThhN2MtMmQxZTBmOWI2YTUzIiwicmlza19zY29yZSI6MzUsInVzZXJfaGlkIjpudWxsfX0=", + "signature_header": "", + "valid": false, + "note": "Missing signature header" + }, + { + "name": "invalid_prefix_only", + "secret": "whsec_test_4f1a9c2e7b5d3a8f6e0c1b2d9a7e5f3c", + "body": "{\"event_type\":\"identification.scored\",\"schema_version\":\"2026-06-01\",\"created_at\":\"2026-09-30T10:15:00Z\",\"data\":{\"request_id\":\"3f2b8c1e-9d4a-4e6b-8a7c-2d1e0f9b6a53\",\"risk_score\":35,\"user_hid\":null}}", + "body_base64": "eyJldmVudF90eXBlIjoiaWRlbnRpZmljYXRpb24uc2NvcmVkIiwic2NoZW1hX3ZlcnNpb24iOiIyMDI2LTA2LTAxIiwiY3JlYXRlZF9hdCI6IjIwMjYtMDktMzBUMTA6MTU6MDBaIiwiZGF0YSI6eyJyZXF1ZXN0X2lkIjoiM2YyYjhjMWUtOWQ0YS00ZTZiLThhN2MtMmQxZTBmOWI2YTUzIiwicmlza19zY29yZSI6MzUsInVzZXJfaGlkIjpudWxsfX0=", + "signature_header": "sha256=", + "valid": false, + "note": "Prefix without digest" + }, + { + "name": "invalid_empty_secret", + "secret": "", + "body": "{\"event_type\":\"identification.scored\",\"schema_version\":\"2026-06-01\",\"created_at\":\"2026-09-30T10:15:00Z\",\"data\":{\"request_id\":\"3f2b8c1e-9d4a-4e6b-8a7c-2d1e0f9b6a53\",\"risk_score\":35,\"user_hid\":null}}", + "body_base64": "eyJldmVudF90eXBlIjoiaWRlbnRpZmljYXRpb24uc2NvcmVkIiwic2NoZW1hX3ZlcnNpb24iOiIyMDI2LTA2LTAxIiwiY3JlYXRlZF9hdCI6IjIwMjYtMDktMzBUMTA6MTU6MDBaIiwiZGF0YSI6eyJyZXF1ZXN0X2lkIjoiM2YyYjhjMWUtOWQ0YS00ZTZiLThhN2MtMmQxZTBmOWI2YTUzIiwicmlza19zY29yZSI6MzUsInVzZXJfaGlkIjpudWxsfX0=", + "signature_header": "sha256=68ae6c4becfcbc22bb017c96fec281973bc554f96afe7ba875854cd65b3368dc", + "valid": false, + "note": "An empty secret is a configuration error and must never verify" + }, + { + "name": "valid_real_ping_delivery", + "secret": "whsec_00112233445566778899aabbccddeeff", + "body": "{\"created_at\":\"2026-09-30T12:34:56Z\",\"event_type\":\"webhook.ping\",\"schema_version\":\"2026-06-01\"}", + "signature_header": "sha256=ea2685733d254f7028fb031c4214583b0650de01e6c8c93131236024edd9fdd8", + "valid": true, + "note": "Exact webhook.ping body and signature as produced by the ShieldLabs servers", + "body_base64": "eyJjcmVhdGVkX2F0IjoiMjAyNi0wOS0zMFQxMjozNDo1NloiLCJldmVudF90eXBlIjoid2ViaG9vay5waW5nIiwic2NoZW1hX3ZlcnNpb24iOiIyMDI2LTA2LTAxIn0=" + }, + { + "name": "valid_scored_with_escaped_ampersand", + "secret": "whsec_00112233445566778899aabbccddeeff", + "body": "{\"event_type\":\"identification.scored\",\"schema_version\":\"2026-06-01\",\"created_at\":\"2026-09-30T12:34:57.482913041Z\",\"data\":{\"request_id\":\"02f1d973-84db-4156-a7f7-e799e6bf389b\",\"visitor_id\":\"bde0e249-20d8-4544-838c-ed9a0b6d7a36\",\"device_id\":\"ac7c303d-971b-41d1-8e25-cd5b46b46aed\",\"session_id\":\"bde78778-efd2-4c49-952f-1f11b9c05f35\",\"cookie_id\":\"4449bb58-590c-444c-ae1f-d1ddc768dbdd\",\"user_hid\":\"9f86d081884c7d659a2feaa0c55ad015\",\"domain\":\"example.com\",\"public_ip\":{\"ip\":\"203.0.113.24\",\"country\":\"Netherlands\"},\"local_ip\":{\"ip\":\"198.51.100.23\",\"country\":\"Germany\"},\"connection_type\":\"proxy\",\"os\":\"Windows\",\"browser\":\"Chrome\",\"device_type\":\"desktop\",\"traffic_source\":{\"channel\":\"Google Ads\",\"referrer_domain\":\"google.com\",\"landing_url\":\"https://shop.example.com/signup?utm_source=google\\u0026utm_medium=cpc\\u0026gclid=abc123\",\"click_id_type\":\"gclid\",\"utm_source\":\"google\",\"utm_medium\":\"cpc\",\"utm_campaign\":\"\",\"utm_content\":\"\",\"utm_term\":\"\"},\"risk_score\":80,\"signals\":[{\"name\":\"proxy\",\"weight\":10},{\"name\":\"datacenter_ip\",\"weight\":10},{\"name\":\"antidetect_browser\",\"weight\":60}],\"detection_flags\":{\"vpn\":false,\"privacy_relay\":false,\"browser_vpn_proxy\":false,\"tor\":false,\"proxy\":true,\"datacenter_ip\":true,\"abuser\":false,\"os_mismatch\":false,\"os_not_detected\":false,\"timezone_mismatch\":false,\"anti_detect_browser\":true,\"browser_automation\":false,\"ip_mismatch\":true,\"incognito\":false,\"search_bot\":false,\"suspicious_paid_click\":true,\"javascript_disabled\":false,\"stun_not_checked\":false,\"check_incomplete\":false},\"observed_at\":\"2026-09-30T12:34:57.482913041Z\"}}", + "signature_header": "sha256=c4d44b7873625bdfda98cdb7a02d460f8492ca6a68fad8d147a5f30ad16f92a9", + "valid": true, + "note": "Server bodies escape & as \\u0026; verify the bytes as received", + "body_base64": "eyJldmVudF90eXBlIjoiaWRlbnRpZmljYXRpb24uc2NvcmVkIiwic2NoZW1hX3ZlcnNpb24iOiIyMDI2LTA2LTAxIiwiY3JlYXRlZF9hdCI6IjIwMjYtMDktMzBUMTI6MzQ6NTcuNDgyOTEzMDQxWiIsImRhdGEiOnsicmVxdWVzdF9pZCI6IjAyZjFkOTczLTg0ZGItNDE1Ni1hN2Y3LWU3OTllNmJmMzg5YiIsInZpc2l0b3JfaWQiOiJiZGUwZTI0OS0yMGQ4LTQ1NDQtODM4Yy1lZDlhMGI2ZDdhMzYiLCJkZXZpY2VfaWQiOiJhYzdjMzAzZC05NzFiLTQxZDEtOGUyNS1jZDViNDZiNDZhZWQiLCJzZXNzaW9uX2lkIjoiYmRlNzg3NzgtZWZkMi00YzQ5LTk1MmYtMWYxMWI5YzA1ZjM1IiwiY29va2llX2lkIjoiNDQ0OWJiNTgtNTkwYy00NDRjLWFlMWYtZDFkZGM3NjhkYmRkIiwidXNlcl9oaWQiOiI5Zjg2ZDA4MTg4NGM3ZDY1OWEyZmVhYTBjNTVhZDAxNSIsImRvbWFpbiI6ImV4YW1wbGUuY29tIiwicHVibGljX2lwIjp7ImlwIjoiMjAzLjAuMTEzLjI0IiwiY291bnRyeSI6Ik5ldGhlcmxhbmRzIn0sImxvY2FsX2lwIjp7ImlwIjoiMTk4LjUxLjEwMC4yMyIsImNvdW50cnkiOiJHZXJtYW55In0sImNvbm5lY3Rpb25fdHlwZSI6InByb3h5Iiwib3MiOiJXaW5kb3dzIiwiYnJvd3NlciI6IkNocm9tZSIsImRldmljZV90eXBlIjoiZGVza3RvcCIsInRyYWZmaWNfc291cmNlIjp7ImNoYW5uZWwiOiJHb29nbGUgQWRzIiwicmVmZXJyZXJfZG9tYWluIjoiZ29vZ2xlLmNvbSIsImxhbmRpbmdfdXJsIjoiaHR0cHM6Ly9zaG9wLmV4YW1wbGUuY29tL3NpZ251cD91dG1fc291cmNlPWdvb2dsZVx1MDAyNnV0bV9tZWRpdW09Y3BjXHUwMDI2Z2NsaWQ9YWJjMTIzIiwiY2xpY2tfaWRfdHlwZSI6ImdjbGlkIiwidXRtX3NvdXJjZSI6Imdvb2dsZSIsInV0bV9tZWRpdW0iOiJjcGMiLCJ1dG1fY2FtcGFpZ24iOiIiLCJ1dG1fY29udGVudCI6IiIsInV0bV90ZXJtIjoiIn0sInJpc2tfc2NvcmUiOjgwLCJzaWduYWxzIjpbeyJuYW1lIjoicHJveHkiLCJ3ZWlnaHQiOjEwfSx7Im5hbWUiOiJkYXRhY2VudGVyX2lwIiwid2VpZ2h0IjoxMH0seyJuYW1lIjoiYW50aWRldGVjdF9icm93c2VyIiwid2VpZ2h0Ijo2MH1dLCJkZXRlY3Rpb25fZmxhZ3MiOnsidnBuIjpmYWxzZSwicHJpdmFjeV9yZWxheSI6ZmFsc2UsImJyb3dzZXJfdnBuX3Byb3h5IjpmYWxzZSwidG9yIjpmYWxzZSwicHJveHkiOnRydWUsImRhdGFjZW50ZXJfaXAiOnRydWUsImFidXNlciI6ZmFsc2UsIm9zX21pc21hdGNoIjpmYWxzZSwib3Nfbm90X2RldGVjdGVkIjpmYWxzZSwidGltZXpvbmVfbWlzbWF0Y2giOmZhbHNlLCJhbnRpX2RldGVjdF9icm93c2VyIjp0cnVlLCJicm93c2VyX2F1dG9tYXRpb24iOmZhbHNlLCJpcF9taXNtYXRjaCI6dHJ1ZSwiaW5jb2duaXRvIjpmYWxzZSwic2VhcmNoX2JvdCI6ZmFsc2UsInN1c3BpY2lvdXNfcGFpZF9jbGljayI6dHJ1ZSwiamF2YXNjcmlwdF9kaXNhYmxlZCI6ZmFsc2UsInN0dW5fbm90X2NoZWNrZWQiOmZhbHNlLCJjaGVja19pbmNvbXBsZXRlIjpmYWxzZX0sIm9ic2VydmVkX2F0IjoiMjAyNi0wOS0zMFQxMjozNDo1Ny40ODI5MTMwNDFaIn19" + }, + { + "name": "invalid_scored_unescaped_reserialization", + "secret": "whsec_00112233445566778899aabbccddeeff", + "body": "{\"event_type\":\"identification.scored\",\"schema_version\":\"2026-06-01\",\"created_at\":\"2026-09-30T12:34:57.482913041Z\",\"data\":{\"request_id\":\"02f1d973-84db-4156-a7f7-e799e6bf389b\",\"visitor_id\":\"bde0e249-20d8-4544-838c-ed9a0b6d7a36\",\"device_id\":\"ac7c303d-971b-41d1-8e25-cd5b46b46aed\",\"session_id\":\"bde78778-efd2-4c49-952f-1f11b9c05f35\",\"cookie_id\":\"4449bb58-590c-444c-ae1f-d1ddc768dbdd\",\"user_hid\":\"9f86d081884c7d659a2feaa0c55ad015\",\"domain\":\"example.com\",\"public_ip\":{\"ip\":\"203.0.113.24\",\"country\":\"Netherlands\"},\"local_ip\":{\"ip\":\"198.51.100.23\",\"country\":\"Germany\"},\"connection_type\":\"proxy\",\"os\":\"Windows\",\"browser\":\"Chrome\",\"device_type\":\"desktop\",\"traffic_source\":{\"channel\":\"Google Ads\",\"referrer_domain\":\"google.com\",\"landing_url\":\"https://shop.example.com/signup?utm_source=google&utm_medium=cpc&gclid=abc123\",\"click_id_type\":\"gclid\",\"utm_source\":\"google\",\"utm_medium\":\"cpc\",\"utm_campaign\":\"\",\"utm_content\":\"\",\"utm_term\":\"\"},\"risk_score\":80,\"signals\":[{\"name\":\"proxy\",\"weight\":10},{\"name\":\"datacenter_ip\",\"weight\":10},{\"name\":\"antidetect_browser\",\"weight\":60}],\"detection_flags\":{\"vpn\":false,\"privacy_relay\":false,\"browser_vpn_proxy\":false,\"tor\":false,\"proxy\":true,\"datacenter_ip\":true,\"abuser\":false,\"os_mismatch\":false,\"os_not_detected\":false,\"timezone_mismatch\":false,\"anti_detect_browser\":true,\"browser_automation\":false,\"ip_mismatch\":true,\"incognito\":false,\"search_bot\":false,\"suspicious_paid_click\":true,\"javascript_disabled\":false,\"stun_not_checked\":false,\"check_incomplete\":false},\"observed_at\":\"2026-09-30T12:34:57.482913041Z\"}}", + "signature_header": "sha256=c4d44b7873625bdfda98cdb7a02d460f8492ca6a68fad8d147a5f30ad16f92a9", + "valid": false, + "note": "Re-serializing (unescaping \\u0026) changes the bytes", + "body_base64": "eyJldmVudF90eXBlIjoiaWRlbnRpZmljYXRpb24uc2NvcmVkIiwic2NoZW1hX3ZlcnNpb24iOiIyMDI2LTA2LTAxIiwiY3JlYXRlZF9hdCI6IjIwMjYtMDktMzBUMTI6MzQ6NTcuNDgyOTEzMDQxWiIsImRhdGEiOnsicmVxdWVzdF9pZCI6IjAyZjFkOTczLTg0ZGItNDE1Ni1hN2Y3LWU3OTllNmJmMzg5YiIsInZpc2l0b3JfaWQiOiJiZGUwZTI0OS0yMGQ4LTQ1NDQtODM4Yy1lZDlhMGI2ZDdhMzYiLCJkZXZpY2VfaWQiOiJhYzdjMzAzZC05NzFiLTQxZDEtOGUyNS1jZDViNDZiNDZhZWQiLCJzZXNzaW9uX2lkIjoiYmRlNzg3NzgtZWZkMi00YzQ5LTk1MmYtMWYxMWI5YzA1ZjM1IiwiY29va2llX2lkIjoiNDQ0OWJiNTgtNTkwYy00NDRjLWFlMWYtZDFkZGM3NjhkYmRkIiwidXNlcl9oaWQiOiI5Zjg2ZDA4MTg4NGM3ZDY1OWEyZmVhYTBjNTVhZDAxNSIsImRvbWFpbiI6ImV4YW1wbGUuY29tIiwicHVibGljX2lwIjp7ImlwIjoiMjAzLjAuMTEzLjI0IiwiY291bnRyeSI6Ik5ldGhlcmxhbmRzIn0sImxvY2FsX2lwIjp7ImlwIjoiMTk4LjUxLjEwMC4yMyIsImNvdW50cnkiOiJHZXJtYW55In0sImNvbm5lY3Rpb25fdHlwZSI6InByb3h5Iiwib3MiOiJXaW5kb3dzIiwiYnJvd3NlciI6IkNocm9tZSIsImRldmljZV90eXBlIjoiZGVza3RvcCIsInRyYWZmaWNfc291cmNlIjp7ImNoYW5uZWwiOiJHb29nbGUgQWRzIiwicmVmZXJyZXJfZG9tYWluIjoiZ29vZ2xlLmNvbSIsImxhbmRpbmdfdXJsIjoiaHR0cHM6Ly9zaG9wLmV4YW1wbGUuY29tL3NpZ251cD91dG1fc291cmNlPWdvb2dsZSZ1dG1fbWVkaXVtPWNwYyZnY2xpZD1hYmMxMjMiLCJjbGlja19pZF90eXBlIjoiZ2NsaWQiLCJ1dG1fc291cmNlIjoiZ29vZ2xlIiwidXRtX21lZGl1bSI6ImNwYyIsInV0bV9jYW1wYWlnbiI6IiIsInV0bV9jb250ZW50IjoiIiwidXRtX3Rlcm0iOiIifSwicmlza19zY29yZSI6ODAsInNpZ25hbHMiOlt7Im5hbWUiOiJwcm94eSIsIndlaWdodCI6MTB9LHsibmFtZSI6ImRhdGFjZW50ZXJfaXAiLCJ3ZWlnaHQiOjEwfSx7Im5hbWUiOiJhbnRpZGV0ZWN0X2Jyb3dzZXIiLCJ3ZWlnaHQiOjYwfV0sImRldGVjdGlvbl9mbGFncyI6eyJ2cG4iOmZhbHNlLCJwcml2YWN5X3JlbGF5IjpmYWxzZSwiYnJvd3Nlcl92cG5fcHJveHkiOmZhbHNlLCJ0b3IiOmZhbHNlLCJwcm94eSI6dHJ1ZSwiZGF0YWNlbnRlcl9pcCI6dHJ1ZSwiYWJ1c2VyIjpmYWxzZSwib3NfbWlzbWF0Y2giOmZhbHNlLCJvc19ub3RfZGV0ZWN0ZWQiOmZhbHNlLCJ0aW1lem9uZV9taXNtYXRjaCI6ZmFsc2UsImFudGlfZGV0ZWN0X2Jyb3dzZXIiOnRydWUsImJyb3dzZXJfYXV0b21hdGlvbiI6ZmFsc2UsImlwX21pc21hdGNoIjp0cnVlLCJpbmNvZ25pdG8iOmZhbHNlLCJzZWFyY2hfYm90IjpmYWxzZSwic3VzcGljaW91c19wYWlkX2NsaWNrIjp0cnVlLCJqYXZhc2NyaXB0X2Rpc2FibGVkIjpmYWxzZSwic3R1bl9ub3RfY2hlY2tlZCI6ZmFsc2UsImNoZWNrX2luY29tcGxldGUiOmZhbHNlfSwib2JzZXJ2ZWRfYXQiOiIyMDI2LTA5LTMwVDEyOjM0OjU3LjQ4MjkxMzA0MVoifX0=" + }, + { + "name": "valid_rotation_second_secret_matches", + "secrets": [ + "whsec_old_secret_value_0000000000", + "whsec_00112233445566778899aabbccddeeff" + ], + "body": "{\"created_at\":\"2026-09-30T12:34:56Z\",\"event_type\":\"webhook.ping\",\"schema_version\":\"2026-06-01\"}", + "signature_header": "sha256=ea2685733d254f7028fb031c4214583b0650de01e6c8c93131236024edd9fdd8", + "valid": true, + "note": "verify accepts a list of secrets during rotation; valid when any matches", + "body_base64": "eyJjcmVhdGVkX2F0IjoiMjAyNi0wOS0zMFQxMjozNDo1NloiLCJldmVudF90eXBlIjoid2ViaG9vay5waW5nIiwic2NoZW1hX3ZlcnNpb24iOiIyMDI2LTA2LTAxIn0=" + }, + { + "name": "invalid_rotation_no_secret_matches", + "secrets": [ + "whsec_old_secret_value_0000000000", + "whsec_other_secret_value_11111111" + ], + "body": "{\"created_at\":\"2026-09-30T12:34:56Z\",\"event_type\":\"webhook.ping\",\"schema_version\":\"2026-06-01\"}", + "signature_header": "sha256=ea2685733d254f7028fb031c4214583b0650de01e6c8c93131236024edd9fdd8", + "valid": false, + "note": "No secret in the list matches", + "body_base64": "eyJjcmVhdGVkX2F0IjoiMjAyNi0wOS0zMFQxMjozNDo1NloiLCJldmVudF90eXBlIjoid2ViaG9vay5waW5nIiwic2NoZW1hX3ZlcnNpb24iOiIyMDI2LTA2LTAxIn0=" + }, + { + "name": "invalid_secret_without_prefix", + "secret": "00112233445566778899aabbccddeeff", + "body": "{\"created_at\":\"2026-09-30T12:34:56Z\",\"event_type\":\"webhook.ping\",\"schema_version\":\"2026-06-01\"}", + "signature_header": "sha256=ea2685733d254f7028fb031c4214583b0650de01e6c8c93131236024edd9fdd8", + "valid": false, + "note": "The key is the full secret string including whsec_; stripping the prefix breaks verification", + "body_base64": "eyJjcmVhdGVkX2F0IjoiMjAyNi0wOS0zMFQxMjozNDo1NloiLCJldmVudF90eXBlIjoid2ViaG9vay5waW5nIiwic2NoZW1hX3ZlcnNpb24iOiIyMDI2LTA2LTAxIn0=" + } + ] +} diff --git a/test/data/webhook-test-delivery.json b/test/data/webhook-test-delivery.json new file mode 100644 index 0000000..8e214ce --- /dev/null +++ b/test/data/webhook-test-delivery.json @@ -0,0 +1,72 @@ +{ + "created_at": "2026-09-30T12:34:56Z", + "data": { + "browser": "Chrome", + "connection_type": "proxy", + "cookie_id": "2c9d1e8f-4b7a-4c3e-9d2f-1a8b7c6d5e4f", + "detection_flags": { + "abuser": true, + "anti_detect_browser": false, + "browser_vpn_proxy": false, + "check_incomplete": false, + "datacenter_ip": true, + "incognito": false, + "ip_mismatch": false, + "javascript_disabled": false, + "os_mismatch": false, + "os_not_detected": false, + "privacy_relay": false, + "proxy": true, + "stun_not_checked": false, + "suspicious_paid_click": false, + "timezone_mismatch": false, + "tor": false, + "vpn": false + }, + "device_id": "6f1e2d3c-4b5a-5968-8776-655443322110", + "device_type": "desktop", + "domain": "example.com", + "local_ip": { + "country": "BY", + "ip": "198.51.100.10" + }, + "observed_at": "2026-09-30T12:34:56Z", + "os": "Windows", + "public_ip": { + "country": "BY", + "ip": "203.0.113.10" + }, + "request_id": "13f84f05-7c2a-4e9b-9f1d-2a6b8c0e4d11", + "risk_score": 30, + "session_id": "3a2b1c0d-9e8f-4a7b-8c6d-5e4f3a2b1c0d", + "signals": [ + { + "name": "proxy", + "weight": 10 + }, + { + "name": "datacenter_ip", + "weight": 10 + }, + { + "name": "abuser", + "weight": 10 + } + ], + "traffic_source": { + "channel": "Direct", + "click_id_type": "", + "landing_url": "https://example.com/", + "referrer_domain": "", + "utm_campaign": "", + "utm_content": "", + "utm_medium": "", + "utm_source": "", + "utm_term": "" + }, + "user_hid": null, + "visitor_id": "7a6b5c4d-3e2f-5a1b-9c8d-7e6f5a4b3c2d" + }, + "event_type": "identification.scored", + "schema_version": "2026-06-01" +} diff --git a/test/docs.test.ts b/test/docs.test.ts new file mode 100644 index 0000000..38bc75e --- /dev/null +++ b/test/docs.test.ts @@ -0,0 +1,88 @@ +// Checks the documents this repository publishes: README structure, example IP addresses, +// changelog and license. +import { readFileSync, readdirSync, statSync } from 'node:fs'; +import { join, relative } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { describe, expect, it } from 'vitest'; + +const root = fileURLToPath(new URL('..', import.meta.url)); +const SKIP_DIRS = new Set(['node_modules', '.git', 'dist', 'coverage', 'data']); +const TEXT_FILE = /\.(ts|mjs|js|md|yml|json)$/; + +function collect(dir: string): string[] { + const files: string[] = []; + for (const name of readdirSync(dir)) { + const path = join(dir, name); + if (statSync(path).isDirectory()) { + if (!SKIP_DIRS.has(name)) files.push(...collect(path)); + } else if (TEXT_FILE.test(name) && name !== 'package-lock.json') { + files.push(relative(root, path)); + } + } + return files; +} + +const files = [...collect(root), 'test/data/README.md']; +const read = (file: string): string => readFileSync(join(root, file), 'utf8'); +const docs = [ + 'README.md', + 'CHANGELOG.md', + 'CONTRIBUTING.md', + 'examples/node-http/README.md', + 'test/data/README.md', +]; + +describe('repository documents', () => { + it('finds the docs, source and examples', () => { + expect(files).toEqual( + expect.arrayContaining([ + 'README.md', + 'src/index.ts', + 'examples/node-http/server.mjs', + '.github/workflows/ci.yml', + ]), + ); + }); + + it('uses documentation IP ranges in docs, source and examples', () => { + const scope = files.filter( + (file) => docs.includes(file) || file.startsWith('src/') || file.startsWith('examples/'), + ); + const allowed = /^(192\.0\.2|198\.51\.100|203\.0\.113)\.\d+$|^(0\.0\.0\.0|127\.0\.0\.1)$/; + const found = scope.flatMap((file) => + (read(file).match(/\b\d{1,3}(?:\.\d{1,3}){3}\b/g) ?? []).map((ip) => `${file}: ${ip}`), + ); + expect(found.filter((entry) => !allowed.test(entry.split(': ')[1] ?? ''))).toEqual([]); + }); + + it('keeps the README structure and call to action', () => { + const readme = read('README.md'); + const headings = readme + .split('\n') + .filter((line) => line.startsWith('## ')) + .map((line) => line.slice(3)); + expect(headings).toEqual([ + 'How it fits', + 'Install', + 'Quick start', + 'Guide', + 'Reference', + 'Errors and retries', + 'Compatibility', + 'Development', + 'License', + ]); + expect(readme).toContain('Start free'); + expect(readme).toContain('trusted` (0-29), `suspicious` (30-59) or `dangerous` (60-100)'); + expect(readme).toContain('https://docs.shieldlabs.ai'); + expect(readme).toContain('contact@shieldlabs.ai'); + }); + + it('documents version 1.0.0 in the changelog', () => { + expect(read('CHANGELOG.md')).toContain('## [1.0.0] - 2026-09-30'); + }); + + it('carries the MIT license of ShieldLabs Inc.', () => { + expect(read('LICENSE')).toContain('Copyright (c) 2026 ShieldLabs Inc.'); + }); +}); diff --git a/test/entries.test.ts b/test/entries.test.ts new file mode 100644 index 0000000..82c37b4 --- /dev/null +++ b/test/entries.test.ts @@ -0,0 +1,240 @@ +import { createHmac } from 'node:crypto'; +import { readFileSync } from 'node:fs'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import * as edge from '../src/edge.js'; +import * as node from '../src/index.js'; +import { ShieldLabsError, ValidationError } from '../src/index.js'; +import { abortReason, detectRuntime, sleep, throwIfAborted, userAgent } from '../src/runtime.js'; +import { + REQUEST_ID, + TEST_API_KEY, + fakeFetch, + historyPage, + historyRow, + loadText, + settle, +} from './helpers.js'; + +const EXPECTED_EXPORTS = [ + 'ApiError', + 'AuthenticationError', + 'BadRequestError', + 'ConnectionError', + 'NIL_UUID', + 'NotFoundError', + 'QuotaExceededError', + 'RISK_BANDS', + 'RateLimitError', + 'SIGNALS', + 'ServerError', + 'ShieldLabs', + 'ShieldLabsError', + 'ShieldLabsManagement', + 'SignatureVerificationError', + 'TimeoutError', + 'VERSION', + 'ValidationError', + 'WebhookParseError', + 'evaluateIdentification', + 'isRateLimited', + 'riskBand', + 'userHid', + 'userHidAsync', + 'webhooks', +]; + +// HMAC-SHA256(key = "whsec_your_signing_secret", message = "user-42") computed independently. +const KNOWN_USER_HID = createHmac('sha256', 'whsec_your_signing_secret') + .update('user-42') + .digest('hex'); + +describe('public exports', () => { + it('are identical in the Node entry and the edge entry', () => { + expect(Object.keys(node).sort()).toEqual(EXPECTED_EXPORTS); + expect(Object.keys(edge).sort()).toEqual(EXPECTED_EXPORTS); + }); + + it('share the same classes and constants', () => { + expect(edge.ShieldLabs).toBe(node.ShieldLabs); + expect(edge.RateLimitError).toBe(node.RateLimitError); + expect(edge.SIGNALS).toBe(node.SIGNALS); + }); + + it('report the package version', () => { + const pkg = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')) as { + version: string; + }; + expect(node.VERSION).toBe(pkg.version); + expect(node.VERSION).toBe('1.0.0'); + }); + + it('expose frozen webhook helpers', () => { + expect(Object.isFrozen(node.webhooks)).toBe(true); + expect(Object.keys(node.webhooks).sort()).toEqual([ + 'constructEvent', + 'constructEventAsync', + 'verifySignature', + 'verifySignatureAsync', + ]); + }); +}); + +describe('userHid', () => { + it('returns HMAC-SHA256 of the user ID as 64 lowercase hex characters', () => { + const hid = node.userHid('user-42', 'whsec_your_signing_secret'); + expect(hid).toBe(KNOWN_USER_HID); + expect(hid).toMatch(/^[0-9a-f]{64}$/); + expect(node.userHid('user-42', 'another-secret')).not.toBe(hid); + }); + + it('matches in every entry and flavor', async () => { + await expect(node.userHidAsync('user-42', 'whsec_your_signing_secret')).resolves.toBe( + KNOWN_USER_HID, + ); + await expect(edge.userHidAsync('user-42', 'whsec_your_signing_secret')).resolves.toBe( + KNOWN_USER_HID, + ); + await expect(edge.userHidAsync('Jöhn ✓', 'sëcret')).resolves.toBe( + node.userHid('Jöhn ✓', 'sëcret'), + ); + }); + + it.each([ + ['', 'secret'], + ['user-42', ''], + [42, 'secret'], + ['user-42', undefined], + ])('rejects userId %j with secret %j', async (userId, secret) => { + expect(() => node.userHid(userId as string, secret as string)).toThrow(ValidationError); + await expect(node.userHidAsync(userId as string, secret as string)).rejects.toThrow( + ValidationError, + ); + await expect(edge.userHidAsync(userId as string, secret as string)).rejects.toThrow( + ValidationError, + ); + }); +}); + +describe('edge entry', () => { + const body = loadText('webhook-ping.raw.txt'); + const header = 'sha256=ea2685733d254f7028fb031c4214583b0650de01e6c8c93131236024edd9fdd8'; + const secret = 'whsec_00112233445566778899aabbccddeeff'; + + it('points synchronous helpers to their Async variants', () => { + expect(() => edge.webhooks.verifySignature(body, header, secret)).toThrow(ShieldLabsError); + expect(() => edge.webhooks.verifySignature(body, header, secret)).toThrow( + /verifySignatureAsync/, + ); + expect(() => edge.webhooks.constructEvent(body, header, secret)).toThrow(/constructEventAsync/); + expect(() => edge.userHid('user-42', 'secret')).toThrow(/userHidAsync/); + }); + + it('verifies with WebCrypto', async () => { + await expect(edge.webhooks.verifySignatureAsync(body, header, secret)).resolves.toBe(true); + await expect(edge.webhooks.constructEventAsync(body, header, secret)).resolves.toMatchObject({ + event_type: 'webhook.ping', + }); + }); + + it('explains when WebCrypto is missing', async () => { + vi.stubGlobal('crypto', undefined); + await expect(edge.webhooks.verifySignatureAsync(body, header, secret)).rejects.toThrow( + /WebCrypto/, + ); + await expect(edge.userHidAsync('user-42', 'secret')).rejects.toThrow(/WebCrypto/); + }); + + it('runs the same History client', async () => { + const fake = fakeFetch([historyPage([historyRow(REQUEST_ID)])]); + const client = new edge.ShieldLabs({ apiKey: TEST_API_KEY, fetch: fake.fetch }); + await expect(client.identifications.get(REQUEST_ID)).resolves.toMatchObject({ + request_id: REQUEST_ID, + }); + }); +}); + +describe('User-Agent', () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('names the SDK, its version and the runtime', () => { + expect(userAgent()).toBe(`shieldlabs-node/1.0.0 node/${process.versions.node}`); + }); + + it('detects other runtimes', () => { + vi.stubGlobal('Bun', { version: '1.4.0' }); + expect(detectRuntime()).toBe('bun/1.4.0'); + vi.unstubAllGlobals(); + vi.stubGlobal('Deno', { version: { deno: '2.5.0' } }); + expect(detectRuntime()).toBe('deno/2.5.0'); + vi.unstubAllGlobals(); + vi.stubGlobal('EdgeRuntime', 'edge-runtime'); + expect(detectRuntime()).toBe('edge-runtime'); + vi.unstubAllGlobals(); + vi.stubGlobal('navigator', { userAgent: 'Cloudflare-Workers' }); + expect(detectRuntime()).toBe('workerd'); + vi.unstubAllGlobals(); + vi.stubGlobal('process', undefined); + expect(detectRuntime()).toBeNull(); + }); +}); + +describe('runtime helpers', () => { + it('rejects a sleep that starts with an aborted signal', async () => { + const controller = new AbortController(); + controller.abort(new Error('already aborted')); + await expect(sleep(1000, controller.signal)).rejects.toThrow('already aborted'); + }); + + it('creates an AbortError when a signal carries no reason', () => { + const signal = { aborted: true, reason: undefined } as unknown as AbortSignal; + const reason = abortReason(signal) as Error; + expect(reason.name).toBe('AbortError'); + expect(() => throwIfAborted(signal)).toThrow('The operation was aborted.'); + expect(() => throwIfAborted(undefined)).not.toThrow(); + }); +}); + +describe('sleep', () => { + // Longer than the longest delay one timer can take (2 147 483 647 ms, about 24.8 days). + const LONG_SLEEP_MS = 3_000_000_000; + + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('resolves after the given time', async () => { + const result = settle(sleep(250)); + await vi.advanceTimersByTimeAsync(249); + expect(vi.getTimerCount()).toBe(1); + await vi.advanceTimersByTimeAsync(1); + expect(await result).toEqual({ value: undefined }); + expect(vi.getTimerCount()).toBe(0); + }); + + it('waits the whole time when it is longer than one timer allows', async () => { + let resolved = false; + const result = sleep(LONG_SLEEP_MS).then(() => { + resolved = true; + }); + await vi.advanceTimersByTimeAsync(LONG_SLEEP_MS - 1); + expect(resolved).toBe(false); + await vi.advanceTimersByTimeAsync(1); + await result; + expect(resolved).toBe(true); + }); + + it('rejects with the abort reason during a long wait, after its first timer', async () => { + const controller = new AbortController(); + const result = settle(sleep(LONG_SLEEP_MS, controller.signal)); + await vi.advanceTimersByTimeAsync(2_500_000_000); + controller.abort(new Error('stopped waiting')); + expect(((await result).error as Error).message).toBe('stopped waiting'); + expect(vi.getTimerCount()).toBe(0); + }); +}); diff --git a/test/errors.test.ts b/test/errors.test.ts new file mode 100644 index 0000000..f88f9d5 --- /dev/null +++ b/test/errors.test.ts @@ -0,0 +1,562 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import * as sdk from '../src/index.js'; +import { + ApiError, + AuthenticationError, + ConnectionError, + RateLimitError, + ServerError, + ShieldLabs, + ShieldLabsError, + ShieldLabsManagement, + TimeoutError, + ValidationError, +} from '../src/index.js'; +import { createApiError, parseRetryAfter } from '../src/http.js'; +import { + REQUEST_ID, + TEST_API_KEY, + fakeFetch, + hangingFetch, + historyPage, + jsonResponse, + loadJson, + rawResponse, + settle, +} from './helpers.js'; + +interface ErrorCase { + surface: 'history' | 'management'; + status: number; + content_type: string | null; + body: string; + expected_error: string; + retry: boolean; +} + +const errorCases = loadJson<{ cases: ErrorCase[] }>('error-responses.json').cases; +const START = Date.parse('2026-09-30T12:00:00.000Z'); + +function historyClient(replies: Parameters[0], maxRetries = 2) { + const fake = fakeFetch(replies); + return { client: new ShieldLabs({ apiKey: TEST_API_KEY, fetch: fake.fetch, maxRetries }), fake }; +} + +function managementClient(replies: Parameters[0], maxRetries = 2) { + const fake = fakeFetch(replies); + const client = new ShieldLabsManagement({ + secretKey: '0123456789abcdef0123456789abcdef', + domain: 'example.com', + fetch: fake.fetch, + maxRetries, + }); + return { client, fake }; +} + +beforeEach(() => { + vi.useFakeTimers({ now: START }); +}); + +afterEach(() => { + vi.useRealTimers(); +}); + +describe('error-responses.json', () => { + it.each(errorCases)( + '$surface $status -> $expected_error (retry: $retry)', + async ({ surface, status, content_type, body, expected_error, retry }) => { + const response = rawResponse(body, status, content_type); + const call: { promise: Promise; fake: ReturnType } = + surface === 'history' + ? (() => { + const { client, fake } = historyClient([response], 1); + return { promise: client.history.search('request_id', REQUEST_ID), fake }; + })() + : (() => { + const { client, fake } = managementClient([response], 1); + return { promise: client.getProfile(), fake }; + })(); + const result = settle(call.promise); + await vi.runAllTimersAsync(); + const { error } = await result; + const ExpectedClass = (sdk as unknown as Record Error>)[ + expected_error + ]; + expect(ExpectedClass).toBeDefined(); + expect(error).toBeInstanceOf(ExpectedClass); + expect(error).toBeInstanceOf(ApiError); + expect((error as ApiError).status).toBe(status); + expect((error as Error).name).toBe(expected_error); + expect(call.fake.calls).toHaveLength(retry ? 2 : 1); + }, + ); + + it('parses error bodies defensively', () => { + const headers = new Headers(); + const jsonText = createApiError('History API', 401, '{"error":"invalid api key"}\n', headers); + expect(jsonText.body).toEqual({ error: 'invalid api key' }); + expect(jsonText.message).toContain('invalid api key'); + + const bareString = createApiError('Management API', 400, '"fail parse uuid"', headers); + expect(bareString.body).toBe('fail parse uuid'); + expect(bareString.message).toContain('fail parse uuid'); + + const nullBody = createApiError('Management API', 400, 'null', headers); + expect(nullBody.body).toBeNull(); + + const emptyBody = createApiError('Management API', 401, '', headers); + expect(emptyBody.body).toBeNull(); + expect(emptyBody.message).toMatch(/HTTP 401\./); + + const plain = createApiError('History API', 404, '404 page not found', headers); + expect(plain.body).toBe('404 page not found'); + expect(plain.message).toContain('404 page not found'); + expect(plain.message).toContain('Check the base URL'); + + const html = createApiError( + 'History API', + 502, + '

502 Bad Gateway

', + headers, + ); + expect(html.body).toContain(''); + expect(html.message).not.toContain('<'); + + const longText = createApiError('History API', 500, 'x'.repeat(500), headers); + expect(longText.message.length).toBeLessThan(300); + + const objectWithoutError = createApiError('History API', 500, '{"message":"x"}', headers); + expect(objectWithoutError.body).toEqual({ message: 'x' }); + }); + + it('maps statuses without a dedicated class to ApiError', () => { + const error = createApiError('History API', 418, '', new Headers()); + expect(error.constructor).toBe(ApiError); + }); + + it('never throws while reading a broken header object', () => { + const headers = { + get: () => { + throw new Error('broken'); + }, + }; + const error = createApiError('History API', 429, '', headers) as RateLimitError; + expect(error).toBeInstanceOf(RateLimitError); + expect(error.retryAfter).toBeUndefined(); + }); + + it('adds actionable hints', () => { + expect(createApiError('History API', 401, '', new Headers()).message).toMatch( + /Private API Key/, + ); + expect(createApiError('Management API', 403, '', new Headers()).message).toMatch( + /registered domain/, + ); + expect(createApiError('Management API', 429, '', new Headers()).message).toMatch(/10 minutes/); + }); +}); + +describe('error classes', () => { + it('form one hierarchy under ShieldLabsError', () => { + const rateLimit = new RateLimitError('slow down', { status: 429, retryAfter: 2 }); + expect(rateLimit).toBeInstanceOf(ApiError); + expect(rateLimit).toBeInstanceOf(ShieldLabsError); + expect(rateLimit).toBeInstanceOf(Error); + expect(rateLimit.retryAfter).toBe(2); + expect(rateLimit.name).toBe('RateLimitError'); + expect(rateLimit.body).toBeNull(); + expect(rateLimit.headers.get('anything')).toBeNull(); + expect(String(rateLimit)).toBe('RateLimitError: slow down'); + expect(rateLimit.stack).toContain('RateLimitError: slow down'); + + for (const ErrorClass of [ConnectionError, TimeoutError, ValidationError]) { + const error = new ErrorClass('x', { cause: 'root' }); + expect(error).toBeInstanceOf(ShieldLabsError); + expect(error).not.toBeInstanceOf(ApiError); + expect(error.cause).toBe('root'); + } + expect(new ShieldLabsError('x').cause).toBeUndefined(); + }); +}); + +describe('Retry-After', () => { + it('parses seconds and HTTP dates', () => { + expect(parseRetryAfter('7')).toBe(7); + expect(parseRetryAfter(' 1.5 ')).toBe(1.5); + expect(parseRetryAfter('0')).toBe(0); + const now = Date.parse('2026-09-30T12:00:00Z'); + expect(parseRetryAfter('Wed, 30 Sep 2026 12:00:30 GMT', now)).toBe(30); + expect(parseRetryAfter('Wed, 30 Sep 2026 11:00:00 GMT', now)).toBe(0); + expect(parseRetryAfter('soon')).toBeUndefined(); + expect(parseRetryAfter('-5')).toBeUndefined(); + expect(parseRetryAfter(null)).toBeUndefined(); + expect(parseRetryAfter(undefined)).toBeUndefined(); + }); + + it('is exposed on RateLimitError', async () => { + const { client } = historyClient( + [ + rawResponse('{"error":"too many requests"}\n', 429, 'application/json', { + 'Retry-After': '4', + }), + ], + 0, + ); + const { error } = await settle(client.history.search('request_id', REQUEST_ID)); + expect((error as RateLimitError).retryAfter).toBe(4); + }); +}); + +describe('retries', () => { + function times(calls: { time: number }[]): number[] { + return calls.map((call) => call.time - START); + } + + it('retries connection errors with exponential backoff and jitter', async () => { + vi.spyOn(Math, 'random').mockReturnValue(0); + const { client, fake } = historyClient([ + new TypeError('fetch failed'), + new TypeError('fetch failed'), + historyPage([]), + ]); + const result = settle(client.history.search('request_id', REQUEST_ID)); + await vi.runAllTimersAsync(); + expect((await result).value).toEqual({ data: [], total: 0 }); + expect(times(fake.calls)).toEqual([0, 250, 750]); + }); + + it('uses the full nominal delay at the top of the jitter range', async () => { + vi.spyOn(Math, 'random').mockReturnValue(0.999999); + const { client, fake } = historyClient([rawResponse('{"error":"internal error"}\n', 500)], 2); + const result = settle(client.history.search('request_id', REQUEST_ID)); + await vi.runAllTimersAsync(); + expect((await result).error).toBeInstanceOf(ServerError); + const [first, second, third] = times(fake.calls); + expect(first).toBe(0); + expect(second).toBeGreaterThanOrEqual(499); + expect(second).toBeLessThanOrEqual(500); + expect((third ?? 0) - (second ?? 0)).toBeGreaterThanOrEqual(999); + expect((third ?? 0) - (second ?? 0)).toBeLessThanOrEqual(1000); + }); + + it('caps the backoff at 8 seconds', async () => { + vi.spyOn(Math, 'random').mockReturnValue(0.999999); + const { client, fake } = historyClient([new TypeError('fetch failed')], 6); + const result = settle(client.history.search('request_id', REQUEST_ID)); + await vi.runAllTimersAsync(); + expect((await result).error).toBeInstanceOf(ConnectionError); + const gaps = times(fake.calls) + .slice(1) + .map((t, i, all) => t - (i === 0 ? 0 : (all[i - 1] ?? 0))); + expect(fake.calls).toHaveLength(7); + expect(Math.max(...gaps)).toBeLessThanOrEqual(8000); + expect(gaps[5]).toBeGreaterThanOrEqual(7999); + }); + + it('honours Retry-After on a retryable response, capped at 10 seconds', async () => { + const { client, fake } = historyClient([ + rawResponse('{"error":"server is busy"}', 503, 'application/json', { 'retry-after': '2' }), + rawResponse('{"error":"too many requests"}\n', 429, 'application/json', { + 'retry-after': '60', + }), + historyPage([]), + ]); + const result = settle(client.history.search('request_id', REQUEST_ID)); + await vi.runAllTimersAsync(); + expect((await result).value).toEqual({ data: [], total: 0 }); + expect(times(fake.calls)).toEqual([0, 2000, 12_000]); + }); + + it('waits at least one second before retrying a History 429 without Retry-After', async () => { + vi.spyOn(Math, 'random').mockReturnValue(0); + const { client, fake } = historyClient([ + rawResponse('{"error":"too many requests"}\n', 429), + historyPage([]), + ]); + const result = settle(client.history.search('request_id', REQUEST_ID)); + await vi.runAllTimersAsync(); + await result; + expect(times(fake.calls)).toEqual([0, 1000]); + }); + + it('retries a History 429 after its Retry-After as sent, even when that is 0', async () => { + // Only the wait of identifications.get raises the delay after a 429 to at least 1 s. + const { client, fake } = historyClient([ + rawResponse('{"error":"too many requests"}\n', 429, 'application/json', { + 'retry-after': '0', + }), + rawResponse('{"error":"too many requests"}\n', 429, 'application/json', { + 'retry-after': '3', + }), + historyPage([]), + ]); + const result = settle(client.history.search('request_id', REQUEST_ID)); + await vi.runAllTimersAsync(); + expect((await result).value).toEqual({ data: [], total: 0 }); + expect(times(fake.calls)).toEqual([0, 0, 3000]); + }); + + it('does not retry with maxRetries 0', async () => { + const { client, fake } = historyClient([new TypeError('fetch failed')], 0); + const { error } = await settle(client.history.search('request_id', REQUEST_ID)); + expect(error).toBeInstanceOf(ConnectionError); + expect((error as ConnectionError).cause).toBeInstanceOf(TypeError); + expect((error as Error).message).toContain('fetch failed'); + expect(fake.calls).toHaveLength(1); + }); + + it('throws the last error after the retries are used up', async () => { + const { client, fake } = historyClient([ + rawResponse('{"error":"a"}', 500), + rawResponse('{"error":"b"}', 502), + rawResponse('{"error":"c"}', 503), + ]); + const result = settle(client.history.search('request_id', REQUEST_ID)); + await vi.runAllTimersAsync(); + const { error } = await result; + expect((error as ServerError).status).toBe(503); + expect(fake.calls).toHaveLength(3); + }); + + it.each([400, 401, 402, 403, 404])('never retries %s', async (status) => { + const { client, fake } = historyClient([rawResponse('', status, null)], 5); + const result = settle(client.history.search('request_id', REQUEST_ID)); + await vi.runAllTimersAsync(); + expect((await result).error).toBeInstanceOf(ApiError); + expect(fake.calls).toHaveLength(1); + }); + + it('never retries a Management 429, even with Retry-After', async () => { + const { client, fake } = managementClient( + [ + rawResponse('{"error":"too many requests"}', 429, 'application/json; charset=utf-8', { + 'retry-after': '1', + }), + ], + 5, + ); + const result = settle(client.getProfile()); + await vi.runAllTimersAsync(); + const { error } = await result; + expect(error).toBeInstanceOf(RateLimitError); + expect((error as RateLimitError).retryAfter).toBe(1); + expect(fake.calls).toHaveLength(1); + }); + + it('retries Management server errors and connection failures', async () => { + const { client, fake } = managementClient([ + rawResponse('{"error":"server is busy"}', 503, 'application/json; charset=utf-8'), + new TypeError('socket hang up'), + jsonResponse(loadJson('management-profile.json')), + ]); + const result = settle(client.getProfile()); + await vi.runAllTimersAsync(); + expect((await result).value?.domain).toBe('example.com'); + expect(fake.calls).toHaveLength(3); + }); + + it('times out an attempt and retries it', async () => { + const hanging = hangingFetch(); + const client = new ShieldLabs({ + apiKey: TEST_API_KEY, + fetch: hanging.fetch, + timeout: 1500, + maxRetries: 1, + }); + const result = settle(client.history.search('request_id', REQUEST_ID)); + await vi.runAllTimersAsync(); + const { error } = await result; + expect(error).toBeInstanceOf(TimeoutError); + expect((error as Error).message).toContain('1500 ms'); + expect(hanging.calls).toHaveLength(2); + }); + + it('times out a response body that never finishes', async () => { + const stalled = { + status: 200, + ok: true, + headers: new Headers(), + text: () => new Promise(() => undefined), + }; + const client = new ShieldLabs({ + apiKey: TEST_API_KEY, + fetch: () => Promise.resolve(stalled), + timeout: 1000, + maxRetries: 0, + }); + const result = settle(client.history.search('request_id', REQUEST_ID)); + await vi.runAllTimersAsync(); + expect((await result).error).toBeInstanceOf(TimeoutError); + }); + + it('does not retry after the caller aborts', async () => { + const hanging = hangingFetch(); + const controller = new AbortController(); + const client = new ShieldLabs({ apiKey: TEST_API_KEY, fetch: hanging.fetch, maxRetries: 3 }); + const result = settle( + client.history.search('request_id', REQUEST_ID, { signal: controller.signal }), + ); + await vi.advanceTimersByTimeAsync(5); + controller.abort(new Error('caller gave up')); + await vi.runAllTimersAsync(); + expect(((await result).error as Error).message).toBe('caller gave up'); + expect(hanging.calls).toHaveLength(1); + }); + + it('aborts while waiting between retries', async () => { + const controller = new AbortController(); + const { client, fake } = historyClient([new TypeError('fetch failed')], 3); + const result = settle( + client.history.search('request_id', REQUEST_ID, { signal: controller.signal }), + ); + await vi.advanceTimersByTimeAsync(100); + controller.abort(new Error('stop')); + await vi.runAllTimersAsync(); + expect(((await result).error as Error).message).toBe('stop'); + expect(fake.calls).toHaveLength(1); + }); +}); + +describe('connection errors', () => { + function networkError(cause: { code?: string; message: string }): TypeError { + return new TypeError('fetch failed', { cause: Object.assign(new Error(cause.message), cause) }); + } + + it.each([ + [ + { code: 'ECONNREFUSED', message: 'connect ECONNREFUSED 192.0.2.10:443' }, + 'History API request failed: fetch failed (connect ECONNREFUSED 192.0.2.10:443)', + ], + [ + { code: 'UND_ERR_CONNECT_TIMEOUT', message: 'Connect Timeout Error' }, + 'History API request failed: fetch failed (UND_ERR_CONNECT_TIMEOUT: Connect Timeout Error)', + ], + [{ code: 'ENOTFOUND', message: '' }, 'History API request failed: fetch failed (ENOTFOUND)'], + [ + { message: 'unable to verify the first certificate' }, + 'History API request failed: fetch failed (unable to verify the first certificate)', + ], + ])('name the network cause %j', async (cause, message) => { + const { client } = historyClient([networkError(cause)], 0); + const { error } = await settle(client.history.search('request_id', REQUEST_ID)); + expect(error).toBeInstanceOf(ConnectionError); + expect((error as Error).message).toBe(message); + expect((error as Error).cause).toBeInstanceOf(TypeError); + }); + + it('withhold an underlying error that contains the Private API Key', async () => { + const leaky = new TypeError( + `Headers.append: "Bearer ${TEST_API_KEY}" is an invalid header value.`, + ); + const { client } = historyClient([leaky], 0); + const { error } = await settle(client.history.search('request_id', REQUEST_ID)); + expect(error).toBeInstanceOf(ConnectionError); + expect((error as Error).message).toMatch(/withheld because it contained a credential/); + expect((error as Error).cause).toBeUndefined(); + const { inspect } = await import('node:util'); + expect(inspect(error)).not.toContain(TEST_API_KEY); + }); + + it('withhold a nested cause or property that contains the Secret Key', async () => { + const secret = '0123456789abcdef0123456789abcdef'; + const nested = new TypeError('fetch failed', { + cause: new Error(`invalid header value "Bearer ${secret}"`), + }); + const withProperty = Object.assign(new Error('request failed'), { + config: { headers: { Authorization: `Bearer ${secret}` } }, + }); + for (const reply of [nested, withProperty]) { + const { client } = managementClient([reply], 0); + const { error } = await settle(client.getProfile()); + expect(error).toBeInstanceOf(ConnectionError); + expect((error as Error).message).not.toContain(secret); + expect((error as Error).cause).toBeUndefined(); + } + }); + + it('check causes that are plain strings', async () => { + const leaky = new TypeError('fetch failed', { cause: `Bearer ${TEST_API_KEY}` }); + const { client } = historyClient([leaky], 0); + const { error } = await settle(client.history.search('request_id', REQUEST_ID)); + expect((error as Error).message).not.toContain(TEST_API_KEY); + expect((error as Error).cause).toBeUndefined(); + + const harmless = new TypeError('fetch failed', { cause: 'socket closed' }); + const second = historyClient([harmless], 0); + const result = await settle(second.client.history.search('request_id', REQUEST_ID)); + expect((result.error as Error).message).toBe('History API request failed: fetch failed'); + expect((result.error as Error).cause).toBe(harmless); + }); + + it('keep errors that only mention the domain', async () => { + const { client } = managementClient([new TypeError('getaddrinfo ENOTFOUND example.com')], 0); + const { error } = await settle(client.getProfile()); + expect((error as Error).message).toBe( + 'Management API request failed: getaddrinfo ENOTFOUND example.com', + ); + expect((error as Error).cause).toBeInstanceOf(TypeError); + }); + + it('cope with a circular cause chain', async () => { + const looping = new Error('socket hang up') as Error & { self?: unknown }; + looping.self = looping; + const { client } = historyClient([looping], 0); + const { error } = await settle(client.history.search('request_id', REQUEST_ID)); + expect((error as Error).message).toBe('History API request failed: socket hang up'); + }); +}); + +describe('fetch implementations', () => { + it('wraps a fetch that throws synchronously in a ConnectionError', async () => { + const client = new ShieldLabs({ + apiKey: TEST_API_KEY, + maxRetries: 0, + fetch: () => { + throw new Error('bad init'); + }, + }); + const { error } = await settle(client.history.search('request_id', REQUEST_ID)); + expect(error).toBeInstanceOf(ConnectionError); + }); + + it('wraps a rejection that is not an Error', async () => { + const client = new ShieldLabs({ + apiKey: TEST_API_KEY, + maxRetries: 0, + // eslint-disable-next-line @typescript-eslint/prefer-promise-reject-errors -- deliberately not an Error + fetch: () => Promise.reject('offline'), + }); + const { error } = await settle(client.history.search('request_id', REQUEST_ID)); + expect(error).toBeInstanceOf(ConnectionError); + expect((error as Error).message).toContain('offline'); + }); + + it('uses the global fetch when none is given, looked up at request time', async () => { + const client = new ShieldLabs({ apiKey: TEST_API_KEY, maxRetries: 0 }); + const fake = fakeFetch([historyPage([])]); + vi.stubGlobal('fetch', fake.fetch); + await expect(client.history.search('request_id', REQUEST_ID)).resolves.toEqual({ + data: [], + total: 0, + }); + expect(fake.calls).toHaveLength(1); + }); + + it('reports a missing global fetch as a ValidationError', async () => { + vi.stubGlobal('fetch', undefined); + const client = new ShieldLabs({ apiKey: TEST_API_KEY, maxRetries: 0 }); + await expect(client.history.search('request_id', REQUEST_ID)).rejects.toThrow(ValidationError); + }); + + it('passes an AuthenticationError from custom fetch responses through unchanged', async () => { + const { client } = historyClient( + [rawResponse('{"error":"invalid api key"}\n', 401, 'text/plain; charset=utf-8')], + 0, + ); + const { error } = await settle(client.history.search('request_id', REQUEST_ID)); + expect(error).toBeInstanceOf(AuthenticationError); + expect((error as AuthenticationError).headers.get('content-type')).toBe( + 'text/plain; charset=utf-8', + ); + }); +}); diff --git a/test/helpers.ts b/test/helpers.ts new file mode 100644 index 0000000..b396a61 --- /dev/null +++ b/test/helpers.ts @@ -0,0 +1,132 @@ +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { vi } from 'vitest'; +import type { FetchLike, FetchRequestInit, Identification } from '../src/types.js'; + +export const TEST_API_KEY = 'sec_abcd1234-efgh5678-ijkl9012'; +export const REQUEST_ID = '02f1d973-84db-4156-a7f7-e799e6bf389b'; + +export function dataPath(name: string): string { + return fileURLToPath(new URL(`./data/${name}`, import.meta.url)); +} + +export function loadJson(name: string): T { + return JSON.parse(readFileSync(dataPath(name), 'utf8')) as T; +} + +export function loadBytes(name: string): Uint8Array { + return new Uint8Array(readFileSync(dataPath(name))); +} + +export function loadText(name: string): string { + return readFileSync(dataPath(name), 'utf8'); +} + +export function withoutRaw(identification: Identification): Omit { + const { raw: _raw, ...rest } = identification; + return rest; +} + +const encoder = new TextEncoder(); + +/** Builds a fresh Response for every call, so one reply can answer many requests. */ +export type ResponseFactory = () => Response; + +/** A JSON response. */ +export function jsonResponse( + body: unknown, + status = 200, + headers: Record = {}, +): ResponseFactory { + const text = JSON.stringify(body); + return () => + new Response(encoder.encode(text), { + status, + headers: { 'content-type': 'application/json', ...headers }, + }); +} + +/** A response with an exact body and content type (null means no Content-Type header). */ +export function rawResponse( + body: string, + status: number, + contentType: string | null = 'application/json', + headers: Record = {}, +): ResponseFactory { + return () => { + const responseHeaders = new Headers(headers); + if (contentType !== null) responseHeaders.set('content-type', contentType); + return new Response(body === '' ? null : encoder.encode(body), { + status, + headers: responseHeaders, + }); + }; +} + +export interface FakeCall { + url: string; + init: FetchRequestInit; + time: number; +} + +type Reply = Error | ResponseFactory; + +/** + * A fake fetch. `replies` is either a function computing the reply for each call, or a list of + * replies used in order (the last one repeats). + */ +export function fakeFetch(replies: Reply[] | ((call: FakeCall, index: number) => Reply)) { + const calls: FakeCall[] = []; + const fetch = vi.fn((url: string, init: FetchRequestInit) => { + const call: FakeCall = { url, init, time: Date.now() }; + calls.push(call); + const index = calls.length - 1; + const reply = + typeof replies === 'function' + ? replies(call, index) + : (replies[Math.min(index, replies.length - 1)] as Reply); + if (reply instanceof Error) return Promise.reject(reply); + return Promise.resolve(reply()); + }); + return { fetch: fetch as unknown as FetchLike, calls, mock: fetch }; +} + +/** A fetch that never answers but rejects when its signal aborts. */ +export function hangingFetch() { + const calls: FakeCall[] = []; + const fetch = vi.fn( + (url: string, init: FetchRequestInit) => + new Promise((_resolve, reject) => { + calls.push({ url, init, time: Date.now() }); + init.signal.addEventListener('abort', () => reject(init.signal.reason as Error)); + }), + ); + return { fetch: fetch as unknown as FetchLike, calls }; +} + +/** Settles a promise into a value or an error, so fake timers can run before assertions. */ +export function settle(promise: Promise): Promise<{ value?: T; error?: unknown }> { + return promise.then( + (value) => ({ value }), + (error: unknown) => ({ error }), + ); +} + +/** One History page containing `rows`. */ +export function historyPage(rows: unknown[], total = rows.length): ResponseFactory { + return jsonResponse({ data: rows, total }); +} + +/** A History row copied from the fixture page, with a new request ID and optional changes. */ +export function historyRow( + requestId: string, + overrides: Record = {}, +): Record { + const page = loadJson<{ data: Record[] }>('history-page.json'); + return { ...(page.data[1] as Record), request_id: requestId, ...overrides }; +} + +/** Formats a Date the way History rows carry created_at. */ +export function historyTimestamp(date: Date): string { + return date.toISOString().replace('T', ' ').replace('Z', ''); +} diff --git a/test/history.test.ts b/test/history.test.ts new file mode 100644 index 0000000..0a5f6c2 --- /dev/null +++ b/test/history.test.ts @@ -0,0 +1,346 @@ +import { describe, expect, it } from 'vitest'; +import { ApiError, ShieldLabs, VERSION } from '../src/index.js'; +import type { Identification } from '../src/types.js'; +import { + REQUEST_ID, + TEST_API_KEY, + fakeFetch, + historyPage, + historyRow, + jsonResponse, + loadJson, + rawResponse, + settle, + withoutRaw, +} from './helpers.js'; + +const normalization = loadJson<{ + cases: { + name: string; + source: string; + input: Record; + expected: Omit; + }[]; +}>('normalization-cases.json'); +const historyExpected = normalization.cases.filter((c) => c.source === 'history'); + +function client(replies: Parameters[0], baseUrl?: string) { + const fake = fakeFetch(replies); + const shieldlabs = new ShieldLabs({ + apiKey: TEST_API_KEY, + baseUrl, + fetch: fake.fetch, + maxRetries: 0, + }); + return { shieldlabs, fake }; +} + +describe('history.search requests', () => { + it('sends a GET to the History API with credentials and SDK headers', async () => { + const { shieldlabs, fake } = client([jsonResponse(loadJson('history-empty.json'))]); + await shieldlabs.history.search('request_id', REQUEST_ID); + expect(fake.calls).toHaveLength(1); + const [call] = fake.calls; + expect(call?.url).toBe( + `https://account.shieldlabs.ai/api/v1/history/request_id/${REQUEST_ID}?limit=20&offset=0`, + ); + expect(call?.init.method).toBe('GET'); + expect(call?.init.headers).toMatchObject({ + Authorization: `Bearer ${TEST_API_KEY}`, + Accept: 'application/json', + }); + expect(call?.init.headers['User-Agent']).toMatch( + new RegExp(`^shieldlabs-node/${VERSION.replaceAll('.', '\\.')} node/\\d+`), + ); + expect(call?.init.signal).toBeInstanceOf(AbortSignal); + }); + + it('sends UUIDs in lowercase and passes limit and offset', async () => { + const { shieldlabs, fake } = client([historyPage([])]); + await shieldlabs.history.search('device_id', 'AC7C303D-971B-41D1-8E25-CD5B46B46AED', { + limit: 5, + offset: 40, + }); + expect(fake.calls[0]?.url).toBe( + 'https://account.shieldlabs.ai/api/v1/history/device_id/ac7c303d-971b-41d1-8e25-cd5b46b46aed?limit=5&offset=40', + ); + }); + + it.each([ + ['User@Example.com', 'User@Example.com'], + ['a@b', 'a@b'], + ['a+b', 'a+b'], + ['a:b=c', 'a:b=c'], + ['a,b', 'a,b'], + ['a;b', 'a;b'], + ['x$y&z', 'x$y&z'], + ['a!b c', 'a%21b%20c'], + ["it's (a)*", 'it%27s%20%28a%29%2A'], + ['a b c?#%', 'a%20b%20c%3F%23%25'], + ['Größe', 'Gr%C3%B6%C3%9Fe'], + ['~u-1_v.2', '~u-1_v.2'], + ['%2e', '%252e'], + ['...', '...'], + ['dGVzdA+=', 'dGVzdA+='], + ])('sends the User HID %j in canonical path form', async (value, segment) => { + const { shieldlabs, fake } = client([historyPage([])]); + await shieldlabs.history.search('user_hid', value); + const url = fake.calls[0]?.url ?? ''; + expect(url).toBe( + `https://account.shieldlabs.ai/api/v1/history/user_hid/${segment}?limit=20&offset=0`, + ); + expect(new URL(url).pathname).toBe(`/api/v1/history/user_hid/${segment}`); + expect(decodeURIComponent(segment)).toBe(value); + }); + + it('keeps the canonical path form on the wire with the runtime fetch', async () => { + const { createServer } = await import('node:http'); + const targets: string[] = []; + const server = createServer((req, res) => { + targets.push(req.url ?? ''); + res.writeHead(200, { 'content-type': 'application/json' }); + res.end('{"data":[],"total":0}'); + }); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + try { + const { port } = server.address() as { port: number }; + const shieldlabs = new ShieldLabs({ + apiKey: TEST_API_KEY, + baseUrl: `http://127.0.0.1:${port}`, + maxRetries: 0, + }); + const values = ['a@b+c=', 'x$y&z,a;b:c', "a!b c'(d)*", 'Größe?#%', '~u-1_v.2']; + for (const value of values) await shieldlabs.history.search('user_hid', value); + expect(targets).toEqual([ + '/api/v1/history/user_hid/a@b+c=?limit=20&offset=0', + '/api/v1/history/user_hid/x$y&z,a;b:c?limit=20&offset=0', + '/api/v1/history/user_hid/a%21b%20c%27%28d%29%2A?limit=20&offset=0', + '/api/v1/history/user_hid/Gr%C3%B6%C3%9Fe%3F%23%25?limit=20&offset=0', + '/api/v1/history/user_hid/~u-1_v.2?limit=20&offset=0', + ]); + } finally { + await new Promise((resolve) => server.close(() => resolve())); + } + }); + + it('uses the same path form in history.iterate', async () => { + const { shieldlabs, fake } = client([historyPage([])]); + for await (const identification of shieldlabs.history.iterate('user_hid', 'a@b c')) { + expect(identification).toBeUndefined(); + } + expect(new URL(fake.calls[0]?.url ?? '').pathname).toBe('/api/v1/history/user_hid/a@b%20c'); + }); + + it('sends IPv4 lookups unchanged', async () => { + const { shieldlabs, fake } = client([historyPage([])]); + await shieldlabs.history.search('ip', '203.0.113.24'); + expect(new URL(fake.calls[0]?.url ?? '').pathname).toBe('/api/v1/history/ip/203.0.113.24'); + }); + + it.each([ + ['https://account.shieldlabs.ai/api', 'https://account.shieldlabs.ai/api/v1/history/'], + ['https://account.shieldlabs.ai/api/', 'https://account.shieldlabs.ai/api/v1/history/'], + ['https://account.shieldlabs.ai/API', 'https://account.shieldlabs.ai/api/v1/history/'], + ['https://dev.account.shieldlabs.ai/', 'https://dev.account.shieldlabs.ai/api/v1/history/'], + ['https://dev.account.shieldlabs.ai//', 'https://dev.account.shieldlabs.ai/api/v1/history/'], + [' https://account.shieldlabs.ai ', 'https://account.shieldlabs.ai/api/v1/history/'], + [ + 'https://gateway.example.com/shieldlabs', + 'https://gateway.example.com/shieldlabs/api/v1/history/', + ], + [ + 'https://gateway.example.com/shieldlabs/api/', + 'https://gateway.example.com/shieldlabs/api/v1/history/', + ], + ['http://127.0.0.1:8080', 'http://127.0.0.1:8080/api/v1/history/'], + ])('base URL %s never produces /api/api', async (baseUrl, prefix) => { + const { shieldlabs, fake } = client([historyPage([])], baseUrl); + await shieldlabs.history.search('request_id', REQUEST_ID); + expect(fake.calls[0]?.url.startsWith(prefix)).toBe(true); + expect(fake.calls[0]?.url).not.toContain('/api/api/'); + }); +}); + +describe('history.search responses', () => { + it('normalizes history-page.json exactly like normalization-cases.json', async () => { + const pageBody = loadJson<{ data: Record[]; total: number }>( + 'history-page.json', + ); + const { shieldlabs } = client([jsonResponse(pageBody)]); + const page = await shieldlabs.history.search('user_hid', 'anonymous'); + expect(page.total).toBe(37); + expect(page.data).toHaveLength(5); + page.data.forEach((identification, index) => { + expect(withoutRaw(identification)).toStrictEqual(historyExpected[index]?.expected); + expect(identification.source).toBe('history'); + expect(identification.raw).toStrictEqual(pageBody.data[index]); + expect(identification.raw.ver).toBe(pageBody.data[index]?.ver); + }); + }); + + it('returns an empty page for history-empty.json', async () => { + const { shieldlabs } = client([jsonResponse(loadJson('history-empty.json'))]); + await expect(shieldlabs.history.search('request_id', REQUEST_ID)).resolves.toEqual({ + data: [], + total: 0, + }); + }); + + it('parses the exact History body format (text with a trailing newline)', async () => { + const body = `${JSON.stringify(loadJson('history-empty.json'))}\n`; + const { shieldlabs } = client([rawResponse(body, 200, 'application/json')]); + await expect(shieldlabs.history.search('request_id', REQUEST_ID)).resolves.toEqual({ + data: [], + total: 0, + }); + }); + + it('skips rows that are not objects and falls back to the row count for a missing total', async () => { + const { shieldlabs } = client([jsonResponse({ data: [historyRow(REQUEST_ID), 5, null, 'x'] })]); + const page = await shieldlabs.history.search('request_id', REQUEST_ID); + expect(page.data).toHaveLength(1); + expect(page.total).toBe(1); + }); + + it.each([ + ['an array', jsonResponse([])], + ['a null data field', jsonResponse({ data: null, total: 0 })], + ['a missing data field', jsonResponse({ total: 3 })], + ])('rejects %s with ApiError', async (_label, response) => { + const { shieldlabs } = client([response]); + const { error } = await settle(shieldlabs.history.search('request_id', REQUEST_ID)); + expect(error).toBeInstanceOf(ApiError); + expect((error as ApiError).status).toBe(200); + }); + + it('rejects a 200 response that is not JSON', async () => { + const { shieldlabs } = client([rawResponse('maintenance', 200, 'text/html')]); + const { error } = await settle(shieldlabs.history.search('request_id', REQUEST_ID)); + expect(error).toBeInstanceOf(ApiError); + expect((error as ApiError).body).toBe('maintenance'); + expect((error as ApiError).message).toMatch(/not valid JSON/); + }); + + it('can be used concurrently from one client', async () => { + const { shieldlabs, fake } = client((call) => { + const id = new URL(call.url).pathname.split('/').pop() ?? ''; + return historyPage([historyRow(id)]); + }); + const ids = [ + '11111111-1111-4111-8111-111111111111', + '22222222-2222-4222-8222-222222222222', + '33333333-3333-4333-8333-333333333333', + ]; + const pages = await Promise.all(ids.map((id) => shieldlabs.history.search('request_id', id))); + expect(pages.map((p) => p.data[0]?.request_id)).toEqual(ids); + expect(fake.calls).toHaveLength(3); + }); +}); + +describe('history.iterate', () => { + const ids = Array.from({ length: 7 }, (_, i) => `00000000-0000-4000-8000-00000000000${i}`); + + function pagedServer(rowsByOffset: Record, total: number) { + return (call: { url: string }) => { + const url = new URL(call.url); + const offset = Number(url.searchParams.get('offset')); + const rows = (rowsByOffset[offset] ?? []).map((id) => historyRow(id)); + return historyPage(rows, total); + }; + } + + async function collect(iterable: AsyncIterable): Promise { + const out: string[] = []; + for await (const identification of iterable) out.push(identification.request_id); + return out; + } + + it('pages with offset and stops at total', async () => { + const { shieldlabs, fake } = client( + pagedServer( + { 0: [ids[0]!, ids[1]!, ids[2]!], 3: [ids[3]!, ids[4]!, ids[5]!], 6: [ids[6]!] }, + 7, + ), + ); + const result = await collect(shieldlabs.history.iterate('user_hid', 'u-1', { pageSize: 3 })); + expect(result).toEqual(ids); + expect(fake.calls.map((c) => new URL(c.url).search)).toEqual([ + '?limit=3&offset=0', + '?limit=3&offset=3', + '?limit=3&offset=6', + ]); + }); + + it('skips rows repeated across pages when new rows shift the offsets', async () => { + const { shieldlabs } = client( + pagedServer({ 0: [ids[0]!, ids[1]!], 2: [ids[1]!, ids[2]!], 4: [ids[3]!] }, 5), + ); + const result = await collect(shieldlabs.history.iterate('device_id', ids[6]!, { pageSize: 2 })); + expect(result).toEqual([ids[0], ids[1], ids[2], ids[3]]); + }); + + it('stops at an empty page even when total promises more', async () => { + const { shieldlabs, fake } = client(pagedServer({ 0: [ids[0]!, ids[1]!] }, 50)); + const result = await collect(shieldlabs.history.iterate('ip', '198.51.100.7', { pageSize: 2 })); + expect(result).toEqual([ids[0], ids[1]]); + expect(fake.calls).toHaveLength(2); + }); + + it('stops after maxItems without fetching more pages', async () => { + const { shieldlabs, fake } = client( + pagedServer({ 0: [ids[0]!, ids[1]!, ids[2]!], 3: [ids[3]!, ids[4]!, ids[5]!] }, 7), + ); + const result = await collect( + shieldlabs.history.iterate('visitor_id', ids[6]!, { pageSize: 3, maxItems: 4 }), + ); + expect(result).toEqual([ids[0], ids[1], ids[2], ids[3]]); + expect(fake.calls).toHaveLength(2); + }); + + it('stops exactly at a page boundary with maxItems', async () => { + const { shieldlabs, fake } = client(pagedServer({ 0: [ids[0]!, ids[1]!] }, 7)); + const result = await collect( + shieldlabs.history.iterate('session_id', ids[6]!, { pageSize: 2, maxItems: 2 }), + ); + expect(result).toEqual([ids[0], ids[1]]); + expect(fake.calls).toHaveLength(1); + }); + + it('sends nothing for maxItems 0', async () => { + const { shieldlabs, fake } = client(pagedServer({ 0: [ids[0]!] }, 1)); + expect( + await collect(shieldlabs.history.iterate('cookie_id', ids[6]!, { maxItems: 0 })), + ).toEqual([]); + expect(fake.calls).toHaveLength(0); + }); + + it('uses 100 rows per page by default', async () => { + const { shieldlabs, fake } = client(pagedServer({ 0: [ids[0]!] }, 1)); + await collect(shieldlabs.history.iterate('user_hid', 'u-1')); + expect(new URL(fake.calls[0]?.url ?? '').searchParams.get('limit')).toBe('100'); + }); + + it('yields rows without a request ID instead of dropping them', async () => { + const { shieldlabs } = client([historyPage([historyRow(''), historyRow('')], 2)]); + const result = await collect(shieldlabs.history.iterate('user_hid', 'u-1')); + expect(result).toEqual(['', '']); + }); + + it('stops when the signal aborts between pages', async () => { + const controller = new AbortController(); + const { shieldlabs, fake } = client(pagedServer({ 0: [ids[0]!], 1: [ids[1]!] }, 5)); + const seen: string[] = []; + const run = (async () => { + for await (const identification of shieldlabs.history.iterate('user_hid', 'u-1', { + pageSize: 1, + signal: controller.signal, + })) { + seen.push(identification.request_id); + controller.abort(new Error('stopped by caller')); + } + })(); + await expect(run).rejects.toThrow('stopped by caller'); + expect(seen).toEqual([ids[0]]); + expect(fake.calls).toHaveLength(1); + }); +}); diff --git a/test/identifications.test.ts b/test/identifications.test.ts new file mode 100644 index 0000000..e082ebc --- /dev/null +++ b/test/identifications.test.ts @@ -0,0 +1,739 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { + ApiError, + AuthenticationError, + BadRequestError, + ConnectionError, + NotFoundError, + RateLimitError, + ServerError, + ShieldLabs, + TimeoutError, +} from '../src/index.js'; +import { pollWait } from '../src/history.js'; +import type { FetchLike, FetchRequestInit } from '../src/types.js'; +import { + REQUEST_ID, + TEST_API_KEY, + fakeFetch, + hangingFetch, + historyPage, + historyRow, + jsonResponse, + rawResponse, + settle, + type ResponseFactory, +} from './helpers.js'; + +const START = Date.parse('2026-09-30T12:00:00.000Z'); +const empty = () => historyPage([]); +const found = () => historyPage([historyRow(REQUEST_ID)]); +const tooMany = (headers: Record = {}) => + rawResponse('{"error":"too many requests"}\n', 429, 'application/json', headers); +const serverError = (status = 500) => rawResponse(`{"error":"status ${status}"}\n`, status); + +function client( + replies: Parameters[0], + options: { timeout?: number; maxRetries?: number } = {}, +) { + const fake = fakeFetch(replies); + const shieldlabs = new ShieldLabs({ apiKey: TEST_API_KEY, fetch: fake.fetch, ...options }); + return { shieldlabs, fake }; +} + +function offsets(calls: { time: number }[]): number[] { + return calls.map((call) => call.time - START); +} + +interface Outcome { + value?: T; + error?: unknown; + /** Milliseconds after START at which the promise settled. */ + at: number; +} + +/** Settles a promise into its value or error and the fake-clock time at which it settled. */ +function outcome(promise: Promise): Promise> { + return promise.then( + (value) => ({ value, at: Date.now() - START }), + (error: unknown) => ({ error, at: Date.now() - START }), + ); +} + +interface Attempt { + start: number; + end?: number; +} + +/** + * A fetch that records when each attempt starts and ends. `reply` answers an attempt with a + * response, rejects it with an error, or leaves it open ('hang') until its signal aborts. + */ +function recordingFetch(reply: (index: number) => ResponseFactory | Error | 'hang') { + const attempts: Attempt[] = []; + const fetch = (_url: string, init: FetchRequestInit) => { + const attempt: Attempt = { start: Date.now() - START }; + attempts.push(attempt); + const answer = reply(attempts.length - 1); + if (answer === 'hang') { + return new Promise((_resolve, reject) => { + init.signal.addEventListener('abort', () => { + attempt.end = Date.now() - START; + reject(init.signal.reason as Error); + }); + }); + } + attempt.end = attempt.start; + return answer instanceof Error ? Promise.reject(answer) : Promise.resolve(answer()); + }; + const starts = () => attempts.map((attempt) => attempt.start); + const durations = () => attempts.map((attempt) => (attempt.end ?? Number.NaN) - attempt.start); + return { fetch: fetch as unknown as FetchLike, starts, durations }; +} + +/** A fetch whose every answer takes `latency` milliseconds. */ +function slowFetch(latency: number, reply: ResponseFactory) { + const calls: number[] = []; + const fetch = (_url: string, init: FetchRequestInit) => + new Promise((resolve, reject) => { + calls.push(Date.now() - START); + const timer = setTimeout(() => resolve(reply()), latency); + init.signal.addEventListener('abort', () => { + clearTimeout(timer); + reject(init.signal.reason as Error); + }); + }); + return { fetch: fetch as unknown as FetchLike, calls }; +} + +beforeEach(() => { + vi.useFakeTimers({ now: START }); +}); + +afterEach(() => { + vi.useRealTimers(); +}); + +describe('identifications.get without waiting', () => { + it('reads once with limit 1 and returns the identification', async () => { + const { shieldlabs, fake } = client([found()]); + const identification = await shieldlabs.identifications.get(REQUEST_ID, { wait: false }); + expect(identification?.request_id).toBe(REQUEST_ID); + expect(fake.calls).toHaveLength(1); + const url = new URL(fake.calls[0]?.url ?? ''); + expect(url.pathname).toBe(`/api/v1/history/request_id/${REQUEST_ID}`); + expect(url.search).toBe('?limit=1&offset=0'); + }); + + it('returns null when there is no row yet', async () => { + const { shieldlabs, fake } = client([empty()]); + await expect(shieldlabs.identifications.get(REQUEST_ID, { wait: false })).resolves.toBeNull(); + expect(fake.calls).toHaveLength(1); + }); + + it('uses the normal retry policy for a single read', async () => { + const { shieldlabs, fake } = client([serverError(), found()]); + const result = settle(shieldlabs.identifications.get(REQUEST_ID, { wait: false })); + await vi.runAllTimersAsync(); + expect((await result).value?.request_id).toBe(REQUEST_ID); + expect(fake.calls).toHaveLength(2); + }); + + it('sends the request ID in lowercase', async () => { + const { shieldlabs, fake } = client([found()]); + await shieldlabs.identifications.get(REQUEST_ID.toUpperCase(), { wait: false }); + expect(fake.calls[0]?.url).toContain(REQUEST_ID); + }); +}); + +describe('identifications.get: timeout is the total budget', () => { + it('returns at once when the first poll finds the row', async () => { + const { shieldlabs, fake } = client([found()]); + await expect(shieldlabs.identifications.get(REQUEST_ID)).resolves.toMatchObject({ + request_id: REQUEST_ID, + }); + expect(fake.calls).toHaveLength(1); + }); + + it('stops polling as soon as the row appears', async () => { + const { shieldlabs, fake } = client([empty(), empty(), found()]); + const result = outcome(shieldlabs.identifications.get(REQUEST_ID)); + await vi.runAllTimersAsync(); + const { value, at } = await result; + expect(value?.request_id).toBe(REQUEST_ID); + expect(at).toBe(750); + expect(offsets(fake.calls)).toEqual([0, 250, 750]); + }); + + it('resolves null at the default deadline of 10 seconds', async () => { + const { shieldlabs } = client([empty()]); + const result = outcome(shieldlabs.identifications.get(REQUEST_ID)); + await vi.runAllTimersAsync(); + expect(await result).toEqual({ value: null, at: 10_000 }); + }); + + it('counts the time spent in requests against the budget', async () => { + const slow = slowFetch(600, empty()); + const shieldlabs = new ShieldLabs({ apiKey: TEST_API_KEY, fetch: slow.fetch }); + const result = outcome(shieldlabs.identifications.get(REQUEST_ID, { timeout: 2000 })); + await vi.runAllTimersAsync(); + // Polls answer after 600 ms: 0-600, then 850-1450, then 1950-2550, which ends after the + // deadline and so is the last poll. + expect(slow.calls).toEqual([0, 850, 1950]); + expect(await result).toEqual({ value: null, at: 2550 }); + }); + + it('polls once for timeout 0', async () => { + const { shieldlabs, fake } = client([empty()]); + await expect(shieldlabs.identifications.get(REQUEST_ID, { timeout: 0 })).resolves.toBeNull(); + expect(fake.calls).toHaveLength(1); + }); +}); + +describe('identifications.get: poll schedule', () => { + it('polls immediately, then after 250 ms, 500 ms, 1 s, 1.5 s and every 2 s until the timeout', async () => { + const { shieldlabs, fake } = client([empty()]); + const result = settle(shieldlabs.identifications.get(REQUEST_ID)); + await vi.runAllTimersAsync(); + expect(await result).toEqual({ value: null }); + expect(offsets(fake.calls)).toEqual([0, 250, 750, 1750, 3250, 5250, 7250, 9250, 10000]); + }); + + it('waits pollInterval times 1, 2, 4 and 6, then 8 times it for every later wait', async () => { + const { shieldlabs, fake } = client([empty()]); + const result = outcome( + shieldlabs.identifications.get(REQUEST_ID, { pollInterval: 100, timeout: 3000 }), + ); + await vi.runAllTimersAsync(); + // Waits of 100, 200, 400, 600 and then 800 ms; the wait after the poll at 2900 is cut to the + // 100 ms left. + expect(await result).toEqual({ value: null, at: 3000 }); + expect(offsets(fake.calls)).toEqual([0, 100, 300, 700, 1300, 2100, 2900, 3000]); + }); + + it('caps each wait of a custom pollInterval at 2 s', async () => { + const { shieldlabs, fake } = client([empty()]); + const result = settle(shieldlabs.identifications.get(REQUEST_ID, { pollInterval: 300 })); + await vi.runAllTimersAsync(); + await result; + // Waits of 300, 600, 1200 and 1800 ms, then 2 s instead of 2.4 s. + expect(offsets(fake.calls)).toEqual([0, 300, 900, 2100, 3900, 5900, 7900, 9900, 10000]); + }); + + it.each([ + // [pollInterval, scheduled waits, poll times of the default 10 s wait] + [250, [250, 500, 1000, 1500, 2000, 2000], [0, 250, 750, 1750, 3250, 5250, 7250, 9250, 10000]], + [1000, [1000, 2000, 2000, 2000], [0, 1000, 3000, 5000, 7000, 9000, 10000]], + [3000, [3000, 3000, 3000, 3000], [0, 3000, 6000, 9000, 10000]], + ])( + 'waits p, 2p, 4p, 6p and 8p, each at most max(2 s, p), for pollInterval %i', + async (pollInterval, waits, polls) => { + const { shieldlabs, fake } = client([empty()]); + const result = outcome(shieldlabs.identifications.get(REQUEST_ID, { pollInterval })); + await vi.runAllTimersAsync(); + expect(await result).toEqual({ value: null, at: 10_000 }); + expect(offsets(fake.calls)).toEqual(polls); + expect(waits.map((_wait, index) => pollWait(index, pollInterval))).toEqual(waits); + }, + ); + + it('waits the whole pollInterval when it is longer than one timer allows', async () => { + const { shieldlabs, fake } = client([empty()]); + const result = outcome( + shieldlabs.identifications.get(REQUEST_ID, { + pollInterval: 3_000_000_000, + timeout: 7_000_000_000, + }), + ); + await vi.runAllTimersAsync(); + expect(await result).toEqual({ value: null, at: 7_000_000_000 }); + expect(offsets(fake.calls)).toEqual([0, 3_000_000_000, 6_000_000_000, 7_000_000_000]); + }); + + it.each([ + [250, [250, 500, 1000, 1500, 2000, 2000, 2000]], + [100, [100, 200, 400, 600, 800, 800, 800]], + [10, [10, 20, 40, 60, 80, 80, 80]], // no lower limit + [300, [300, 600, 1200, 1800, 2000, 2000, 2000]], + [1000, [1000, 2000, 2000, 2000, 2000, 2000, 2000]], + [2000, [2000, 2000, 2000, 2000, 2000, 2000, 2000]], + [3000, [3000, 3000, 3000, 3000, 3000, 3000, 3000]], + [60_000, [60_000, 60_000, 60_000, 60_000, 60_000, 60_000, 60_000]], + ])('computes the scheduled waits for pollInterval %i', (pollInterval, expected) => { + expect(expected.map((_wait, index) => pollWait(index, pollInterval))).toEqual(expected); + }); +}); + +describe('identifications.get: the last poll runs at the deadline', () => { + it.each([ + [0, [0]], + [100, [0, 100]], + [1000, [0, 250, 750, 1000]], + [3250, [0, 250, 750, 1750, 3250]], + [4000, [0, 250, 750, 1750, 3250, 4000]], + ])('with timeout %i', async (timeout, expected) => { + const { shieldlabs, fake } = client([empty()]); + const result = outcome(shieldlabs.identifications.get(REQUEST_ID, { timeout })); + await vi.runAllTimersAsync(); + expect(await result).toEqual({ value: null, at: timeout }); + expect(offsets(fake.calls)).toEqual(expected); + }); + + it('makes exactly one poll at the deadline, even when the clock then reads a little early', async () => { + const deadline = START + 1000; + const { shieldlabs, fake } = client((call) => { + // A timer that fires a few milliseconds early leaves the clock just before the deadline. + if (call.time >= deadline) vi.setSystemTime(deadline - 5); + return empty(); + }); + const result = settle(shieldlabs.identifications.get(REQUEST_ID, { timeout: 1000 })); + await vi.runAllTimersAsync(); + expect(await result).toEqual({ value: null }); + expect(offsets(fake.calls)).toEqual([0, 250, 750, 1000]); + }); + + it('shortens a wait stretched by a 429 so that the last poll still runs at the deadline', async () => { + const { shieldlabs, fake } = client([tooMany()]); + const result = outcome(shieldlabs.identifications.get(REQUEST_ID, { timeout: 2500 })); + await vi.runAllTimersAsync(); + const { error, at } = await result; + expect(error).toBeInstanceOf(RateLimitError); + expect(at).toBe(2500); + expect(offsets(fake.calls)).toEqual([0, 1000, 2000, 2500]); + }); + + it('polls at the deadline when Retry-After fits in the time left but the next step does not', async () => { + const { shieldlabs, fake } = client([ + empty(), + empty(), + empty(), + tooMany({ 'retry-after': '1' }), + found(), + ]); + const result = outcome(shieldlabs.identifications.get(REQUEST_ID, { timeout: 3000 })); + await vi.runAllTimersAsync(); + const { value, at } = await result; + // The 429 at 1750 asks for 1 s and 1.25 s are left: the 1.5 s step is cut to the deadline. + expect(value?.request_id).toBe(REQUEST_ID); + expect(at).toBe(3000); + expect(offsets(fake.calls)).toEqual([0, 250, 750, 1750, 3000]); + }); +}); + +describe('identifications.get: one HTTP attempt per poll', () => { + it.each([ + ['a 5xx', serverError(503), ServerError, 1000, [0, 250, 750, 1000]], + [ + 'a connection failure', + new TypeError('fetch failed'), + ConnectionError, + 1000, + [0, 250, 750, 1000], + ], + ['a 429', tooMany(), RateLimitError, 2000, [0, 1000, 2000]], + ])( + 'never retries %s inside a poll, whatever maxRetries is', + async (_label, reply, ErrorClass, timeout, expected) => { + const { shieldlabs, fake } = client([reply], { maxRetries: 5 }); + const result = settle(shieldlabs.identifications.get(REQUEST_ID, { timeout })); + await vi.runAllTimersAsync(); + expect((await result).error).toBeInstanceOf(ErrorClass); + expect(offsets(fake.calls)).toEqual(expected); + }, + ); + + it.each([ + // [client timeout, get timeout, attempt timeout of the first poll] + [10_000, 3_000, 3_000], // the time left + [500, 3_000, 500], // the client timeout + [10_000, 400, 1_000], // at least 1 s + [10_000, 0, 1_000], // at least 1 s, also for the only poll + [800, 400, 800], // the client timeout, even below 1 s + ])( + 'limits the first attempt to min(client timeout %i, max(time left of %i, 1 s))', + async (clientTimeout, timeout, expected) => { + const recorder = recordingFetch(() => 'hang'); + const shieldlabs = new ShieldLabs({ + apiKey: TEST_API_KEY, + fetch: recorder.fetch, + timeout: clientTimeout, + }); + const result = settle(shieldlabs.identifications.get(REQUEST_ID, { timeout })); + await vi.runAllTimersAsync(); + expect((await result).error).toBeInstanceOf(TimeoutError); + expect(recorder.durations()[0]).toBe(expected); + }, + ); + + it('computes the attempt timeout again for every poll', async () => { + const recorder = recordingFetch(() => 'hang'); + const shieldlabs = new ShieldLabs({ + apiKey: TEST_API_KEY, + fetch: recorder.fetch, + timeout: 2000, + }); + const result = outcome(shieldlabs.identifications.get(REQUEST_ID, { timeout: 4000 })); + await vi.runAllTimersAsync(); + const { error, at } = await result; + // First poll: the 2 s client timeout. Second poll at 2250: the 1.75 s left. + expect(recorder.starts()).toEqual([0, 2250]); + expect(recorder.durations()).toEqual([2000, 1750]); + expect(error).toBeInstanceOf(TimeoutError); + expect((error as Error).message).toContain('1750 ms'); + expect(at).toBe(4000); + }); + + it('gives a poll near the deadline at least one second', async () => { + const recorder = recordingFetch((index) => (index < 2 ? empty() : 'hang')); + const shieldlabs = new ShieldLabs({ apiKey: TEST_API_KEY, fetch: recorder.fetch }); + const result = outcome(shieldlabs.identifications.get(REQUEST_ID, { timeout: 1000 })); + await vi.runAllTimersAsync(); + const { error, at } = await result; + // The third poll starts at 750 with 250 ms left and still gets 1 s. + expect(recorder.starts()).toEqual([0, 250, 750]); + expect(recorder.durations()[2]).toBe(1000); + expect(error).toBeInstanceOf(TimeoutError); + expect((error as Error).message).toContain('1000 ms'); + expect(at).toBe(1750); + }); + + it('limits a hanging poll to the time left before the deadline', async () => { + const hanging = hangingFetch(); + const shieldlabs = new ShieldLabs({ apiKey: TEST_API_KEY, fetch: hanging.fetch }); + const result = outcome(shieldlabs.identifications.get(REQUEST_ID, { timeout: 3000 })); + await vi.runAllTimersAsync(); + const { error, at } = await result; + expect(error).toBeInstanceOf(TimeoutError); + expect((error as Error).message).toContain('3000 ms'); + expect(at).toBe(3000); + expect(hanging.calls).toHaveLength(1); + }); +}); + +describe('identifications.get: transient errors keep polling', () => { + it('keeps polling through 5xx, connection errors, 429 and attempt timeouts', async () => { + const replies = [ + serverError(500), + new TypeError('fetch failed'), + tooMany(), + serverError(502), + 'hang' as const, + ]; + const recorder = recordingFetch((index) => replies[index] ?? found()); + const shieldlabs = new ShieldLabs({ + apiKey: TEST_API_KEY, + fetch: recorder.fetch, + timeout: 1000, + }); + const result = outcome(shieldlabs.identifications.get(REQUEST_ID)); + await vi.runAllTimersAsync(); + const { value, at } = await result; + // Waits of 250 ms, 500 ms, 1 s (also the minimum after a 429) and 1.5 s; the poll at 3250 + // times out after its 1 s attempt and the next one follows the 2 s step. + expect(value?.request_id).toBe(REQUEST_ID); + expect(recorder.starts()).toEqual([0, 250, 750, 1750, 3250, 6250]); + expect(at).toBe(6250); + }); + + it.each([ + ['a 5xx', serverError(504), ServerError], + ['a connection failure', new TypeError('getaddrinfo ENOTFOUND'), ConnectionError], + ['a 429', tooMany(), RateLimitError], + ])( + 'throws the error of the last poll at the deadline when it was %s', + async (_label, last, ErrorClass) => { + const { shieldlabs, fake } = client([ + serverError(500), + new TypeError('fetch failed'), + empty(), + last, + ]); + const result = outcome(shieldlabs.identifications.get(REQUEST_ID, { timeout: 2000 })); + await vi.runAllTimersAsync(); + const { error, at } = await result; + expect(error).toBeInstanceOf(ErrorClass); + expect(at).toBe(2000); + expect(offsets(fake.calls)).toEqual([0, 250, 750, 1750, 2000]); + }, + ); + + it('throws the error of the last poll, not an earlier one', async () => { + const { shieldlabs, fake } = client([ + serverError(500), + serverError(503), + serverError(502), + serverError(504), + ]); + const result = outcome(shieldlabs.identifications.get(REQUEST_ID, { timeout: 1000 })); + await vi.runAllTimersAsync(); + const { error, at } = await result; + expect(error).toBeInstanceOf(ServerError); + expect((error as ServerError).status).toBe(504); + expect(at).toBe(1000); + expect(offsets(fake.calls)).toEqual([0, 250, 750, 1000]); + }); + + it('throws a TimeoutError when the last poll timed out', async () => { + const hanging = hangingFetch(); + const shieldlabs = new ShieldLabs({ + apiKey: TEST_API_KEY, + fetch: hanging.fetch, + timeout: 1000, + }); + const result = settle(shieldlabs.identifications.get(REQUEST_ID, { timeout: 0 })); + await vi.runAllTimersAsync(); + expect((await result).error).toBeInstanceOf(TimeoutError); + }); + + it('resolves null when the last poll found nothing, even after failed polls', async () => { + const { shieldlabs, fake } = client([ + serverError(500), + new TypeError('fetch failed'), + tooMany(), + empty(), + ]); + const result = outcome(shieldlabs.identifications.get(REQUEST_ID, { timeout: 3000 })); + await vi.runAllTimersAsync(); + expect(await result).toEqual({ value: null, at: 3000 }); + expect(offsets(fake.calls)).toEqual([0, 250, 750, 1750, 3000]); + }); +}); + +describe('identifications.get: 429 while waiting', () => { + it('waits at least 1 s after a 429 without Retry-After, then keeps polling', async () => { + const { shieldlabs, fake } = client([tooMany(), found()]); + const result = settle(shieldlabs.identifications.get(REQUEST_ID)); + await vi.runAllTimersAsync(); + expect((await result).value?.request_id).toBe(REQUEST_ID); + expect(offsets(fake.calls)).toEqual([0, 1000]); + }); + + it('keeps a scheduled wait longer than 1 s after a 429 without Retry-After', async () => { + const { shieldlabs, fake } = client([empty(), empty(), empty(), tooMany(), found()]); + const result = settle(shieldlabs.identifications.get(REQUEST_ID)); + await vi.runAllTimersAsync(); + expect((await result).value?.request_id).toBe(REQUEST_ID); + expect(offsets(fake.calls)).toEqual([0, 250, 750, 1750, 3250]); + }); + + it('throws the RateLimitError at the deadline when every poll was rate limited', async () => { + const { shieldlabs, fake } = client([tooMany()]); + const result = outcome(shieldlabs.identifications.get(REQUEST_ID, { timeout: 3000 })); + await vi.runAllTimersAsync(); + const { error, at } = await result; + expect(error).toBeInstanceOf(RateLimitError); + expect(at).toBe(3000); + expect(offsets(fake.calls)).toEqual([0, 1000, 2000, 3000]); + }); + + it('waits for Retry-After', async () => { + const { shieldlabs, fake } = client([empty(), tooMany({ 'retry-after': '3' }), found()]); + const result = settle(shieldlabs.identifications.get(REQUEST_ID)); + await vi.runAllTimersAsync(); + expect((await result).value?.request_id).toBe(REQUEST_ID); + expect(offsets(fake.calls)).toEqual([0, 250, 3250]); + }); + + it.each([ + ['Retry-After: 0', '0'], + ['a Retry-After date in the past', 'Wed, 30 Sep 2026 11:59:30 GMT'], + ])('counts %s as 0 and still waits at least 1 s', async (_label, retryAfter) => { + const { shieldlabs, fake } = client([empty(), tooMany({ 'retry-after': retryAfter }), found()]); + const result = settle(shieldlabs.identifications.get(REQUEST_ID)); + await vi.runAllTimersAsync(); + expect((await result).value?.request_id).toBe(REQUEST_ID); + // The 429 at 250 would be followed by the 500 ms step: the 1 s floor wins. + expect(offsets(fake.calls)).toEqual([0, 250, 1250]); + }); + + it('keeps a scheduled wait longer than Retry-After', async () => { + const replies = [empty(), empty(), empty(), empty(), tooMany({ 'retry-after': '1' }), found()]; + const { shieldlabs, fake } = client(replies); + const result = settle(shieldlabs.identifications.get(REQUEST_ID)); + await vi.runAllTimersAsync(); + expect((await result).value?.request_id).toBe(REQUEST_ID); + // The 429 at 3250 asks for 1 s; the scheduled 2 s step is longer and applies. + expect(offsets(fake.calls)).toEqual([0, 250, 750, 1750, 3250, 5250]); + }); + + it('waits at least 1 s after a 429 with a short pollInterval, then resumes the schedule', async () => { + const { shieldlabs, fake } = client([ + empty(), + tooMany({ 'retry-after': '0' }), + empty(), + found(), + ]); + const result = settle(shieldlabs.identifications.get(REQUEST_ID, { pollInterval: 100 })); + await vi.runAllTimersAsync(); + expect((await result).value?.request_id).toBe(REQUEST_ID); + // Waits of 100 ms, 1 s instead of 200 ms after the 429, then the 400 ms step. + expect(offsets(fake.calls)).toEqual([0, 100, 1100, 1500]); + }); + + it.each([ + // [label, Retry-After header, poll times]: the 3 s step is longer than 1 s and Retry-After 2. + ['without Retry-After', {}, [0, 3000, 6000]], + ['with a shorter Retry-After', { 'retry-after': '2' }, [0, 3000, 6000]], + ['with a longer Retry-After', { 'retry-after': '5' }, [0, 5000, 8000]], + ])('keeps the ladder of a 3 s pollInterval after a 429 %s', async (_label, headers, expected) => { + const { shieldlabs, fake } = client([tooMany(headers), empty(), found()]); + const result = settle(shieldlabs.identifications.get(REQUEST_ID, { pollInterval: 3000 })); + await vi.runAllTimersAsync(); + expect((await result).value?.request_id).toBe(REQUEST_ID); + expect(offsets(fake.calls)).toEqual(expected); + }); + + it.each([ + ['without Retry-After', {}], + ['with Retry-After: 0', { 'retry-after': '0' }], + ['with a Retry-After date in the past', { 'retry-after': 'Wed, 30 Sep 2026 11:59:30 GMT' }], + ])('polls at the deadline after a 429 %s when less than 1 s is left', async (_label, headers) => { + const { shieldlabs, fake } = client([empty(), empty(), tooMany(headers), found()]); + const result = outcome(shieldlabs.identifications.get(REQUEST_ID, { timeout: 1000 })); + await vi.runAllTimersAsync(); + const { value, at } = await result; + // The 429 at 750 leaves 250 ms: the 1 s wait is cut to the deadline instead of throwing. + expect(value?.request_id).toBe(REQUEST_ID); + expect(at).toBe(1000); + expect(offsets(fake.calls)).toEqual([0, 250, 750, 1000]); + }); + + it('throws at once when a short Retry-After is still longer than the time left', async () => { + const { shieldlabs, fake } = client([empty(), empty(), tooMany({ 'retry-after': '1' })]); + const result = outcome(shieldlabs.identifications.get(REQUEST_ID, { timeout: 1000 })); + await vi.runAllTimersAsync(); + const { error, at } = await result; + // The 429 at 750 asks for 1 s and only 250 ms are left. + expect(error).toBeInstanceOf(RateLimitError); + expect((error as RateLimitError).retryAfter).toBe(1); + expect(at).toBe(750); + expect(offsets(fake.calls)).toEqual([0, 250, 750]); + }); + + it('caps Retry-After at 10 seconds', async () => { + const { shieldlabs, fake } = client([tooMany({ 'retry-after': '120' }), found()]); + const result = settle(shieldlabs.identifications.get(REQUEST_ID, { timeout: 30_000 })); + await vi.runAllTimersAsync(); + expect((await result).value?.request_id).toBe(REQUEST_ID); + expect(offsets(fake.calls)).toEqual([0, 10_000]); + }); + + it('throws the RateLimitError at once when Retry-After is longer than the time left', async () => { + const { shieldlabs, fake } = client([empty(), tooMany({ 'retry-after': '5' })]); + const result = outcome(shieldlabs.identifications.get(REQUEST_ID, { timeout: 3000 })); + await vi.runAllTimersAsync(); + const { error, at } = await result; + expect(error).toBeInstanceOf(RateLimitError); + expect((error as RateLimitError).retryAfter).toBe(5); + expect(at).toBe(250); + expect(fake.calls).toHaveLength(2); + }); + + it('compares the capped Retry-After with the time left', async () => { + const { shieldlabs, fake } = client([tooMany({ 'retry-after': '120' })]); + const result = outcome(shieldlabs.identifications.get(REQUEST_ID, { timeout: 9_000 })); + await vi.runAllTimersAsync(); + const { error, at } = await result; + expect(error).toBeInstanceOf(RateLimitError); + expect(at).toBe(0); + expect(fake.calls).toHaveLength(1); + }); + + it('keeps polling when Retry-After equals the time left', async () => { + const { shieldlabs, fake } = client([tooMany({ 'retry-after': '2' }), found()]); + const result = outcome(shieldlabs.identifications.get(REQUEST_ID, { timeout: 2000 })); + await vi.runAllTimersAsync(); + const { value, at } = await result; + expect(value?.request_id).toBe(REQUEST_ID); + expect(at).toBe(2000); + expect(offsets(fake.calls)).toEqual([0, 2000]); + }); +}); + +describe('identifications.get: errors another poll cannot fix', () => { + it.each([ + [400, BadRequestError, 'null', 'application/json'], + [401, AuthenticationError, '{"error":"invalid api key"}\n', 'text/plain; charset=utf-8'], + [403, AuthenticationError, '', null], + [404, NotFoundError, '404 page not found', 'text/plain; charset=utf-8'], + ])('stops at once on %s', async (status, ErrorClass, body, contentType) => { + const { shieldlabs, fake } = client([empty(), rawResponse(body, status, contentType)]); + const result = outcome(shieldlabs.identifications.get(REQUEST_ID)); + await vi.runAllTimersAsync(); + const { error, at } = await result; + expect(error).toBeInstanceOf(ErrorClass); + expect((error as ApiError).status).toBe(status); + expect(at).toBe(250); + expect(fake.calls).toHaveLength(2); + }); + + it('stops at once on a response body it cannot use', async () => { + const { shieldlabs, fake } = client([jsonResponse({ unexpected: true })]); + const { error } = await settle(shieldlabs.identifications.get(REQUEST_ID)); + expect(error).toBeInstanceOf(ApiError); + expect(fake.calls).toHaveLength(1); + }); +}); + +describe('identifications.get cancellation', () => { + it('rejects with the abort reason while waiting and stops polling', async () => { + const controller = new AbortController(); + const { shieldlabs, fake } = client([empty()]); + const result = settle( + shieldlabs.identifications.get(REQUEST_ID, { signal: controller.signal }), + ); + await vi.advanceTimersByTimeAsync(300); + controller.abort(new Error('request cancelled')); + await vi.runAllTimersAsync(); + expect(((await result).error as Error).message).toBe('request cancelled'); + expect(fake.calls).toHaveLength(2); + }); + + it('rejects at once for an aborted signal', async () => { + const { shieldlabs, fake } = client([found()]); + const controller = new AbortController(); + controller.abort(); + const { error } = await settle( + shieldlabs.identifications.get(REQUEST_ID, { signal: controller.signal }), + ); + expect((error as Error).name).toBe('AbortError'); + expect(fake.calls).toHaveLength(0); + }); + + it('aborts an in-flight request', async () => { + const hanging = hangingFetch(); + const controller = new AbortController(); + const shieldlabs = new ShieldLabs({ apiKey: TEST_API_KEY, fetch: hanging.fetch }); + const result = settle( + shieldlabs.identifications.get(REQUEST_ID, { signal: controller.signal }), + ); + await vi.advanceTimersByTimeAsync(10); + controller.abort(new Error('client went away')); + expect(((await result).error as Error).message).toBe('client went away'); + expect(hanging.calls).toHaveLength(1); + }); + + it('rejects with the abort reason when the signal aborts while a poll fails', async () => { + const controller = new AbortController(); + const { shieldlabs, fake } = client(() => { + controller.abort(new Error('caller gave up')); + return serverError(503); + }); + const { error } = await settle( + shieldlabs.identifications.get(REQUEST_ID, { signal: controller.signal }), + ); + expect((error as Error).message).toBe('caller gave up'); + expect(fake.calls).toHaveLength(1); + }); +}); + +describe('identifications.get returns normalized data', () => { + it('returns the first row as an Identification', async () => { + const { shieldlabs } = client([ + jsonResponse({ data: [historyRow(REQUEST_ID, { score: 85 })], total: 1 }), + ]); + const identification = await shieldlabs.identifications.get(REQUEST_ID); + expect(identification?.risk_score).toBe(85); + expect(identification?.source).toBe('history'); + }); +}); diff --git a/test/management.test.ts b/test/management.test.ts new file mode 100644 index 0000000..6256a06 --- /dev/null +++ b/test/management.test.ts @@ -0,0 +1,140 @@ +import { describe, expect, it } from 'vitest'; +import { + ApiError, + AuthenticationError, + QuotaExceededError, + RateLimitError, + ShieldLabsManagement, + VERSION, +} from '../src/index.js'; +import { normalizeDomain } from '../src/management.js'; +import { fakeFetch, jsonResponse, loadJson, rawResponse, settle } from './helpers.js'; + +const SECRET = '0123456789abcdef0123456789abcdef'; + +function client( + replies: Parameters[0], + domain = 'example.com', + baseUrl?: string, +) { + const fake = fakeFetch(replies); + const management = new ShieldLabsManagement({ + secretKey: SECRET, + domain, + baseUrl, + fetch: fake.fetch, + maxRetries: 0, + }); + return { management, fake }; +} + +describe('getProfile', () => { + it('returns the normalized DomainProfile from management-profile.json', async () => { + const body = loadJson>('management-profile.json'); + const expected = loadJson>('management-profile-expected.json'); + const { management } = client([jsonResponse(body)]); + const { raw, ...profile } = await management.getProfile(); + expect(profile).toStrictEqual(expected); + expect(raw).toStrictEqual(body); + expect(raw.Callback).toBe(''); + }); + + it('sends GET /v1/profile with the domain header, the Secret Key and SDK headers', async () => { + const { management, fake } = client( + [jsonResponse(loadJson('management-profile.json'))], + 'https://www.Example.com/', + ); + await management.getProfile(); + const [call] = fake.calls; + expect(call?.url).toBe('https://api.shieldlabs.ai/v1/profile'); + expect(call?.init.method).toBe('GET'); + expect(call?.init.headers).toMatchObject({ + 'X-Shield-Domain': 'example.com', + Authorization: `Bearer ${SECRET}`, + Accept: 'application/json', + }); + expect(call?.init.headers['User-Agent']).toMatch( + new RegExp(`^shieldlabs-node/${VERSION.replaceAll('.', '\\.')} `), + ); + }); + + it('exposes the normalized domain', () => { + const { management } = client([], ' SHOP.Example.com '); + expect(management.domain).toBe('shop.example.com'); + }); + + it.each([ + ['https://dev.api.shieldlabs.ai/', 'https://dev.api.shieldlabs.ai/v1/profile'], + ['https://gateway.example.com/management', 'https://gateway.example.com/management/v1/profile'], + ['https://gateway.example.com/api', 'https://gateway.example.com/api/v1/profile'], + ])('uses base URL %s', async (baseUrl, expected) => { + const { management, fake } = client( + [jsonResponse(loadJson('management-profile.json'))], + 'example.com', + baseUrl, + ); + await management.getProfile(); + expect(fake.calls[0]?.url).toBe(expected); + }); + + it('maps 401 to AuthenticationError', async () => { + const { management } = client([rawResponse('', 401, null)]); + const { error } = await settle(management.getProfile()); + expect(error).toBeInstanceOf(AuthenticationError); + expect((error as AuthenticationError).body).toBeNull(); + }); + + it('maps 402 to QuotaExceededError', async () => { + const { management } = client([rawResponse('', 402, null)]); + await expect(management.getProfile()).rejects.toBeInstanceOf(QuotaExceededError); + }); + + it('raises a 429 without retrying', async () => { + const fake = fakeFetch([ + rawResponse('{"error":"too many requests"}', 429, 'application/json; charset=utf-8'), + ]); + const management = new ShieldLabsManagement({ + secretKey: SECRET, + domain: 'example.com', + fetch: fake.fetch, + }); + await expect(management.getProfile()).rejects.toBeInstanceOf(RateLimitError); + expect(fake.calls).toHaveLength(1); + }); + + it('rejects a body that is not an object', async () => { + const { management } = client([jsonResponse([1, 2])]); + const { error } = await settle(management.getProfile()); + expect(error).toBeInstanceOf(ApiError); + expect((error as ApiError).status).toBe(200); + }); + + it('supports cancellation', async () => { + const { management, fake } = client([jsonResponse(loadJson('management-profile.json'))]); + const controller = new AbortController(); + controller.abort(new Error('cancelled')); + await expect(management.getProfile({ signal: controller.signal })).rejects.toThrow('cancelled'); + expect(fake.calls).toHaveLength(0); + }); +}); + +describe('normalizeDomain', () => { + it.each([ + ['example.com', 'example.com'], + [' Example.COM ', 'example.com'], + ['https://example.com', 'example.com'], + ['http://example.com/', 'example.com'], + ['https://www.example.com/', 'example.com'], + ['WWW.Example.com', 'example.com'], + ['www.www.example.com', 'www.example.com'], + ['https://shop.example.com/path/page?q=1#top', 'shop.example.com'], + ['example.com/', 'example.com'], + ['example.com?x', 'example.com'], + ['example.com:8080', 'example.com:8080'], + ['wss://example.com', 'example.com'], + ['wwwexample.com', 'wwwexample.com'], + ['', ''], + ])('%s -> %s', (input, expected) => { + expect(normalizeDomain(input)).toBe(expected); + }); +}); diff --git a/test/normalize.test.ts b/test/normalize.test.ts new file mode 100644 index 0000000..1585800 --- /dev/null +++ b/test/normalize.test.ts @@ -0,0 +1,281 @@ +import { describe, expect, it } from 'vitest'; +import { + fallbackSlug, + fromHistoryRow, + fromWebhookData, + isTruthy, + parseHistoryTime, + parseRfc3339, + profileFromResponse, + signalSlug, + stripWhitespace, +} from '../src/normalize.js'; +import type { Identification } from '../src/types.js'; +import { loadJson, withoutRaw } from './helpers.js'; + +interface NormalizationCase { + name: string; + source: 'history' | 'webhook'; + input: Record; + expected: Omit; +} + +const normalization = loadJson<{ cases: NormalizationCase[] }>('normalization-cases.json'); +const slugs = loadJson<{ cases: { description: string; slug: string }[] }>( + 'signal-slug-cases.json', +); +const edge = loadJson<{ + slug_cases: { description: string; slug: string }[]; + history_time_cases: { input: string; expected: string | null }[]; + rfc3339_cases: { input: string; expected: string | null }[]; + history_rows: { + name: string; + input: Record; + expected: Omit; + }[]; + webhook_data: { + name: string; + input: Record; + expected: Omit; + }[]; +}>('normalizer-edge-cases.json'); + +const IDENTIFICATION_KEYS = [ + 'request_id', + 'visitor_id', + 'device_id', + 'session_id', + 'cookie_id', + 'user_hid', + 'domain', + 'public_ip', + 'local_ip', + 'connection_type', + 'os', + 'browser', + 'device_type', + 'traffic_source', + 'risk_score', + 'signals', + 'detection_flags', + 'observed_at', + 'source', + 'raw', +]; + +function normalize(source: 'history' | 'webhook', input: Record): Identification { + return source === 'history' ? fromHistoryRow(input) : fromWebhookData(input); +} + +describe('normalization-cases.json', () => { + it('covers both sources', () => { + const sources = new Set(normalization.cases.map((c) => c.source)); + expect(sources).toEqual(new Set(['history', 'webhook'])); + expect(normalization.cases).toHaveLength(8); + }); + + for (const testCase of normalization.cases) { + it(`${testCase.name} (${testCase.source})`, () => { + const result = normalize(testCase.source, testCase.input); + expect(withoutRaw(result)).toStrictEqual(testCase.expected); + expect(result.raw).toBe(testCase.input); + expect(Object.keys(result)).toEqual(IDENTIFICATION_KEYS); + expect(Object.keys(result.detection_flags)).toHaveLength(19); + }); + } +}); + +describe('signal-slug-cases.json', () => { + for (const { description, slug } of slugs.cases) { + it(`${JSON.stringify(description)} -> ${slug}`, () => { + expect(signalSlug(description)).toBe(slug); + }); + } +}); + +describe('normalizer-edge-cases.json', () => { + it.each(edge.slug_cases)('slug of $description', ({ description, slug }) => { + expect(signalSlug(description)).toBe(slug); + }); + + it.each(edge.history_time_cases)('history time $input', ({ input, expected }) => { + expect(parseHistoryTime(input)).toBe(expected); + }); + + it.each(edge.rfc3339_cases)('RFC 3339 time $input', ({ input, expected }) => { + expect(parseRfc3339(input)).toBe(expected); + }); + + it.each(edge.history_rows)('history row $name', ({ input, expected }) => { + const result = fromHistoryRow(input); + expect(withoutRaw(result)).toStrictEqual(expected); + expect(result.raw).toBe(input); + }); + + it.each(edge.webhook_data)('webhook data $name', ({ input, expected }) => { + const result = fromWebhookData(input); + expect(withoutRaw(result)).toStrictEqual(expected); + expect(result.raw).toBe(input); + }); +}); + +describe('timestamps', () => { + it.each([ + '2026-02-30 00:00:00', + '2026-02-29 00:00:00', + '2026-13-01 00:00:00', + '2026-00-10 00:00:00', + '2026-04-31 00:00:00', + '2026-09-00 00:00:00', + '2026-09-30 24:00:00', + '2026-09-30 12:60:00', + '2026-09-30 12:34:60', + '0000-01-01 00:00:00', + ])('rejects the impossible history date %s', (value) => { + expect(parseHistoryTime(value)).toBeNull(); + }); + + it('accepts leap days in leap years only', () => { + expect(parseHistoryTime('2000-02-29 00:00:00')).toBe('2000-02-29T00:00:00.000Z'); + expect(parseHistoryTime('1900-02-29 00:00:00')).toBeNull(); + expect(parseHistoryTime('2023-02-29 00:00:00')).toBeNull(); + }); + + it('returns null when an offset moves the date outside years 1 to 9999', () => { + expect(parseRfc3339('0001-01-01T00:00:00+01:00')).toBeNull(); + expect(parseRfc3339('9999-12-31T23:30:00-01:00')).toBeNull(); + }); + + it('keeps two-digit years as written', () => { + expect(parseRfc3339('0099-06-15T12:00:00Z')).toBe('0099-06-15T12:00:00.000Z'); + }); + + it('returns null for values that are not strings', () => { + expect(parseHistoryTime(1790771696123)).toBeNull(); + expect(parseHistoryTime(null)).toBeNull(); + expect(parseRfc3339(undefined)).toBeNull(); + expect(parseRfc3339({})).toBeNull(); + }); + + it('truncates fractional seconds instead of rounding', () => { + expect(parseRfc3339('2026-09-30T12:34:56.999999999Z')).toBe('2026-09-30T12:34:56.999Z'); + expect(parseHistoryTime('2026-09-30 12:34:56.0009')).toBe('2026-09-30T12:34:56.000Z'); + }); +}); + +describe('helpers', () => { + it('trims the shared whitespace set, which differs from String.prototype.trim', () => { + expect(stripWhitespace('\u001c\u0085 x \u3000')).toBe('x'); + expect(stripWhitespace('\ufeffx')).toBe('\ufeffx'); + expect(stripWhitespace('')).toBe(''); + }); + + it('applies the shared truthiness rules', () => { + expect([null, undefined, false, 0, '', [], {}].map(isTruthy)).toEqual(Array(7).fill(false)); + expect([true, 1, -1, 'x', [0], { a: 0 }, Number.NaN].map(isTruthy)).toEqual( + Array(7).fill(true), + ); + expect(isTruthy(() => undefined)).toBe(true); + }); + + it('falls back to "unknown" for empty slugs', () => { + expect(fallbackSlug('')).toBe('unknown'); + expect(fallbackSlug('___')).toBe('unknown'); + }); + + it('never resolves object prototype keys as exact slugs', () => { + expect(signalSlug('constructor')).toBe('constructor'); + expect(signalSlug('__proto__')).toBe('proto'); + }); +}); + +describe('type guarantees for unexpected input', () => { + it('turns values of the wrong type into empty strings, 0 weights and NaN scores', () => { + const result = fromWebhookData({ + request_id: null, + domain: 42, + user_hid: 12345, + risk_score: '80', + signals: [{ name: null, weight: null }], + public_ip: 'not an object', + local_ip: { ip: 7, country: false }, + traffic_source: { channel: ['x'] }, + observed_at: 1790771696123, + }); + expect(result.request_id).toBe(''); + expect(result.domain).toBe(''); + expect(result.user_hid).toBe('12345'); + expect(result.risk_score).toBeNaN(); + expect(result.signals).toEqual([{ name: '', weight: 0, description: null }]); + expect(result.public_ip).toEqual({ ip: '', country: '' }); + expect(result.local_ip).toEqual({ ip: '', country: '' }); + expect(result.traffic_source.channel).toBe(''); + expect(result.observed_at).toBeNull(); + }); + + it('handles History rows with values of the wrong type', () => { + const result = fromHistoryRow({ + site_domain: 5, + domain: 'example.com', + user_hid: { nested: true }, + score: null, + score_details: ['already', 'parsed'], + webrtc_leak_source: 7, + ip: 12, + created_at: 0, + }); + expect(result.domain).toBe(''); + expect(result.user_hid).toBeNull(); + expect(result.risk_score).toBeNaN(); + expect(result.signals).toEqual([]); + expect(result.public_ip.ip).toBe(''); + expect(result.observed_at).toBeNull(); + }); + + it('uses "" for a signal description that is not a string', () => { + const result = fromHistoryRow({ + score_details: JSON.stringify([{ Value: 10, Description: 42 }]), + }); + expect(result.signals).toEqual([{ name: 'unknown', weight: 10, description: '' }]); + }); + + it('keeps boolean User HID values as strings', () => { + expect(fromWebhookData({ user_hid: false }).user_hid).toBe('false'); + }); +}); + +describe('profileFromResponse', () => { + it('matches management-profile-expected.json and keeps the raw body', () => { + const body = loadJson>('management-profile.json'); + const expected = loadJson>('management-profile-expected.json'); + const profile = profileFromResponse(body); + const { raw, ...rest } = profile; + expect(rest).toStrictEqual(expected); + expect(raw).toBe(body); + expect(raw.Callback).toBe(''); + }); + + it('keeps negative remaining identifications and the zero timestamp', () => { + const profile = profileFromResponse({ + Domain: 'example.com', + Weight: -125, + PublicKey: '', + Secret: '', + CreatedAt: '0001-01-01T00:00:00Z', + }); + expect(profile.remaining_identifications).toBe(-125); + expect(profile.created_at).toBe('0001-01-01T00:00:00.000Z'); + expect(profile.public_key_masked).toBe(''); + }); + + it('defaults missing fields', () => { + const profile = profileFromResponse({}); + expect(profile).toMatchObject({ + domain: '', + remaining_identifications: 0, + public_key_masked: '', + secret_key_masked: '', + created_at: null, + }); + }); +}); diff --git a/test/risk.test.ts b/test/risk.test.ts new file mode 100644 index 0000000..c7baf1a --- /dev/null +++ b/test/risk.test.ts @@ -0,0 +1,259 @@ +import { describe, expect, it, vi } from 'vitest'; +import { + NIL_UUID, + RISK_BANDS, + SIGNALS, + ValidationError, + evaluateIdentification, + isRateLimited, + riskBand, +} from '../src/index.js'; +import { fromHistoryRow } from '../src/normalize.js'; +import type { Identification } from '../src/types.js'; +import { loadJson } from './helpers.js'; + +const bands = loadJson<{ cases: { score: number; band: string }[] }>('risk-band-cases.json'); +const page = loadJson<{ data: Record[] }>('history-page.json'); + +// Fixture rows: 0 dangerous (80, paid click), 1 trusted anonymous (10), 2 VPN (45), 3 marker (999), 4 search bot (0). +const NOW = Date.parse('2026-09-30T12:41:00.000Z'); + +function identification(index: number, changes: Partial = {}): Identification { + const base = fromHistoryRow(page.data[index] as Record); + return { ...base, observed_at: '2026-09-30T12:40:01.007Z', ...changes }; +} + +describe('riskBand', () => { + it.each(bands.cases)('score $score is $band', ({ score, band }) => { + expect(riskBand(score)).toBe(band); + }); + + it('treats every score above 100 as the rate-limit marker', () => { + expect(riskBand(101)).toBe('rate_limited'); + expect(riskBand(100)).toBe('dangerous'); + }); + + it('refuses values that are not numbers instead of calling them trusted', () => { + expect(() => riskBand(Number.NaN)).toThrow(ValidationError); + expect(() => riskBand('80' as unknown as number)).toThrow(ValidationError); + expect(() => riskBand(undefined as unknown as number)).toThrow(ValidationError); + }); +}); + +describe('isRateLimited', () => { + it('is true only above 100', () => { + expect(isRateLimited(999)).toBe(true); + expect(isRateLimited(101)).toBe(true); + expect(isRateLimited(100)).toBe(false); + expect(isRateLimited(0)).toBe(false); + expect(isRateLimited(Number.NaN)).toBe(false); + expect(isRateLimited('999' as unknown as number)).toBe(false); + }); +}); + +describe('constants', () => { + it('describes the three bands', () => { + expect(RISK_BANDS).toEqual({ + trusted: { min: 0, max: 29 }, + suspicious: { min: 30, max: 59 }, + dangerous: { min: 60, max: 100 }, + }); + expect(Object.isFrozen(RISK_BANDS)).toBe(true); + expect(Object.isFrozen(RISK_BANDS.trusted)).toBe(true); + for (const [band, range] of Object.entries(RISK_BANDS)) { + expect(riskBand(range.min)).toBe(band); + expect(riskBand(range.max)).toBe(band); + } + }); + + it('lists known signal slugs as unique, frozen constants', () => { + const values = Object.values(SIGNALS); + expect(new Set(values).size).toBe(values.length); + expect(Object.isFrozen(SIGNALS)).toBe(true); + expect(SIGNALS.VPN).toBe('vpn'); + expect(SIGNALS.ANTIDETECT_BROWSER).toBe('antidetect_browser'); + expect(SIGNALS.RATE_LIMITED).toBe('rate_limited'); + expect(SIGNALS.STUN_LATE_CORRECTION).toBe('stun_late_correction'); + }); + + it('includes every slug that appears in the fixtures', () => { + const scored = loadJson<{ data: { signals: { name: string }[] } }>( + 'webhook-identification-scored.json', + ); + const known = new Set(Object.values(SIGNALS)); + for (const signal of scored.data.signals) expect(known.has(signal.name)).toBe(true); + }); + + it('exposes the nil UUID', () => { + expect(NIL_UUID).toBe('00000000-0000-0000-0000-000000000000'); + }); +}); + +describe('evaluateIdentification', () => { + it('refuses a missing identification', () => { + expect(evaluateIdentification(null)).toEqual({ ok: false, reason: 'missing', band: null }); + expect(evaluateIdentification(undefined)).toEqual({ ok: false, reason: 'missing', band: null }); + }); + + it('accepts a fresh trusted identification', () => { + const result = evaluateIdentification(identification(1), { now: NOW }); + expect(result).toEqual({ ok: true, reason: null, band: 'trusted' }); + expect('flag' in result).toBe(false); + }); + + it('asks isReplay with the request ID and refuses replays first', () => { + const isReplay = vi.fn(() => true); + const stale = identification(0, { observed_at: '2020-01-01T00:00:00.000Z' }); + expect(evaluateIdentification(stale, { now: NOW, isReplay })).toEqual({ + ok: false, + reason: 'replayed', + band: 'dangerous', + }); + expect(isReplay).toHaveBeenCalledWith('02f1d973-84db-4156-a7f7-e799e6bf389b'); + }); + + it('treats any truthy isReplay result as a replay', () => { + const isReplay = (() => 1) as unknown as (id: string) => boolean; + expect(evaluateIdentification(identification(1), { now: NOW, isReplay }).reason).toBe( + 'replayed', + ); + expect(evaluateIdentification(identification(1), { now: NOW, isReplay: () => false }).ok).toBe( + true, + ); + }); + + it('rejects an asynchronous isReplay', () => { + const isReplay = (() => Promise.resolve(false)) as unknown as (id: string) => boolean; + expect(() => evaluateIdentification(identification(1), { now: NOW, isReplay })).toThrow( + ValidationError, + ); + }); + + it('refuses identifications older than maxAge', () => { + const observed = Date.parse('2026-09-30T12:40:01.007Z'); + expect(evaluateIdentification(identification(1), { now: observed + 300_000 }).ok).toBe(true); + expect(evaluateIdentification(identification(1), { now: observed + 300_001 })).toEqual({ + ok: false, + reason: 'stale', + band: 'trusted', + }); + expect( + evaluateIdentification(identification(1), { now: observed + 60_001, maxAge: 60_000 }).reason, + ).toBe('stale'); + expect(evaluateIdentification(identification(1), { now: new Date(observed + 1_000) }).ok).toBe( + true, + ); + }); + + it('skips the freshness check for maxAge Infinity', () => { + const observed = Date.parse('2026-09-30T12:40:01.007Z'); + const options = { now: observed + 86_400_000, maxAge: Number.POSITIVE_INFINITY }; + expect(evaluateIdentification(identification(1), options).ok).toBe(true); + expect(evaluateIdentification(identification(1, { observed_at: null }), options).ok).toBe(true); + expect(evaluateIdentification(identification(1), { ...options, maxAge: 0 }).reason).toBe( + 'stale', + ); + }); + + it('treats an unknown observation time as stale', () => { + expect( + evaluateIdentification(identification(1, { observed_at: null }), { now: NOW }).reason, + ).toBe('stale'); + expect( + evaluateIdentification(identification(1, { observed_at: 'garbage' }), { now: NOW }).reason, + ).toBe('stale'); + }); + + it('uses the current time by default', () => { + const fresh = identification(1, { observed_at: new Date().toISOString() }); + expect(evaluateIdentification(fresh).ok).toBe(true); + }); + + it('refuses the rate-limit marker before any device or band check', () => { + expect(evaluateIdentification(identification(3), { now: NOW })).toEqual({ + ok: false, + reason: 'rate_limited', + band: 'rate_limited', + }); + }); + + it('refuses identifications without device signals', () => { + expect( + evaluateIdentification(identification(1, { device_id: NIL_UUID }), { now: NOW }).reason, + ).toBe('no_device_signals'); + expect(evaluateIdentification(identification(1, { device_id: '' }), { now: NOW }).reason).toBe( + 'no_device_signals', + ); + }); + + it('refuses the default blocking flags and names the flag', () => { + const automation = identification(1); + automation.detection_flags = { ...automation.detection_flags, browser_automation: true }; + expect(evaluateIdentification(automation, { now: NOW })).toEqual({ + ok: false, + reason: 'blocked_flag', + band: 'trusted', + flag: 'browser_automation', + }); + + const noJs = identification(1); + noJs.detection_flags = { ...noJs.detection_flags, javascript_disabled: true }; + expect(evaluateIdentification(noJs, { now: NOW }).flag).toBe('javascript_disabled'); + }); + + it('checks flags before bands and supports custom flags', () => { + const dangerous = identification(0); + expect(evaluateIdentification(dangerous, { now: NOW, blockFlags: ['proxy'] })).toEqual({ + ok: false, + reason: 'blocked_flag', + band: 'dangerous', + flag: 'proxy', + }); + }); + + it('refuses the dangerous band by default', () => { + expect(evaluateIdentification(identification(0), { now: NOW })).toEqual({ + ok: false, + reason: 'blocked_band', + band: 'dangerous', + }); + }); + + it('supports custom bands and empty lists', () => { + const suspicious = identification(2); + expect(evaluateIdentification(suspicious, { now: NOW }).ok).toBe(true); + expect( + evaluateIdentification(suspicious, { now: NOW, blockBands: ['suspicious', 'dangerous'] }) + .reason, + ).toBe('blocked_band'); + const dangerous = identification(0); + expect(evaluateIdentification(dangerous, { now: NOW, blockBands: [], blockFlags: [] }).ok).toBe( + true, + ); + }); + + it('validates its options', () => { + const value = identification(1); + expect(() => evaluateIdentification(value, { maxAge: -1 })).toThrow(ValidationError); + expect(() => evaluateIdentification(value, { maxAge: Number.NaN })).toThrow(ValidationError); + expect(() => evaluateIdentification(value, { maxAge: Number.NEGATIVE_INFINITY })).toThrow( + ValidationError, + ); + expect(() => evaluateIdentification(value, { now: new Date('invalid') })).toThrow( + ValidationError, + ); + expect(() => + evaluateIdentification(value, { blockBands: ['rate_limited' as unknown as 'dangerous'] }), + ).toThrow(ValidationError); + expect(() => + evaluateIdentification(value, { blockFlags: ['browser_automations' as unknown as 'vpn'] }), + ).toThrow(ValidationError); + expect(() => evaluateIdentification(null, { maxAge: -5 })).toThrow(ValidationError); + }); + + it('throws when the identification carries no numeric score', () => { + expect(() => + evaluateIdentification(identification(1, { risk_score: Number.NaN }), { now: NOW }), + ).toThrow(ValidationError); + }); +}); diff --git a/test/runtime/smoke.mjs b/test/runtime/smoke.mjs new file mode 100644 index 0000000..f704244 --- /dev/null +++ b/test/runtime/smoke.mjs @@ -0,0 +1,199 @@ +// Runtime check of the built package on the oldest supported Node.js and in the edge build. +// Plain script (no test runner). Run after npm run build: +// node test/runtime/smoke.mjs (Node entry) +// node --conditions=worker test/runtime/smoke.mjs (edge entry) +import { createHmac } from 'node:crypto'; +import { readFileSync } from 'node:fs'; +import { + AuthenticationError, + RateLimitError, + ShieldLabs, + ShieldLabsManagement, + SignatureVerificationError, + evaluateIdentification, + riskBand, + userHid, + userHidAsync, + webhooks, +} from '@shieldlabs-ai/node'; + +const failures = []; +function check(label, condition) { + console.log(`${condition ? 'ok ' : 'FAIL'} ${label}`); + if (!condition) failures.push(label); +} + +const data = (name) => readFileSync(new URL(`../data/${name}`, import.meta.url)); +const page = JSON.parse(data('history-page.json').toString('utf8')); + +let entry = 'node'; +try { + webhooks.verifySignature('x', `sha256=${'0'.repeat(64)}`, 'whsec_x'); +} catch (error) { + entry = /Async/.test(error.message) ? 'edge' : 'unknown'; +} +const hasWebCrypto = Boolean(globalThis.crypto?.subtle); +console.log( + `# ${entry} entry on Node.js ${process.versions.node} (WebCrypto ${hasWebCrypto ? 'available' : 'not global'})`, +); +check('a known entry was resolved', entry === 'node' || entry === 'edge'); + +function respond(status, body, headers = {}) { + return new Response(typeof body === 'string' ? body : JSON.stringify(body), { status, headers }); +} + +// History API: search, polling and error mapping with a local fetch. +const row = { + ...page.data[1], + created_at: new Date().toISOString().replace('T', ' ').replace('Z', ''), +}; +let polls = 0; +const history = new ShieldLabs({ + apiKey: 'sec_abcd1234-efgh5678-ijkl9012', + fetch: async (url, init) => { + if (init.headers.Authorization !== 'Bearer sec_abcd1234-efgh5678-ijkl9012') + return respond(401, '{"error":"invalid api key"}\n'); + if (url.includes('/request_id/')) { + polls += 1; + return respond(200, { data: polls >= 3 ? [row] : [], total: polls >= 3 ? 1 : 0 }); + } + if (url.includes('/ip/203.0.113.9')) + return respond(429, '{"error":"too many requests"}\n', { 'retry-after': '1' }); + return respond(200, page); + }, + maxRetries: 0, +}); + +const searched = await history.history.search('user_hid', 'anonymous'); +check( + 'history.search normalizes the fixture page', + searched.total === 37 && searched.data.length === 5, +); +check('History rows keep their raw fields', searched.data[0].raw.ver === page.data[0].ver); +const identification = await history.identifications.get(row.request_id, { + pollInterval: 20, + timeout: 2000, +}); +check( + 'identifications.get polls until the row appears', + identification?.request_id === row.request_id && polls === 3, +); +const verdict = evaluateIdentification(identification); +check( + 'evaluateIdentification accepts a fresh trusted identification', + verdict.ok && verdict.band === 'trusted', +); +check('riskBand maps the marker', riskBand(999) === 'rate_limited'); +try { + await history.history.search('ip', '203.0.113.9'); + check('a 429 raises RateLimitError', false); +} catch (error) { + check( + 'a 429 raises RateLimitError with retryAfter', + error instanceof RateLimitError && error.retryAfter === 1, + ); +} +check( + 'a client constructs with the global fetch', + new ShieldLabs({ apiKey: 'sec_zzzzzzzz-zzzzzzzz-zzzzzzzz' }) instanceof ShieldLabs, +); + +// Management API. +const management = new ShieldLabsManagement({ + secretKey: '0123456789abcdef0123456789abcdef', + domain: 'https://www.Example.com/', + fetch: async (_url, init) => + init.headers['X-Shield-Domain'] === 'example.com' + ? respond(200, data('management-profile.json').toString('utf8')) + : respond(401, ''), +}); +const profile = await management.getProfile(); +check( + 'getProfile normalizes the profile', + profile.remaining_identifications === 148230 && profile.created_at === '2026-01-15T09:00:00.000Z', +); +const unauthorized = new ShieldLabsManagement({ + secretKey: 'x', + domain: 'example.com', + fetch: async () => respond(401, ''), +}); +try { + await unauthorized.getProfile(); + check('a Management 401 raises AuthenticationError', false); +} catch (error) { + check('a Management 401 raises AuthenticationError', error instanceof AuthenticationError); +} + +// Webhooks. +const secret = 'whsec_00112233445566778899aabbccddeeff'; +const ping = data('webhook-ping.raw.txt'); +const pingHeader = 'sha256=ea2685733d254f7028fb031c4214583b0650de01e6c8c93131236024edd9fdd8'; +const scored = data('webhook-identification-scored.raw.txt'); +const scoredHeader = `sha256=${createHmac('sha256', secret).update(scored).digest('hex')}`; + +if (entry === 'node') { + check( + 'verifySignature accepts the ping', + webhooks.verifySignature(ping, pingHeader, secret) === true, + ); + check( + 'verifySignature refuses a wrong secret', + webhooks.verifySignature(ping, pingHeader, 'whsec_other') === false, + ); + const event = webhooks.constructEvent(scored, scoredHeader, secret); + check( + 'constructEvent parses the scored delivery', + event.event_type === 'identification.scored' && event.data.risk_score === 80, + ); + check( + 'userHid computes HMAC-SHA256', + userHid('user-42', secret) === createHmac('sha256', secret).update('user-42').digest('hex'), + ); +} else { + let message = ''; + try { + webhooks.constructEvent(scored, scoredHeader, secret); + } catch (error) { + message = error.message; + } + check( + 'the edge constructEvent points to constructEventAsync', + message.includes('constructEventAsync'), + ); +} + +if (entry === 'node' || hasWebCrypto) { + check( + 'verifySignatureAsync accepts the ping', + (await webhooks.verifySignatureAsync(ping, pingHeader, [secret])) === true, + ); + const event = await webhooks.constructEventAsync(scored, scoredHeader, ['whsec_old', secret]); + check( + 'constructEventAsync parses the scored delivery', + event.event_type === 'identification.scored', + ); + try { + await webhooks.constructEventAsync(scored, pingHeader, secret); + check('constructEventAsync refuses a bad signature', false); + } catch (error) { + check( + 'constructEventAsync refuses a bad signature', + error instanceof SignatureVerificationError, + ); + } + check( + 'userHidAsync computes HMAC-SHA256', + (await userHidAsync('user-42', secret)) === + createHmac('sha256', secret).update('user-42').digest('hex'), + ); +} else { + console.log( + '# skipped WebCrypto checks: globalThis.crypto is not available in this Node.js version', + ); +} + +if (failures.length > 0) { + console.error(`\n${failures.length} runtime check(s) failed.`); + process.exit(1); +} +console.log(`\nRuntime smoke test passed (${entry} entry).`); diff --git a/test/types.test.ts b/test/types.test.ts new file mode 100644 index 0000000..b361e35 --- /dev/null +++ b/test/types.test.ts @@ -0,0 +1,78 @@ +// Compile-time checks: `npm run typecheck` fails when a public type drifts. +import { describe, expect, expectTypeOf, it } from 'vitest'; +import { + evaluateIdentification, + riskBand, + webhooks, + type Evaluation, + type FetchLike, + type IdentificationScoredEvent, + type LookupType, + type RiskBand, + type ShieldLabs, + type ShieldLabsOptions, + type UnknownWebhookEvent, + type WebhookEvent, + type WebhookPingEvent, +} from '../src/index.js'; +import { loadText } from './helpers.js'; + +function describeEvent(event: WebhookEvent): string { + switch (event.event_type) { + case 'identification.scored': + expectTypeOf(event).toEqualTypeOf(); + return `scored ${event.data.request_id}`; + case 'webhook.ping': + expectTypeOf(event).toEqualTypeOf(); + return 'ping'; + default: + expectTypeOf(event).toEqualTypeOf(); + return `unknown ${String(event.event_type)}`; + } +} + +describe('public types', () => { + it('narrow webhook events by event_type, keeping unknown events in the default branch', () => { + const body = loadText('webhook-ping.raw.txt'); + const event = webhooks.constructEvent( + body, + 'sha256=ea2685733d254f7028fb031c4214583b0650de01e6c8c93131236024edd9fdd8', + 'whsec_00112233445566778899aabbccddeeff', + ); + expect(describeEvent(event)).toBe('ping'); + expectTypeOf().toExtend(); + }); + + it('describe the risk helpers', () => { + expectTypeOf(riskBand).returns.toEqualTypeOf(); + expectTypeOf(evaluateIdentification).returns.toEqualTypeOf(); + expectTypeOf().toEqualTypeOf< + | 'missing' + | 'replayed' + | 'stale' + | 'rate_limited' + | 'no_device_signals' + | 'blocked_flag' + | 'blocked_band' + | null + >(); + }); + + it('accept the global fetch and explicit undefined options', () => { + expectTypeOf(globalThis.fetch).toExtend(); + const options: ShieldLabsOptions = { + apiKey: 'sec_your_private_key', + baseUrl: undefined, + timeout: undefined, + maxRetries: undefined, + fetch: undefined, + allowInsecureHttp: undefined, + }; + expect(options.apiKey).toBe('sec_your_private_key'); + }); + + it('type the History methods', () => { + expectTypeOf[0]>().toEqualTypeOf(); + expectTypeOf>().toExtend>(); + }); +}); diff --git a/test/validation.test.ts b/test/validation.test.ts new file mode 100644 index 0000000..f8ee59f --- /dev/null +++ b/test/validation.test.ts @@ -0,0 +1,401 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { + ShieldLabs, + ShieldLabsManagement, + ValidationError, + type LookupType, +} from '../src/index.js'; +import { resetWarnings } from '../src/runtime.js'; +import { encodePathSegment } from '../src/validation.js'; +import { REQUEST_ID, TEST_API_KEY, fakeFetch, historyPage } from './helpers.js'; + +function client() { + const fake = fakeFetch([historyPage([])]); + return { shieldlabs: new ShieldLabs({ apiKey: TEST_API_KEY, fetch: fake.fetch }), fake }; +} + +afterEach(() => resetWarnings()); + +describe('History lookups are validated before any request', () => { + const invalid: Array<[string, unknown, unknown]> = [ + ['unknown type', 'email', 'user@example.com'], + ['empty type', '', REQUEST_ID], + ['auto type', 'auto', REQUEST_ID], + ['non-string type', 42, REQUEST_ID], + ['uppercase type', 'REQUEST_ID', REQUEST_ID], + ['bad UUID', 'device_id', 'not-a-uuid'], + ['UUID with braces', 'visitor_id', `{${REQUEST_ID}}`], + ['UUID without dashes', 'request_id', REQUEST_ID.replaceAll('-', '')], + ['UUID with spaces', 'session_id', ` ${REQUEST_ID}`], + ['URN UUID', 'cookie_id', `urn:uuid:${REQUEST_ID}`], + ['IPv6', 'ip', '2001:db8::1'], + ['IPv4-mapped IPv6', 'ip', '::ffff:192.0.2.1'], + ['octet above 255', 'ip', '256.1.1.1'], + ['three octets', 'ip', '192.0.2'], + ['leading zero', 'ip', '192.0.02.1'], + ['padded IP', 'ip', ' 192.0.2.1'], + ['hostname', 'ip', 'example.com'], + ['empty user_hid', 'user_hid', ''], + ['dot user_hid', 'user_hid', '.'], + ['dot-dot user_hid', 'user_hid', '..'], + ['user_hid with a slash', 'user_hid', 'a/b'], + ['base64 user_hid with a slash', 'user_hid', 'ab+/cd=='], + ['user_hid with an unpaired surrogate', 'user_hid', 'a\uD800b'], + ['non-string value', 'user_hid', 42], + ['undefined value', 'device_id', undefined], + ]; + + it.each(invalid)('rejects %s', async (_label, type, value) => { + const { shieldlabs, fake } = client(); + await expect(shieldlabs.history.search(type as LookupType, value as string)).rejects.toThrow( + ValidationError, + ); + expect(() => shieldlabs.history.iterate(type as LookupType, value as string)).toThrow( + ValidationError, + ); + expect(fake.calls).toHaveLength(0); + }); + + it('explains why a User HID with a slash cannot be searched', async () => { + const { shieldlabs } = client(); + await expect(shieldlabs.history.search('user_hid', 'a/b')).rejects.toThrow( + /cannot contain "\/"/, + ); + }); + + it('explains that IPv6 cannot be searched', async () => { + const { shieldlabs } = client(); + await expect(shieldlabs.history.search('ip', '2001:db8::7')).rejects.toThrow(/IPv6/); + }); + + it('lists the valid types for an unknown type', async () => { + const { shieldlabs } = client(); + await expect(shieldlabs.history.search('email' as LookupType, 'x')).rejects.toThrow( + /ip, user_hid, visitor_id, request_id, device_id, session_id, cookie_id/, + ); + }); + + it.each([0, 101, 1.5, -1, Number.NaN, '20'])('rejects limit %s', async (limit) => { + const { shieldlabs, fake } = client(); + await expect( + shieldlabs.history.search('request_id', REQUEST_ID, { limit: limit as number }), + ).rejects.toThrow(ValidationError); + expect(fake.calls).toHaveLength(0); + }); + + it.each([-1, 1.5, Number.POSITIVE_INFINITY, '0'])('rejects offset %s', async (offset) => { + const { shieldlabs, fake } = client(); + await expect( + shieldlabs.history.search('request_id', REQUEST_ID, { offset: offset as number }), + ).rejects.toThrow(ValidationError); + expect(fake.calls).toHaveLength(0); + }); + + it.each([0, 101, 2.5])('rejects pageSize %s', (pageSize) => { + const { shieldlabs } = client(); + expect(() => shieldlabs.history.iterate('user_hid', 'u1', { pageSize })).toThrow( + ValidationError, + ); + }); + + it.each([-1, 0.5])('rejects maxItems %s', (maxItems) => { + const { shieldlabs } = client(); + expect(() => shieldlabs.history.iterate('user_hid', 'u1', { maxItems })).toThrow( + ValidationError, + ); + }); + + it('accepts the boundary values', async () => { + const { shieldlabs, fake } = client(); + await shieldlabs.history.search('request_id', REQUEST_ID, { limit: 1, offset: 0 }); + await shieldlabs.history.search('request_id', REQUEST_ID, { limit: 100, offset: 1_000_000 }); + await shieldlabs.history.search('ip', '0.0.0.0'); + await shieldlabs.history.search('ip', '255.255.255.255'); + await shieldlabs.history.search('device_id', '00000000-0000-0000-0000-000000000000'); + await shieldlabs.history.search('user_hid', 'anonymous'); + expect(fake.calls).toHaveLength(6); + }); +}); + +describe('User HID path segments', () => { + it('escape every ASCII character in canonical path form', () => { + const plain = /^[A-Za-z0-9\-._~$&+,:;=@]$/; + for (let code = 0; code < 128; code++) { + const char = String.fromCharCode(code); + if (char === '/') continue; + const expected = plain.test(char) + ? char + : `%${code.toString(16).toUpperCase().padStart(2, '0')}`; + expect(encodePathSegment(`a${char}b`), JSON.stringify(char)).toBe(`a${expected}b`); + } + }); + + it('escape other characters as uppercase UTF-8 bytes', () => { + expect(encodePathSegment('ü')).toBe('%C3%BC'); + expect(encodePathSegment('日本')).toBe('%E6%97%A5%E6%9C%AC'); + expect(encodePathSegment('\u{1F600}')).toBe('%F0%9F%98%80'); + }); + + it('match byte-wise canonical path escaping across Unicode', () => { + // Canonical path escaping, one UTF-8 byte at a time. + const plain = /^[A-Za-z0-9\-._~$&+,:;=@]$/; + const canonical = (text: string): string => + Array.from(new TextEncoder().encode(text), (byte) => { + const char = String.fromCharCode(byte); + return plain.test(char) ? char : `%${byte.toString(16).toUpperCase().padStart(2, '0')}`; + }).join(''); + for (let code = 0x80; code <= 0x10ffff; code += 0x7f) { + if (code >= 0xd800 && code <= 0xdfff) continue; + const text = `id ${String.fromCodePoint(code)}+=@%?#`; + const segment = encodePathSegment(text); + expect(segment, code.toString(16)).toBe(canonical(text)); + expect(decodeURIComponent(segment)).toBe(text); + } + }); +}); + +describe('identifications.get validation', () => { + it.each([['not-a-uuid'], [''], [42], [undefined]])('rejects request ID %s', async (requestId) => { + const { shieldlabs, fake } = client(); + await expect(shieldlabs.identifications.get(requestId as string)).rejects.toThrow( + ValidationError, + ); + await expect( + shieldlabs.identifications.get(requestId as string, { wait: false }), + ).rejects.toThrow(ValidationError); + expect(fake.calls).toHaveLength(0); + }); + + it.each([ + [{ timeout: -1 }], + [{ timeout: Number.NaN }], + [{ pollInterval: 0 }], + [{ pollInterval: -5 }], + [{ pollInterval: Number.POSITIVE_INFINITY }], + [{ wait: 'yes' }], + ])('rejects options %j', async (options) => { + const { shieldlabs, fake } = client(); + await expect(shieldlabs.identifications.get(REQUEST_ID, options as never)).rejects.toThrow( + ValidationError, + ); + expect(fake.calls).toHaveLength(0); + }); +}); + +describe('ShieldLabs constructor', () => { + it.each([ + ['missing options', undefined], + ['null options', null], + ['empty apiKey', { apiKey: '' }], + ['blank apiKey', { apiKey: ' ' }], + ['non-string apiKey', { apiKey: 42 }], + ['invalid URL', { apiKey: TEST_API_KEY, baseUrl: 'not a url' }], + ['empty URL', { apiKey: TEST_API_KEY, baseUrl: '' }], + ['ftp URL', { apiKey: TEST_API_KEY, baseUrl: 'ftp://account.shieldlabs.ai' }], + [ + 'URL with credentials', + { apiKey: TEST_API_KEY, baseUrl: 'https://user:pass@account.shieldlabs.ai' }, + ], + ['URL with query', { apiKey: TEST_API_KEY, baseUrl: 'https://account.shieldlabs.ai/?x=1' }], + ['URL with fragment', { apiKey: TEST_API_KEY, baseUrl: 'https://account.shieldlabs.ai/#top' }], + ['zero timeout', { apiKey: TEST_API_KEY, timeout: 0 }], + ['negative timeout', { apiKey: TEST_API_KEY, timeout: -1 }], + ['fractional retries', { apiKey: TEST_API_KEY, maxRetries: 1.5 }], + ['negative retries', { apiKey: TEST_API_KEY, maxRetries: -1 }], + ['fetch not a function', { apiKey: TEST_API_KEY, fetch: 'fetch' }], + ])('rejects %s', (_label, options) => { + expect(() => new ShieldLabs(options as never)).toThrow(ValidationError); + }); + + it.each([ + ['a line feed', `${TEST_API_KEY}\nX-Injected: 1`], + ['a carriage return', `${TEST_API_KEY}\r\nX-Injected: 1`], + ['a NUL character', `${TEST_API_KEY}\u0000`], + ['an inner space', 'sec_abcd1234 efgh5678-ijkl9012'], + ['a tab', 'sec_abcd1234\tefgh5678-ijkl9012'], + ['a non-ASCII character', 'sec_abcd1234-efgh5678-ijkl901\u00fc'], + ['a non-breaking space', 'sec_abcd1234\u00a0efgh5678-ijkl9012'], + ])('rejects an apiKey with %s without echoing it', (_label, apiKey) => { + const error = (() => { + try { + new ShieldLabs({ apiKey }); + } catch (caught) { + return caught as Error; + } + return undefined; + })(); + expect(error).toBeInstanceOf(ValidationError); + expect(error?.message).toMatch( + /apiKey contains characters that cannot be sent in an HTTP header/, + ); + expect(error?.message).not.toContain('abcd1234'); + expect(error?.cause).toBeUndefined(); + }); + + it('warns once when the key does not look like a Private API Key, without printing it', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + new ShieldLabs({ apiKey: 'pk_0123456789abcdef0123456789abcdef' }); + new ShieldLabs({ apiKey: 'pk_0123456789abcdef0123456789abcdef' }); + expect(warn).toHaveBeenCalledTimes(1); + expect(String(warn.mock.calls[0]?.[0])).toMatch(/Private API Key/); + expect(String(warn.mock.calls[0]?.[0])).not.toContain('0123456789abcdef'); + }); + + it('does not warn for a well-formed key', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + new ShieldLabs({ apiKey: TEST_API_KEY }); + new ShieldLabs({ apiKey: ` ${TEST_API_KEY} ` }); + expect(warn).not.toHaveBeenCalled(); + }); + + it.each([ + 'http://127.0.0.1:8080', + 'http://127.0.0.2', + 'http://127.1:9000', + 'http://localhost:8080', + 'http://api.localhost', + 'http://[::1]:8080', + 'http://[0:0:0:0:0:0:0:1]', + ])('accepts plain http on the loopback host %s without a warning', (baseUrl) => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + expect(new ShieldLabs({ apiKey: TEST_API_KEY, baseUrl })).toBeInstanceOf(ShieldLabs); + expect( + new ShieldLabsManagement({ secretKey: 'secret', domain: 'example.com', baseUrl }), + ).toBeInstanceOf(ShieldLabsManagement); + expect(warn).not.toHaveBeenCalled(); + }); + + it.each([ + 'http://account.shieldlabs.ai', + 'http://history.example.com', + 'http://192.0.2.10:8080', + 'http://localhost.example.com', + 'http://[2001:db8::1]', + ])('refuses plain http on %s', (baseUrl) => { + expect(() => new ShieldLabs({ apiKey: TEST_API_KEY, baseUrl })).toThrow( + /baseUrl must be an https URL.*allowInsecureHttp: true/, + ); + expect( + () => new ShieldLabsManagement({ secretKey: 'secret', domain: 'example.com', baseUrl }), + ).toThrow(ValidationError); + }); + + it('accepts plain http on another host with allowInsecureHttp, with a one-time warning', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + const baseUrl = 'http://mock.test:8080'; + new ShieldLabs({ apiKey: TEST_API_KEY, baseUrl, allowInsecureHttp: true }); + new ShieldLabs({ apiKey: TEST_API_KEY, baseUrl, allowInsecureHttp: true }); + new ShieldLabsManagement({ + secretKey: 'secret', + domain: 'example.com', + baseUrl: 'http://management.test', + allowInsecureHttp: true, + }); + expect(warn).toHaveBeenCalledTimes(2); + expect(String(warn.mock.calls[0]?.[0])).toMatch(/plain http/); + }); + + it('rejects an allowInsecureHttp that is not a boolean', () => { + expect( + () => new ShieldLabs({ apiKey: TEST_API_KEY, allowInsecureHttp: 'yes' as never }), + ).toThrow(/allowInsecureHttp must be a boolean/); + expect( + () => + new ShieldLabsManagement({ + secretKey: 'secret', + domain: 'example.com', + allowInsecureHttp: 1 as never, + }), + ).toThrow(ValidationError); + }); + + it('warns once when created in a web page, without printing the key', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + vi.stubGlobal('window', {}); + vi.stubGlobal('document', {}); + new ShieldLabs({ apiKey: TEST_API_KEY }); + new ShieldLabs({ apiKey: TEST_API_KEY }); + new ShieldLabsManagement({ secretKey: 'my-secret-value', domain: 'example.com' }); + expect(warn).toHaveBeenCalledTimes(2); + const messages = warn.mock.calls.map((call) => String(call[0])); + expect(messages[0]).toMatch(/ShieldLabs was created in a web page.*Private API Key/); + expect(messages[1]).toMatch(/ShieldLabsManagement was created in a web page.*Secret Key/); + expect(messages.join(' ')).not.toContain(TEST_API_KEY); + expect(messages.join(' ')).not.toContain('my-secret-value'); + }); + + it('does not warn in a worker, which has no document', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + vi.stubGlobal('window', {}); + new ShieldLabs({ apiKey: TEST_API_KEY }); + expect(warn).not.toHaveBeenCalled(); + }); + + it('never exposes the key as a property', () => { + const shieldlabs = new ShieldLabs({ apiKey: TEST_API_KEY }); + expect(JSON.stringify(shieldlabs)).not.toContain(TEST_API_KEY); + expect(Object.keys(shieldlabs)).toEqual(['identifications', 'history']); + }); +}); + +describe('ShieldLabsManagement constructor', () => { + it.each([ + ['missing options', undefined], + ['empty secret', { secretKey: '', domain: 'example.com' }], + ['missing domain', { secretKey: 'secret' }], + ['non-string domain', { secretKey: 'secret', domain: 42 }], + ['empty domain', { secretKey: 'secret', domain: ' ' }], + ['scheme only', { secretKey: 'secret', domain: 'https://' }], + ['bad base URL', { secretKey: 'secret', domain: 'example.com', baseUrl: 'api.shieldlabs.ai' }], + ['zero timeout', { secretKey: 'secret', domain: 'example.com', timeout: 0 }], + ['negative retries', { secretKey: 'secret', domain: 'example.com', maxRetries: -1 }], + ['fetch not a function', { secretKey: 'secret', domain: 'example.com', fetch: {} }], + ])('rejects %s', (_label, options) => { + expect(() => new ShieldLabsManagement(options as never)).toThrow(ValidationError); + }); + + it.each([ + ['a line feed', '0123456789abcdef\r\n0123456789abcdef'], + ['a NUL character', '0123456789abcdef\u00000123456789abcdef'], + ['an inner space', '0123456789abcdef 0123456789abcdef'], + ['a non-ASCII character', '0123456789abcdef\u00e90123456789abcdef'], + ])('rejects a secretKey with %s without echoing it', (_label, secretKey) => { + expect(() => new ShieldLabsManagement({ secretKey, domain: 'example.com' })).toThrow( + /secretKey contains characters that cannot be sent in an HTTP header/, + ); + try { + new ShieldLabsManagement({ secretKey, domain: 'example.com' }); + } catch (error) { + expect((error as Error).message).not.toContain('0123456789abcdef'); + } + }); + + it.each([ + ['a line feed', 'example.com\nX-Injected: 1'], + ['a NUL character', 'example\u0000.com'], + ['an inner space', 'exam ple.com'], + ['a tab', 'example.com\tx'], + ])('rejects a domain with %s', (_label, domain) => { + expect(() => new ShieldLabsManagement({ secretKey: 'secret', domain })).toThrow( + /domain contains characters that cannot be sent in an HTTP header/, + ); + }); + + it('asks for the punycode form of a non-ASCII domain', () => { + expect( + () => + new ShieldLabsManagement({ secretKey: 'secret', domain: 'https://www.M\u00fcnchen.de/' }), + ).toThrow(/punycode/); + expect( + new ShieldLabsManagement({ secretKey: 'secret', domain: 'xn--mnchen-3ya.de' }).domain, + ).toBe('xn--mnchen-3ya.de'); + }); + + it('never exposes the secret as a property', () => { + const management = new ShieldLabsManagement({ + secretKey: 'my-secret-value', + domain: 'example.com', + }); + expect(JSON.stringify(management)).not.toContain('my-secret-value'); + }); +}); diff --git a/test/webhooks.test.ts b/test/webhooks.test.ts new file mode 100644 index 0000000..9c23224 --- /dev/null +++ b/test/webhooks.test.ts @@ -0,0 +1,354 @@ +import { createHmac } from 'node:crypto'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import * as edge from '../src/edge.js'; +import { + SignatureVerificationError, + WebhookParseError, + isRateLimited, + webhooks, + type IdentificationScoredEvent, + type WebhookEvent, +} from '../src/index.js'; +import { resetWarnings } from '../src/runtime.js'; +import { + constantTimeEqual, + parseSignatureHeader, + payloadBytes, + secretList, +} from '../src/webhooks.js'; +import type { Identification } from '../src/types.js'; +import { loadBytes, loadJson, loadText, withoutRaw } from './helpers.js'; + +interface Vector { + name: string; + secret?: string; + secrets?: string[]; + body: string; + body_base64: string; + signature_header: string; + valid: boolean; +} + +const { vectors } = loadJson<{ vectors: Vector[] }>('webhook-signature-vectors.json'); +const expectedByName = new Map( + loadJson<{ cases: { name: string; expected: Omit }[] }>( + 'normalization-cases.json', + ).cases.map((c) => [c.name, c.expected]), +); + +const SECRET = 'whsec_00112233445566778899aabbccddeeff'; + +function sign(body: string | Uint8Array, secret = SECRET): string { + return `sha256=${createHmac('sha256', secret).update(body).digest('hex')}`; +} + +function secretOf(vector: Vector): string | string[] { + return vector.secrets ?? vector.secret ?? ''; +} + +function bodyBytes(vector: Vector): Uint8Array { + return new Uint8Array(Buffer.from(vector.body_base64, 'base64')); +} + +afterEach(() => resetWarnings()); + +describe('webhook-signature-vectors.json', () => { + it('contains 21 vectors with both secret forms', () => { + expect(vectors).toHaveLength(21); + expect(vectors.some((v) => Array.isArray(v.secrets))).toBe(true); + expect(vectors.some((v) => typeof v.secret === 'string')).toBe(true); + for (const vector of vectors) + expect(new TextDecoder().decode(bodyBytes(vector))).toBe(vector.body); + }); + + describe.each(vectors)('$name', (vector) => { + const secret = secretOf(vector); + const header = vector.signature_header; + + it('verifySignature with the raw bytes, a Buffer, an ArrayBuffer and the string', () => { + const bytes = bodyBytes(vector); + expect(webhooks.verifySignature(bytes, header, secret)).toBe(vector.valid); + expect(webhooks.verifySignature(Buffer.from(bytes), header, secret)).toBe(vector.valid); + expect(webhooks.verifySignature(bytes.slice().buffer, header, secret)).toBe(vector.valid); + expect(webhooks.verifySignature(vector.body, header, secret)).toBe(vector.valid); + }); + + it('verifySignatureAsync (Node entry and edge entry)', async () => { + await expect(webhooks.verifySignatureAsync(bodyBytes(vector), header, secret)).resolves.toBe( + vector.valid, + ); + await expect( + edge.webhooks.verifySignatureAsync(bodyBytes(vector), header, secret), + ).resolves.toBe(vector.valid); + await expect(edge.webhooks.verifySignatureAsync(vector.body, header, secret)).resolves.toBe( + vector.valid, + ); + }); + + it('constructEvent and constructEventAsync', async () => { + if (vector.valid) { + const event = webhooks.constructEvent(bodyBytes(vector), header, secret); + expect(event.event_type).toBe(JSON.parse(vector.body).event_type); + await expect( + edge.webhooks.constructEventAsync(vector.body, header, secret), + ).resolves.toEqual(event); + await expect(webhooks.constructEventAsync(vector.body, header, secret)).resolves.toEqual( + event, + ); + } else { + expect(() => webhooks.constructEvent(bodyBytes(vector), header, secret)).toThrow( + SignatureVerificationError, + ); + await expect( + edge.webhooks.constructEventAsync(bodyBytes(vector), header, secret), + ).rejects.toThrow(SignatureVerificationError); + await expect( + webhooks.constructEventAsync(bodyBytes(vector), header, secret), + ).rejects.toThrow(SignatureVerificationError); + } + }); + }); +}); + +describe('constructEvent with the webhook fixtures', () => { + it('parses the scored delivery exactly as sent', () => { + const raw = loadBytes('webhook-identification-scored.raw.txt'); + const event = webhooks.constructEvent(raw, sign(raw), SECRET) as IdentificationScoredEvent; + expect(event.event_type).toBe('identification.scored'); + expect(event.schema_version).toBe('2026-06-01'); + expect(event.created_at).toBe('2026-09-30T12:34:57.482913041Z'); + expect(withoutRaw(event.data)).toStrictEqual(expectedByName.get('webhook_scored')); + expect(event.data.observed_at).toBe('2026-09-30T12:34:57.482Z'); + expect(event.data.traffic_source.landing_url).toBe( + 'https://shop.example.com/signup?utm_source=google&utm_medium=cpc&gclid=abc123', + ); + expect(event.raw.data).toBe(event.data.raw); + expect(Object.keys(event)).toEqual([ + 'event_type', + 'schema_version', + 'created_at', + 'data', + 'raw', + ]); + }); + + it('parses the pretty-printed scored fixture to the same identification', () => { + const pretty = loadText('webhook-identification-scored.json'); + const event = webhooks.constructEvent( + pretty, + sign(pretty), + SECRET, + ) as IdentificationScoredEvent; + expect(withoutRaw(event.data)).toStrictEqual(expectedByName.get('webhook_scored')); + }); + + it('parses the rate-limited delivery', () => { + const body = loadText('webhook-rate-limited.json'); + const event = webhooks.constructEvent(body, sign(body), SECRET) as IdentificationScoredEvent; + expect(withoutRaw(event.data)).toStrictEqual(expectedByName.get('webhook_rate_limited')); + expect(isRateLimited(event.data.risk_score)).toBe(true); + expect(event.data.signals).toEqual([{ name: 'rate_limited', weight: 999, description: null }]); + }); + + it('parses the Verify ping from its exact bytes', () => { + const raw = loadBytes('webhook-ping.raw.txt'); + const header = vectors.find((v) => v.name === 'valid_real_ping_delivery')?.signature_header; + const event = webhooks.constructEvent(raw, header, SECRET); + expect(event).toEqual({ + event_type: 'webhook.ping', + schema_version: '2026-06-01', + created_at: '2026-09-30T12:34:56Z', + raw: loadJson('webhook-ping.json'), + }); + expect('data' in event).toBe(false); + }); + + it('parses the dashboard test delivery with 17 of 19 flags', () => { + const body = loadText('webhook-test-delivery.json'); + const sent = JSON.parse(body) as { data: { detection_flags: Record } }; + expect(Object.keys(sent.data.detection_flags)).toHaveLength(17); + const event = webhooks.constructEvent(body, sign(body), SECRET) as IdentificationScoredEvent; + expect(Object.keys(event.data.detection_flags)).toHaveLength(19); + expect(event.data.detection_flags.browser_automation).toBe(false); + expect(event.data.detection_flags.search_bot).toBe(false); + expect(event.data.user_hid).toBeNull(); + expect(event.created_at).toBe('2026-09-30T12:34:56Z'); + expect(event.data.observed_at).toBe('2026-09-30T12:34:56.000Z'); + expect(withoutRaw(event.data)).toStrictEqual(expectedByName.get('webhook_test_delivery')); + }); + + it('narrows the event union by event_type', () => { + const raw = loadBytes('webhook-identification-scored.raw.txt'); + const event: WebhookEvent = webhooks.constructEvent(raw, sign(raw), SECRET); + let requestId = ''; + switch (event.event_type) { + case 'identification.scored': + requestId = event.data.request_id; + break; + case 'webhook.ping': + break; + default: + expect.unreachable(); + } + expect(requestId).toBe('02f1d973-84db-4156-a7f7-e799e6bf389b'); + }); +}); + +describe('event parsing rules', () => { + function construct(body: string): WebhookEvent { + return webhooks.constructEvent(body, sign(body), SECRET); + } + + it('returns an UnknownWebhookEvent for new event types', () => { + const body = + '{"event_type":"identification.refined","schema_version":"2026-06-01","created_at":"2026-09-30T12:00:00Z","data":{"x":1}}'; + const event = construct(body); + expect(event.event_type).toBe('identification.refined'); + expect(event.raw).toEqual(JSON.parse(body)); + expect('data' in event).toBe(false); + }); + + it('accepts an unknown schema_version and warns once per value', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + const body = + '{"event_type":"webhook.ping","schema_version":"2027-01-01","created_at":"2026-09-30T12:00:00Z"}'; + expect(construct(body).schema_version).toBe('2027-01-01'); + construct(body); + expect(warn).toHaveBeenCalledTimes(1); + expect(String(warn.mock.calls[0]?.[0])).toContain('2027-01-01'); + construct('{"event_type":"webhook.ping","created_at":"2026-09-30T12:00:00Z"}'); + expect(warn).toHaveBeenCalledTimes(2); + }); + + it('does not warn for the known schema version', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + construct(loadText('webhook-ping.raw.txt')); + expect(warn).not.toHaveBeenCalled(); + }); + + it('defaults missing envelope strings to ""', () => { + vi.spyOn(console, 'warn').mockImplementation(() => undefined); + const event = construct('{"event_type":"webhook.ping","schema_version":5,"created_at":null}'); + expect(event.schema_version).toBe(''); + expect(event.created_at).toBe(''); + }); + + it('strips a byte order mark', () => { + const body = `\ufeff${loadText('webhook-ping.raw.txt')}`; + expect(construct(body).event_type).toBe('webhook.ping'); + }); + + it.each([ + ['invalid JSON', '{"event_type":'], + ['an empty body', ''], + ['a JSON array', '[{"event_type":"webhook.ping"}]'], + ['a JSON string', '"webhook.ping"'], + ['a missing event_type', '{"schema_version":"2026-06-01"}'], + ['an empty event_type', '{"event_type":""}'], + ['a numeric event_type', '{"event_type":5}'], + [ + 'a scored event without data', + '{"event_type":"identification.scored","schema_version":"2026-06-01"}', + ], + [ + 'a scored event with array data', + '{"event_type":"identification.scored","schema_version":"2026-06-01","data":[]}', + ], + ])('throws WebhookParseError for %s', async (_label, body) => { + vi.spyOn(console, 'warn').mockImplementation(() => undefined); + expect(() => construct(body)).toThrow(WebhookParseError); + await expect(edge.webhooks.constructEventAsync(body, sign(body), SECRET)).rejects.toThrow( + WebhookParseError, + ); + }); + + it('verifies before parsing', () => { + expect(() => webhooks.constructEvent('{"event_type":', sign('something else'), SECRET)).toThrow( + SignatureVerificationError, + ); + }); +}); + +describe('signature inputs', () => { + const body = loadText('webhook-ping.raw.txt'); + const header = sign(body); + + it('accepts a header array with exactly one value', () => { + expect(webhooks.verifySignature(body, [header], SECRET)).toBe(true); + expect(webhooks.verifySignature(body, [header, header], SECRET)).toBe(false); + expect(webhooks.verifySignature(body, [], SECRET)).toBe(false); + }); + + it('returns false for a missing header', () => { + expect(webhooks.verifySignature(body, null, SECRET)).toBe(false); + expect(webhooks.verifySignature(body, undefined, SECRET)).toBe(false); + expect(webhooks.verifySignature(body, 42 as unknown as string, SECRET)).toBe(false); + }); + + it('requires the lowercase sha256= prefix and a 64-digit hex digest', () => { + expect(webhooks.verifySignature(body, header.replace('sha256=', 'SHA256='), SECRET)).toBe( + false, + ); + expect(webhooks.verifySignature(body, header.replace('sha256=', 'sha256= '), SECRET)).toBe( + false, + ); + expect(webhooks.verifySignature(body, `${header}00`, SECRET)).toBe(false); + expect(parseSignatureHeader(` ${header.toUpperCase().replace('SHA256=', 'sha256=')}\t`)).toBe( + header.slice(7), + ); + }); + + it('checks every secret in a list and ignores empty entries', () => { + expect(webhooks.verifySignature(body, header, ['', 'whsec_other', SECRET])).toBe(true); + expect(webhooks.verifySignature(body, header, ['', ''])).toBe(false); + expect(webhooks.verifySignature(body, header, [])).toBe(false); + expect(webhooks.verifySignature(body, header, [42, SECRET] as unknown as string[])).toBe(true); + expect(secretList(undefined)).toEqual([]); + }); + + it('returns false for a missing secret', () => { + expect(webhooks.verifySignature(body, header, undefined as unknown as string)).toBe(false); + expect(() => webhooks.constructEvent(body, header, '')).toThrow(/No webhook signing secret/); + }); + + it('uses the secret with its whsec_ prefix as UTF-8 text', () => { + const unicodeSecret = 'whsec_тест✓'; + expect(webhooks.verifySignature(body, sign(body, unicodeSecret), unicodeSecret)).toBe(true); + expect(webhooks.verifySignature(body, sign(body, 'whsec_abc'), 'abc')).toBe(false); + }); + + it('refuses a parsed object and explains why', async () => { + const parsed = JSON.parse(body) as unknown as string; + expect(webhooks.verifySignature(parsed, header, SECRET)).toBe(false); + expect(() => webhooks.constructEvent(parsed, header, SECRET)).toThrow(/raw request body/); + await expect(edge.webhooks.verifySignatureAsync(parsed, header, SECRET)).resolves.toBe(false); + }); + + it('accepts typed array views with an offset', () => { + const padded = new Uint8Array(Buffer.from(`xx${body}yy`)); + const view = padded.subarray(2, padded.length - 2); + expect(webhooks.verifySignature(view, header, SECRET)).toBe(true); + const dataView = new DataView(padded.buffer, 2, padded.length - 4); + expect(webhooks.verifySignature(dataView as unknown as Uint8Array, header, SECRET)).toBe(true); + }); + + it('never puts secrets or signatures in error messages', () => { + try { + webhooks.constructEvent(body, sign(body, 'whsec_wrong'), SECRET); + expect.unreachable(); + } catch (error) { + const message = (error as Error).message; + expect(message).not.toContain(SECRET); + expect(message).not.toContain(header.slice(7)); + expect(message).not.toContain(sign(body, 'whsec_wrong').slice(7)); + } + }); + + it('exposes byte helpers that behave as documented', () => { + expect(payloadBytes({})).toBeNull(); + expect(payloadBytes(new ArrayBuffer(2))).toHaveLength(2); + expect(constantTimeEqual(new Uint8Array([1, 2]), new Uint8Array([1, 2]))).toBe(true); + expect(constantTimeEqual(new Uint8Array([1, 2]), new Uint8Array([1, 3]))).toBe(false); + expect(constantTimeEqual(new Uint8Array([1]), new Uint8Array([1, 2]))).toBe(false); + }); +}); diff --git a/tsconfig.json b/tsconfig.json index ad15890..c1c05ac 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,14 +1,23 @@ { "compilerOptions": { - "target": "ES2020", + "target": "ES2022", + "lib": ["ES2022"], + "types": ["node"], "module": "ESNext", "moduleResolution": "Bundler", - "lib": ["ES2020", "DOM"], - "declaration": true, + "resolveJsonModule": true, "strict": true, - "esModuleInterop": true, + "exactOptionalPropertyTypes": true, + "noUncheckedIndexedAccess": true, + "noImplicitOverride": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + "useUnknownInCatchVariables": true, + "verbatimModuleSyntax": true, + "isolatedModules": true, + "forceConsistentCasingInFileNames": true, "skipLibCheck": true, - "outDir": "dist" + "noEmit": true }, - "include": ["src"] + "include": ["src", "test", "vitest.config.ts", "tsup.config.ts"] } diff --git a/tsup.config.ts b/tsup.config.ts new file mode 100644 index 0000000..56dc6e3 --- /dev/null +++ b/tsup.config.ts @@ -0,0 +1,21 @@ +import { defineConfig } from 'tsup'; + +// Two entries with the same public API: +// - index: Node.js and Bun. Synchronous webhook helpers use node:crypto. +// - edge: workers, edge runtimes, Deno and browsers. Never imports node:crypto. +export default defineConfig({ + entry: { + index: 'src/index.ts', + edge: 'src/edge.ts', + }, + format: ['esm', 'cjs'], + // Both entries export the same names and signatures, so one set of declarations serves both. + dts: { entry: { index: 'src/index.ts' } }, + sourcemap: true, + clean: true, + splitting: false, + treeshake: true, + target: ['node18', 'es2022'], + platform: 'node', + removeNodeProtocol: false, +}); diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..2c0b576 --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,21 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + include: ['test/**/*.test.ts'], + environment: 'node', + restoreMocks: true, + unstubGlobals: true, + coverage: { + provider: 'v8', + include: ['src/**/*.ts'], + reporter: ['text', 'json-summary'], + thresholds: { + lines: 90, + statements: 90, + functions: 90, + branches: 90, + }, + }, + }, +});