From a409406c3bbae43af09252d8d8f4fe2dfd1db3d0 Mon Sep 17 00:00:00 2001 From: Devin Dynamic Date: Wed, 15 Jul 2026 13:36:48 +0000 Subject: [PATCH] feat(examples): add Transaction Review webhook reference server Co-Authored-By: ndavis --- .../.env.example | 31 + .../.gitignore | 3 + .../README.md | 143 +++ .../package.json | 22 + .../pnpm-lock.yaml | 999 ++++++++++++++++++ .../src/keygen.ts | 34 + .../src/server.ts | 588 +++++++++++ .../tsconfig.json | 15 + 8 files changed, 1835 insertions(+) create mode 100644 examples/nodejs-transaction-review-webhook/.env.example create mode 100644 examples/nodejs-transaction-review-webhook/.gitignore create mode 100644 examples/nodejs-transaction-review-webhook/README.md create mode 100644 examples/nodejs-transaction-review-webhook/package.json create mode 100644 examples/nodejs-transaction-review-webhook/pnpm-lock.yaml create mode 100644 examples/nodejs-transaction-review-webhook/src/keygen.ts create mode 100644 examples/nodejs-transaction-review-webhook/src/server.ts create mode 100644 examples/nodejs-transaction-review-webhook/tsconfig.json diff --git a/examples/nodejs-transaction-review-webhook/.env.example b/examples/nodejs-transaction-review-webhook/.env.example new file mode 100644 index 0000000..b0b74c8 --- /dev/null +++ b/examples/nodejs-transaction-review-webhook/.env.example @@ -0,0 +1,31 @@ +# Port to listen on. ngrok this with `ngrok http $PORT`. +PORT=4040 + +# Shared secret used by Dynamic to HMAC-sign incoming requests. Must match +# the "Webhook Secret" value saved in the Dynamic dashboard. Leave empty to +# accept unsigned requests (NOT recommended). +WEBHOOK_SECRET= + +# Path to your Ed25519 private key in PEM format. The matching public key +# (printed by `npm run keygen`) is what you paste into the dashboard's +# "Response Verification Key" field. Leave empty to send unsigned responses. +WEBHOOK_PRIVATE_KEY_PATH=./private.pem + +# Alternative to WEBHOOK_PRIVATE_KEY_PATH for hosted/containerized deploys: +# the base64-encoded PEM private key, so no key file has to be mounted on disk. +# Takes precedence over WEBHOOK_PRIVATE_KEY_PATH when set. Encode with: +# base64 -w0 private.pem +WEBHOOK_PRIVATE_KEY_PEM= + +# Default decision mode. Override per-request via ?mode=... query string. +# allow -> { proceed: true } +# deny -> { proceed: false, reason: } +# slow -> sleep SLOW_MS then allow (exercises your DENY/ALLOW failure policy) +# crash -> hang up the connection without responding +MODE=allow + +# Reason returned with denials (any string). +DENY_REASON=Denied by example webhook + +# Milliseconds to sleep before responding when MODE=slow. +SLOW_MS=10000 diff --git a/examples/nodejs-transaction-review-webhook/.gitignore b/examples/nodejs-transaction-review-webhook/.gitignore new file mode 100644 index 0000000..11be4a8 --- /dev/null +++ b/examples/nodejs-transaction-review-webhook/.gitignore @@ -0,0 +1,3 @@ +node_modules/ +.env +*.pem diff --git a/examples/nodejs-transaction-review-webhook/README.md b/examples/nodejs-transaction-review-webhook/README.md new file mode 100644 index 0000000..16509d9 --- /dev/null +++ b/examples/nodejs-transaction-review-webhook/README.md @@ -0,0 +1,143 @@ +# Transaction Review webhook — example server + +A self-contained Express app that implements Dynamic's [Transaction Review +webhook contract](https://docs.dynamic.xyz/overview/wallets/embedded-wallets/mpc/transaction-review) +end-to-end. Use it to validate the feature locally against your Dynamic sandbox +environment before wiring up your real backend. + +What it does: + +- Listens on `POST /webhook`. +- Verifies the `x-dynamic-signature` HMAC-SHA256 request header against your + shared secret. Requests with a missing or wrong signature get a `401` and + `{ proceed: false, reason: "Invalid signature" }`. +- Optionally signs every response with Ed25519 and sets the + `x-dynamic-response-signature` header so Dynamic's response verification path + can be exercised. +- Exposes four hot-switchable decision modes via the `?mode=` query string so + you can flip behavior without restarting the server: + + | mode | behavior | + | ------- | ------------------------------------------------------------------- | + | `allow` | `{ proceed: true }` | + | `deny` | `{ proceed: false, reason: "" }` | + | `slow` | Sleep for `SLOW_MS` ms before responding (exercises failure policy) | + | `crash` | Tear down the TCP socket without writing a response | + + The default mode comes from `MODE` in `.env`. Anything else falls back to + the default. + +## Setup + +> Requires Node 18+ and pnpm. Tested on Node 22. + +```bash +cd examples/nodejs-transaction-review-webhook +pnpm install +cp .env.example .env +pnpm keygen # writes private.pem + public.pem, prints the public key +pnpm dev # starts the server with hot-reload on $PORT (default 4040) +``` + +`pnpm keygen` will refuse to overwrite existing keys — delete `private.pem` +and `public.pem` first if you really want to rotate. + +## Expose it to Dynamic + +Dynamic needs to reach your local server, so tunnel it: + +```bash +ngrok http 4040 +``` + +Copy the `https://.ngrok-free.app` URL ngrok prints — that's your +**Webhook URL**. + +## Configure in the dashboard + +In the Dynamic dashboard for your sandbox environment, go to +**Wallets → Transaction Review** and fill in: + +| Field | Value | +| ----------------------------- | -------------------------------------------------- | +| **Webhook URL** | `https://.ngrok-free.app/webhook` | +| **Webhook Secret** | Same string you put in `WEBHOOK_SECRET` in `.env` | +| **Response Verification Key** | Paste the contents of `public.pem` (printed above) | +| **Failure Policy** | `DENY` (default) — recommended for testing | + +Save. You're now ready to drive transactions through the SDK. + +## Fastest validation — the dashboard "Send test" button + +You don't need to drive a real SDK transaction. Once the URL/secret/public +key are in the form, the side panel exposes a **Test webhook** card with a +scenario dropdown (`Sign message`, `EVM transaction`, `EVM token transfer`, +`EVM user operation`, `EVM typed data`, `Solana transaction`) and a **Send +test** button. Clicking it drives a synthetic payload through the same HMAC +signing, Ed25519 verification, timeout, and failure-policy machinery as the +live signing path — but with no events, no DB writes, and no signing +operation involved. + +The result panel shows the decision badge (`Approved` / `Denied` / `Failure +policy applied`), latency, HTTP status, signature verification state, and +collapsible request/response bodies. Flip `?mode=allow|deny|slow|crash` in +the **Webhook URL** field and press _Send test_ again to confirm each +scenario without ever leaving the dashboard. + +## What to verify with a real signing operation + +Trigger any signing operation from the SDK (`signMessage`, EVM tx, Solana tx, +ERC-4337 UserOp — doesn't matter; the webhook fires for all of them). For +each scenario flip the mode and re-trigger: + +1. **Default approve** (`MODE=allow` or `?mode=allow`) + + - Webhook logs the incoming request with the `requestId`, `walletId`, `chain`. + - Signing completes; SDK gets a signature. + - Event `waas.transaction.review.approved` is published. + +2. **Deny with reason** (`?mode=deny`) + + - Webhook responds `{ proceed: false, reason: "Denied by example webhook" }`. + - SDK surfaces a `TransactionReviewDenied` error whose message contains the + reason. + - Event `waas.transaction.review.denied` is published. + +3. **Response signing** + + - With `WEBHOOK_PRIVATE_KEY_PATH` set and the matching public key saved in + the dashboard, Dynamic accepts the response. Try corrupting the public key + in the dashboard (e.g. change one base64 char) — Dynamic should fall back + to your failure policy and reject the response signature. + +4. **Failure policy** (`?mode=slow` with `SLOW_MS` > the configured timeout) + + - Configured `DENY`: signing is blocked with a transaction-review-unreachable + error. + - Re-save the dashboard config with `ALLOW`: signing proceeds despite the + timeout. Same `?mode=slow` exercises both. + +5. **Hard crash** (`?mode=crash`) + - Webhook tears down the TCP connection. Dynamic treats this like any other + transport failure and applies the failure policy. + +## Sanity check + +`GET http://localhost:$PORT/health` is a minimal liveness probe. Your effective +config (mode, whether HMAC verification / response signing are enabled) is +printed to the console on startup — check there to confirm your `.env` was +picked up, rather than exposing it over HTTP. + +```bash +curl -s http://localhost:4040/health | jq +# { +# "status": "ok" +# } +``` + +## Not production code + +This is a reference server. It can log request bodies to stdout (opt-in via +`LOG_BODIES=true`), has no persistence, and doesn't validate payload shapes +beyond what's needed to demonstrate the contract. Use it to verify the wiring; +build your real webhook against the [Transaction Review documentation](https://docs.dynamic.xyz/overview/wallets/embedded-wallets/mpc/transaction-review). diff --git a/examples/nodejs-transaction-review-webhook/package.json b/examples/nodejs-transaction-review-webhook/package.json new file mode 100644 index 0000000..27f08f2 --- /dev/null +++ b/examples/nodejs-transaction-review-webhook/package.json @@ -0,0 +1,22 @@ +{ + "name": "nodejs-transaction-review-webhook", + "version": "1.0.0", + "private": true, + "description": "Reference Express server for Dynamic's Transaction Review webhooks. Verifies HMAC request signatures, optionally Ed25519-signs responses, and exposes hot-switchable approve/deny/slow/crash modes for end-to-end testing.", + "scripts": { + "dev": "tsx watch src/server.ts", + "start": "tsx src/server.ts", + "keygen": "tsx src/keygen.ts" + }, + "dependencies": { + "dotenv": "16.6.1", + "express": "4.21.2" + }, + "devDependencies": { + "@types/express": "4.17.23", + "@types/node": "22.10.5", + "tsx": "4.22.4", + "typescript": "5.4.4" + }, + "packageManager": "pnpm@11.13.0+sha512.88d94724d8f2e6c186744a5584c6e59ecac869ec7ba15e9cb4cd628e8dc7066820b2481d8ee3b51ea8da323a7378068aa58c556a3720d32b7c20a051d088363a" +} diff --git a/examples/nodejs-transaction-review-webhook/pnpm-lock.yaml b/examples/nodejs-transaction-review-webhook/pnpm-lock.yaml new file mode 100644 index 0000000..cdca8c6 --- /dev/null +++ b/examples/nodejs-transaction-review-webhook/pnpm-lock.yaml @@ -0,0 +1,999 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + dotenv: + specifier: 16.6.1 + version: 16.6.1 + express: + specifier: 4.21.2 + version: 4.21.2 + devDependencies: + '@types/express': + specifier: 4.17.23 + version: 4.17.23 + '@types/node': + specifier: 22.10.5 + version: 22.10.5 + tsx: + specifier: 4.22.4 + version: 4.22.4 + typescript: + specifier: 5.4.4 + version: 5.4.4 + +packages: + + '@esbuild/aix-ppc64@0.28.1': + resolution: {integrity: sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.28.1': + resolution: {integrity: sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.28.1': + resolution: {integrity: sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.28.1': + resolution: {integrity: sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.28.1': + resolution: {integrity: sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.28.1': + resolution: {integrity: sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.28.1': + resolution: {integrity: sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.28.1': + resolution: {integrity: sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.28.1': + resolution: {integrity: sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.28.1': + resolution: {integrity: sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.28.1': + resolution: {integrity: sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.28.1': + resolution: {integrity: sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.28.1': + resolution: {integrity: sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.28.1': + resolution: {integrity: sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.28.1': + resolution: {integrity: sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.28.1': + resolution: {integrity: sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.28.1': + resolution: {integrity: sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.28.1': + resolution: {integrity: sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.28.1': + resolution: {integrity: sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.28.1': + resolution: {integrity: sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.28.1': + resolution: {integrity: sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.28.1': + resolution: {integrity: sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.28.1': + resolution: {integrity: sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.28.1': + resolution: {integrity: sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.28.1': + resolution: {integrity: sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.28.1': + resolution: {integrity: sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@types/body-parser@1.19.6': + resolution: {integrity: sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==} + + '@types/connect@3.4.38': + resolution: {integrity: sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==} + + '@types/express-serve-static-core@4.19.8': + resolution: {integrity: sha512-02S5fmqeoKzVZCHPZid4b8JH2eM5HzQLZWN2FohQEy/0eXTq8VXZfSN6Pcr3F6N9R/vNrj7cpgbhjie6m/1tCA==} + + '@types/express@4.17.23': + resolution: {integrity: sha512-Crp6WY9aTYP3qPi2wGDo9iUe/rceX01UMhnF1jmwDcKCFM6cx7YhGP/Mpr3y9AASpfHixIG0E6azCcL5OcDHsQ==} + + '@types/http-errors@2.0.5': + resolution: {integrity: sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==} + + '@types/node@22.10.5': + resolution: {integrity: sha512-F8Q+SeGimwOo86fiovQh8qiXfFEh2/ocYv7tU5pJ3EXMSSxk1Joj5wefpFK2fHTf/N6HKGSxIDBT9f3gCxXPkQ==} + + '@types/qs@6.15.1': + resolution: {integrity: sha512-GZHUBZR9hckSUhrxmp1nG6NwdpM9fCunJwyThLW1X3AyHgd9IlHb6VANpQQqDr2o/qQp6McZ3y/IA2rVzKzSbw==} + + '@types/range-parser@1.2.7': + resolution: {integrity: sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==} + + '@types/send@1.2.1': + resolution: {integrity: sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==} + + '@types/serve-static@2.2.0': + resolution: {integrity: sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ==} + + accepts@1.3.8: + resolution: {integrity: sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==} + engines: {node: '>= 0.6'} + + array-flatten@1.1.1: + resolution: {integrity: sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==} + + async-function@1.0.0: + resolution: {integrity: sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==} + engines: {node: '>= 0.4'} + + async-generator-function@1.0.0: + resolution: {integrity: sha512-+NAXNqgCrB95ya4Sr66i1CL2hqLVckAk7xwRYWdcm39/ELQ6YNn1aw5r0bdQtqNZgQpEWzc5yc/igXc7aL5SLA==} + engines: {node: '>= 0.4'} + + body-parser@1.20.3: + resolution: {integrity: sha512-7rAxByjUMqQ3/bHJy7D6OGXvx/MMc4IqBn/X0fcM1QUcAItpZrBEYhWGem+tzXH90c+G01ypMcYJBO9Y30203g==} + engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16} + + bytes@3.1.2: + resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==} + engines: {node: '>= 0.8'} + + call-bind-apply-helpers@1.0.2: + resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} + engines: {node: '>= 0.4'} + + call-bound@1.0.4: + resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==} + engines: {node: '>= 0.4'} + + content-disposition@0.5.4: + resolution: {integrity: sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==} + engines: {node: '>= 0.6'} + + content-type@1.0.5: + resolution: {integrity: sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==} + engines: {node: '>= 0.6'} + + cookie-signature@1.0.6: + resolution: {integrity: sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==} + + cookie@0.7.1: + resolution: {integrity: sha512-6DnInpx7SJ2AK3+CTUE/ZM0vWTUboZCegxhC2xiIydHR9jNuTAASBrfEpHhiGOZw/nX51bHt6YQl8jsGo4y/0w==} + engines: {node: '>= 0.6'} + + debug@2.6.9: + resolution: {integrity: sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + depd@2.0.0: + resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} + engines: {node: '>= 0.8'} + + destroy@1.2.0: + resolution: {integrity: sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==} + engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16} + + dotenv@16.6.1: + resolution: {integrity: sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==} + engines: {node: '>=12'} + + dunder-proto@1.0.1: + resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} + engines: {node: '>= 0.4'} + + ee-first@1.1.1: + resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} + + encodeurl@1.0.2: + resolution: {integrity: sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==} + engines: {node: '>= 0.8'} + + encodeurl@2.0.0: + resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==} + engines: {node: '>= 0.8'} + + es-define-property@1.0.1: + resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} + engines: {node: '>= 0.4'} + + es-errors@1.3.0: + resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} + engines: {node: '>= 0.4'} + + es-object-atoms@1.1.2: + resolution: {integrity: sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==} + engines: {node: '>= 0.4'} + + esbuild@0.28.1: + resolution: {integrity: sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==} + engines: {node: '>=18'} + hasBin: true + + escape-html@1.0.3: + resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==} + + etag@1.8.1: + resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==} + engines: {node: '>= 0.6'} + + express@4.21.2: + resolution: {integrity: sha512-28HqgMZAmih1Czt9ny7qr6ek2qddF4FclbMzwhCREB6OFfH+rXAnuNCwo1/wFvrtbgsQDb4kSbX9de9lFbrXnA==} + engines: {node: '>= 0.10.0'} + + finalhandler@1.3.1: + resolution: {integrity: sha512-6BN9trH7bp3qvnrRyzsBz+g3lZxTNZTbVO2EV1CS0WIcDbawYVdYvGflME/9QP0h0pYlCDBCTjYa9nZzMDpyxQ==} + engines: {node: '>= 0.8'} + + forwarded@0.2.0: + resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==} + engines: {node: '>= 0.6'} + + fresh@0.5.2: + resolution: {integrity: sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==} + engines: {node: '>= 0.6'} + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + function-bind@1.1.2: + resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + + generator-function@2.0.1: + resolution: {integrity: sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==} + engines: {node: '>= 0.4'} + + get-intrinsic@1.3.1: + resolution: {integrity: sha512-fk1ZVEeOX9hVZ6QzoBNEC55+Ucqg4sTVwrVuigZhuRPESVFpMyXnd3sbXvPOwp7Y9riVyANiqhEuRF0G1aVSeQ==} + engines: {node: '>= 0.4'} + + get-proto@1.0.1: + resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} + engines: {node: '>= 0.4'} + + gopd@1.2.0: + resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} + engines: {node: '>= 0.4'} + + has-symbols@1.1.0: + resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} + engines: {node: '>= 0.4'} + + hasown@2.0.4: + resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==} + engines: {node: '>= 0.4'} + + http-errors@2.0.0: + resolution: {integrity: sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==} + engines: {node: '>= 0.8'} + + iconv-lite@0.4.24: + resolution: {integrity: sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==} + engines: {node: '>=0.10.0'} + + inherits@2.0.4: + resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + + ipaddr.js@1.9.1: + resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==} + engines: {node: '>= 0.10'} + + math-intrinsics@1.1.0: + resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} + engines: {node: '>= 0.4'} + + media-typer@0.3.0: + resolution: {integrity: sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==} + engines: {node: '>= 0.6'} + + merge-descriptors@1.0.3: + resolution: {integrity: sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==} + + methods@1.1.2: + resolution: {integrity: sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==} + engines: {node: '>= 0.6'} + + mime-db@1.52.0: + resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} + engines: {node: '>= 0.6'} + + mime-types@2.1.35: + resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} + engines: {node: '>= 0.6'} + + mime@1.6.0: + resolution: {integrity: sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==} + engines: {node: '>=4'} + hasBin: true + + ms@2.0.0: + resolution: {integrity: sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + negotiator@0.6.3: + resolution: {integrity: sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==} + engines: {node: '>= 0.6'} + + object-inspect@1.13.4: + resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} + engines: {node: '>= 0.4'} + + on-finished@2.4.1: + resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==} + engines: {node: '>= 0.8'} + + parseurl@1.3.3: + resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} + engines: {node: '>= 0.8'} + + path-to-regexp@0.1.12: + resolution: {integrity: sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==} + + proxy-addr@2.0.7: + resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} + engines: {node: '>= 0.10'} + + qs@6.13.0: + resolution: {integrity: sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg==} + engines: {node: '>=0.6'} + + range-parser@1.2.1: + resolution: {integrity: sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==} + engines: {node: '>= 0.6'} + + raw-body@2.5.2: + resolution: {integrity: sha512-8zGqypfENjCIqGhgXToC8aB2r7YrBX+AQAfIPs/Mlk+BtPTztOvTS01NRW/3Eh60J+a48lt8qsCzirQ6loCVfA==} + engines: {node: '>= 0.8'} + + safe-buffer@5.2.1: + resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} + + safer-buffer@2.1.2: + resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + + send@0.19.0: + resolution: {integrity: sha512-dW41u5VfLXu8SJh5bwRmyYUbAoSB3c9uQh6L8h/KtsFREPWpbX1lrljJo186Jc4nmci/sGUZ9a0a0J2zgfq2hw==} + engines: {node: '>= 0.8.0'} + + serve-static@1.16.2: + resolution: {integrity: sha512-VqpjJZKadQB/PEbEwvFdO43Ax5dFBZ2UECszz8bQ7pi7wt//PWe1P6MN7eCnjsatYtBT6EuiClbjSWP2WrIoTw==} + engines: {node: '>= 0.8.0'} + + setprototypeof@1.2.0: + resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} + + side-channel-list@1.0.1: + resolution: {integrity: sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==} + engines: {node: '>= 0.4'} + + side-channel-map@1.0.1: + resolution: {integrity: sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==} + engines: {node: '>= 0.4'} + + side-channel-weakmap@1.0.2: + resolution: {integrity: sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==} + engines: {node: '>= 0.4'} + + side-channel@1.1.1: + resolution: {integrity: sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==} + engines: {node: '>= 0.4'} + + statuses@2.0.1: + resolution: {integrity: sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==} + engines: {node: '>= 0.8'} + + toidentifier@1.0.1: + resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} + engines: {node: '>=0.6'} + + tsx@4.22.4: + resolution: {integrity: sha512-X8EX+XV4QR5xCsrgxaED954zTDfY8KqlDtskKEL0cHhyS/P8b4IFOvGDQpsC9Q1XnLq915wEfwwY/zzskCtmhg==} + engines: {node: '>=18.0.0'} + hasBin: true + + type-is@1.6.18: + resolution: {integrity: sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==} + engines: {node: '>= 0.6'} + + typescript@5.4.4: + resolution: {integrity: sha512-dGE2Vv8cpVvw28v8HCPqyb08EzbBURxDpuhJvTrusShUfGnhHBafDsLdS1EhhxyL6BJQE+2cT3dDPAv+MQ6oLw==} + engines: {node: '>=14.17'} + hasBin: true + + undici-types@6.20.0: + resolution: {integrity: sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg==} + + unpipe@1.0.0: + resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==} + engines: {node: '>= 0.8'} + + utils-merge@1.0.1: + resolution: {integrity: sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==} + engines: {node: '>= 0.4.0'} + + vary@1.1.2: + resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} + engines: {node: '>= 0.8'} + +snapshots: + + '@esbuild/aix-ppc64@0.28.1': + optional: true + + '@esbuild/android-arm64@0.28.1': + optional: true + + '@esbuild/android-arm@0.28.1': + optional: true + + '@esbuild/android-x64@0.28.1': + optional: true + + '@esbuild/darwin-arm64@0.28.1': + optional: true + + '@esbuild/darwin-x64@0.28.1': + optional: true + + '@esbuild/freebsd-arm64@0.28.1': + optional: true + + '@esbuild/freebsd-x64@0.28.1': + optional: true + + '@esbuild/linux-arm64@0.28.1': + optional: true + + '@esbuild/linux-arm@0.28.1': + optional: true + + '@esbuild/linux-ia32@0.28.1': + optional: true + + '@esbuild/linux-loong64@0.28.1': + optional: true + + '@esbuild/linux-mips64el@0.28.1': + optional: true + + '@esbuild/linux-ppc64@0.28.1': + optional: true + + '@esbuild/linux-riscv64@0.28.1': + optional: true + + '@esbuild/linux-s390x@0.28.1': + optional: true + + '@esbuild/linux-x64@0.28.1': + optional: true + + '@esbuild/netbsd-arm64@0.28.1': + optional: true + + '@esbuild/netbsd-x64@0.28.1': + optional: true + + '@esbuild/openbsd-arm64@0.28.1': + optional: true + + '@esbuild/openbsd-x64@0.28.1': + optional: true + + '@esbuild/openharmony-arm64@0.28.1': + optional: true + + '@esbuild/sunos-x64@0.28.1': + optional: true + + '@esbuild/win32-arm64@0.28.1': + optional: true + + '@esbuild/win32-ia32@0.28.1': + optional: true + + '@esbuild/win32-x64@0.28.1': + optional: true + + '@types/body-parser@1.19.6': + dependencies: + '@types/connect': 3.4.38 + '@types/node': 22.10.5 + + '@types/connect@3.4.38': + dependencies: + '@types/node': 22.10.5 + + '@types/express-serve-static-core@4.19.8': + dependencies: + '@types/node': 22.10.5 + '@types/qs': 6.15.1 + '@types/range-parser': 1.2.7 + '@types/send': 1.2.1 + + '@types/express@4.17.23': + dependencies: + '@types/body-parser': 1.19.6 + '@types/express-serve-static-core': 4.19.8 + '@types/qs': 6.15.1 + '@types/serve-static': 2.2.0 + + '@types/http-errors@2.0.5': {} + + '@types/node@22.10.5': + dependencies: + undici-types: 6.20.0 + + '@types/qs@6.15.1': {} + + '@types/range-parser@1.2.7': {} + + '@types/send@1.2.1': + dependencies: + '@types/node': 22.10.5 + + '@types/serve-static@2.2.0': + dependencies: + '@types/http-errors': 2.0.5 + '@types/node': 22.10.5 + + accepts@1.3.8: + dependencies: + mime-types: 2.1.35 + negotiator: 0.6.3 + + array-flatten@1.1.1: {} + + async-function@1.0.0: {} + + async-generator-function@1.0.0: {} + + body-parser@1.20.3: + dependencies: + bytes: 3.1.2 + content-type: 1.0.5 + debug: 2.6.9 + depd: 2.0.0 + destroy: 1.2.0 + http-errors: 2.0.0 + iconv-lite: 0.4.24 + on-finished: 2.4.1 + qs: 6.13.0 + raw-body: 2.5.2 + type-is: 1.6.18 + unpipe: 1.0.0 + transitivePeerDependencies: + - supports-color + + bytes@3.1.2: {} + + call-bind-apply-helpers@1.0.2: + dependencies: + es-errors: 1.3.0 + function-bind: 1.1.2 + + call-bound@1.0.4: + dependencies: + call-bind-apply-helpers: 1.0.2 + get-intrinsic: 1.3.1 + + content-disposition@0.5.4: + dependencies: + safe-buffer: 5.2.1 + + content-type@1.0.5: {} + + cookie-signature@1.0.6: {} + + cookie@0.7.1: {} + + debug@2.6.9: + dependencies: + ms: 2.0.0 + + depd@2.0.0: {} + + destroy@1.2.0: {} + + dotenv@16.6.1: {} + + dunder-proto@1.0.1: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-errors: 1.3.0 + gopd: 1.2.0 + + ee-first@1.1.1: {} + + encodeurl@1.0.2: {} + + encodeurl@2.0.0: {} + + es-define-property@1.0.1: {} + + es-errors@1.3.0: {} + + es-object-atoms@1.1.2: + dependencies: + es-errors: 1.3.0 + + esbuild@0.28.1: + 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 + + escape-html@1.0.3: {} + + etag@1.8.1: {} + + express@4.21.2: + dependencies: + accepts: 1.3.8 + array-flatten: 1.1.1 + body-parser: 1.20.3 + content-disposition: 0.5.4 + content-type: 1.0.5 + cookie: 0.7.1 + cookie-signature: 1.0.6 + debug: 2.6.9 + depd: 2.0.0 + encodeurl: 2.0.0 + escape-html: 1.0.3 + etag: 1.8.1 + finalhandler: 1.3.1 + fresh: 0.5.2 + http-errors: 2.0.0 + merge-descriptors: 1.0.3 + methods: 1.1.2 + on-finished: 2.4.1 + parseurl: 1.3.3 + path-to-regexp: 0.1.12 + proxy-addr: 2.0.7 + qs: 6.13.0 + range-parser: 1.2.1 + safe-buffer: 5.2.1 + send: 0.19.0 + serve-static: 1.16.2 + setprototypeof: 1.2.0 + statuses: 2.0.1 + type-is: 1.6.18 + utils-merge: 1.0.1 + vary: 1.1.2 + transitivePeerDependencies: + - supports-color + + finalhandler@1.3.1: + dependencies: + debug: 2.6.9 + encodeurl: 2.0.0 + escape-html: 1.0.3 + on-finished: 2.4.1 + parseurl: 1.3.3 + statuses: 2.0.1 + unpipe: 1.0.0 + transitivePeerDependencies: + - supports-color + + forwarded@0.2.0: {} + + fresh@0.5.2: {} + + fsevents@2.3.3: + optional: true + + function-bind@1.1.2: {} + + generator-function@2.0.1: {} + + get-intrinsic@1.3.1: + dependencies: + async-function: 1.0.0 + async-generator-function: 1.0.0 + call-bind-apply-helpers: 1.0.2 + es-define-property: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.2 + function-bind: 1.1.2 + generator-function: 2.0.1 + get-proto: 1.0.1 + gopd: 1.2.0 + has-symbols: 1.1.0 + hasown: 2.0.4 + math-intrinsics: 1.1.0 + + get-proto@1.0.1: + dependencies: + dunder-proto: 1.0.1 + es-object-atoms: 1.1.2 + + gopd@1.2.0: {} + + has-symbols@1.1.0: {} + + hasown@2.0.4: + dependencies: + function-bind: 1.1.2 + + http-errors@2.0.0: + dependencies: + depd: 2.0.0 + inherits: 2.0.4 + setprototypeof: 1.2.0 + statuses: 2.0.1 + toidentifier: 1.0.1 + + iconv-lite@0.4.24: + dependencies: + safer-buffer: 2.1.2 + + inherits@2.0.4: {} + + ipaddr.js@1.9.1: {} + + math-intrinsics@1.1.0: {} + + media-typer@0.3.0: {} + + merge-descriptors@1.0.3: {} + + methods@1.1.2: {} + + mime-db@1.52.0: {} + + mime-types@2.1.35: + dependencies: + mime-db: 1.52.0 + + mime@1.6.0: {} + + ms@2.0.0: {} + + ms@2.1.3: {} + + negotiator@0.6.3: {} + + object-inspect@1.13.4: {} + + on-finished@2.4.1: + dependencies: + ee-first: 1.1.1 + + parseurl@1.3.3: {} + + path-to-regexp@0.1.12: {} + + proxy-addr@2.0.7: + dependencies: + forwarded: 0.2.0 + ipaddr.js: 1.9.1 + + qs@6.13.0: + dependencies: + side-channel: 1.1.1 + + range-parser@1.2.1: {} + + raw-body@2.5.2: + dependencies: + bytes: 3.1.2 + http-errors: 2.0.0 + iconv-lite: 0.4.24 + unpipe: 1.0.0 + + safe-buffer@5.2.1: {} + + safer-buffer@2.1.2: {} + + send@0.19.0: + dependencies: + debug: 2.6.9 + depd: 2.0.0 + destroy: 1.2.0 + encodeurl: 1.0.2 + escape-html: 1.0.3 + etag: 1.8.1 + fresh: 0.5.2 + http-errors: 2.0.0 + mime: 1.6.0 + ms: 2.1.3 + on-finished: 2.4.1 + range-parser: 1.2.1 + statuses: 2.0.1 + transitivePeerDependencies: + - supports-color + + serve-static@1.16.2: + dependencies: + encodeurl: 2.0.0 + escape-html: 1.0.3 + parseurl: 1.3.3 + send: 0.19.0 + transitivePeerDependencies: + - supports-color + + setprototypeof@1.2.0: {} + + side-channel-list@1.0.1: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + + side-channel-map@1.0.1: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.1 + object-inspect: 1.13.4 + + side-channel-weakmap@1.0.2: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.1 + object-inspect: 1.13.4 + side-channel-map: 1.0.1 + + side-channel@1.1.1: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + side-channel-list: 1.0.1 + side-channel-map: 1.0.1 + side-channel-weakmap: 1.0.2 + + statuses@2.0.1: {} + + toidentifier@1.0.1: {} + + tsx@4.22.4: + dependencies: + esbuild: 0.28.1 + optionalDependencies: + fsevents: 2.3.3 + + type-is@1.6.18: + dependencies: + media-typer: 0.3.0 + mime-types: 2.1.35 + + typescript@5.4.4: {} + + undici-types@6.20.0: {} + + unpipe@1.0.0: {} + + utils-merge@1.0.1: {} + + vary@1.1.2: {} diff --git a/examples/nodejs-transaction-review-webhook/src/keygen.ts b/examples/nodejs-transaction-review-webhook/src/keygen.ts new file mode 100644 index 0000000..7115709 --- /dev/null +++ b/examples/nodejs-transaction-review-webhook/src/keygen.ts @@ -0,0 +1,34 @@ +import { generateKeyPairSync } from 'node:crypto'; +import { existsSync, writeFileSync } from 'node:fs'; +import { resolve } from 'node:path'; + +const privatePath = resolve(process.cwd(), 'private.pem'); +const publicPath = resolve(process.cwd(), 'public.pem'); + +if (existsSync(privatePath) || existsSync(publicPath)) { + console.error( + `Refusing to overwrite existing key files at ${privatePath} / ${publicPath}.`, + ); + console.error('Delete them first if you really want to regenerate.'); + process.exit(1); +} + +const { privateKey, publicKey } = generateKeyPairSync('ed25519'); + +const privatePem = privateKey.export({ + format: 'pem', + type: 'pkcs8', +}) as string; +const publicPem = publicKey.export({ format: 'pem', type: 'spki' }) as string; + +writeFileSync(privatePath, privatePem, { mode: 0o600 }); +writeFileSync(publicPath, publicPem, { mode: 0o644 }); + +console.log(`Wrote ${privatePath}`); +console.log(`Wrote ${publicPath}`); +console.log(''); +console.log('Paste the following into the Dynamic dashboard'); +console.log(' -> Wallets -> Transaction Review'); +console.log(' -> Response Verification Key:'); +console.log(''); +console.log(publicPem); diff --git a/examples/nodejs-transaction-review-webhook/src/server.ts b/examples/nodejs-transaction-review-webhook/src/server.ts new file mode 100644 index 0000000..247ac0f --- /dev/null +++ b/examples/nodejs-transaction-review-webhook/src/server.ts @@ -0,0 +1,588 @@ +import 'dotenv/config'; + +import { + createHmac, + createPrivateKey, + createPublicKey, + sign as cryptoSign, + timingSafeEqual, +} from 'node:crypto'; +import { existsSync, readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; + +import express, { + type NextFunction, + type Request, + type Response, +} from 'express'; + +type Mode = 'allow' | 'deny' | 'slow' | 'crash'; +const SUPPORTED_MODES: ReadonlyArray = ['allow', 'deny', 'slow', 'crash']; + +const PORT = Number(process.env.PORT ?? 4040); +const WEBHOOK_SECRET = process.env.WEBHOOK_SECRET ?? ''; + +// Preferred in hosted/containerized deploys: pass the PEM directly as a +// base64-encoded env var so there's no key file on disk to mount or leak. +// Takes precedence over the file path below when set. +const PRIVATE_KEY_PEM_B64 = process.env.WEBHOOK_PRIVATE_KEY_PEM; + +// If WEBHOOK_PRIVATE_KEY_PATH isn't set, fall back to ./private.pem when it +// exists. Most local dev setups run `npm run keygen` (which writes +// private.pem in cwd) and then expect signing to "just work" without a .env +// file. Set WEBHOOK_PRIVATE_KEY_PATH= explicitly (empty) to opt out. +const AUTO_KEY_PATH = './private.pem'; +const PRIVATE_KEY_PATH = (() => { + const fromEnv = process.env.WEBHOOK_PRIVATE_KEY_PATH; + if (fromEnv !== undefined) return fromEnv; + return existsSync(resolve(process.cwd(), AUTO_KEY_PATH)) ? AUTO_KEY_PATH : ''; +})(); +const PRIVATE_KEY_AUTO_DETECTED = + process.env.WEBHOOK_PRIVATE_KEY_PATH === undefined && PRIVATE_KEY_PATH !== ''; + +const RAW_MODE = (process.env.MODE ?? 'allow').toLowerCase(); +const IS_KNOWN_MODE = (SUPPORTED_MODES as readonly string[]).includes(RAW_MODE); +if (!IS_KNOWN_MODE) { + console.warn( + `[CONFIG] Unknown MODE="${RAW_MODE}" — expected one of ${SUPPORTED_MODES.join( + ', ', + )}. Falling back to "allow".`, + ); +} +const DEFAULT_MODE: Mode = IS_KNOWN_MODE ? (RAW_MODE as Mode) : 'allow'; +const DENY_REASON = process.env.DENY_REASON ?? 'Denied by example webhook'; +const SLOW_MS = Number(process.env.SLOW_MS ?? 10_000); +// Off by default: request bodies carry transaction data (wallet addresses, +// calldata, user IDs). Opt in with LOG_BODIES=true for local debugging only. +const LOG_BODIES = (process.env.LOG_BODIES ?? 'false').toLowerCase() === 'true'; +const LOG_COLOR = (process.env.LOG_COLOR ?? 'true').toLowerCase() !== 'false'; + +const C = { + bold: LOG_COLOR ? '\x1b[1m' : '', + cyan: LOG_COLOR ? '\x1b[36m' : '', + dim: LOG_COLOR ? '\x1b[2m' : '', + gray: LOG_COLOR ? '\x1b[90m' : '', + green: LOG_COLOR ? '\x1b[32m' : '', + magenta: LOG_COLOR ? '\x1b[35m' : '', + red: LOG_COLOR ? '\x1b[31m' : '', + reset: LOG_COLOR ? '\x1b[0m' : '', + yellow: LOG_COLOR ? '\x1b[33m' : '', +}; + +const HORIZ = '─'.repeat(72); + +const privateKey = PRIVATE_KEY_PEM_B64 + ? (() => { + try { + return createPrivateKey( + Buffer.from(PRIVATE_KEY_PEM_B64, 'base64').toString('utf8'), + ); + } catch { + console.error( + `${C.red}WEBHOOK_PRIVATE_KEY_PEM is set but is not a valid base64-encoded PEM private key.${C.reset}\nEncode with: base64 -w0 private.pem`, + ); + process.exit(1); + } + })() + : PRIVATE_KEY_PATH + ? (() => { + const abs = resolve(process.cwd(), PRIVATE_KEY_PATH); + if (!existsSync(abs)) { + console.error( + `${C.red}WEBHOOK_PRIVATE_KEY_PATH=${PRIVATE_KEY_PATH} does not exist (resolved to ${abs}).${C.reset}\nRun \`npm run keygen\` first, or unset WEBHOOK_PRIVATE_KEY_PATH to send unsigned responses.`, + ); + process.exit(1); + } + return createPrivateKey(readFileSync(abs, 'utf8')); + })() + : null; + +// Derive the matching public key from the loaded private key so we can show +// it to the operator on startup. The dashboard config's Response Verification +// Key MUST match this exactly, or signature verification will fail. +const publicKeyPem = privateKey + ? (createPublicKey(privateKey) + .export({ format: 'pem', type: 'spki' }) + .toString('utf8') + .trim() as string) + : ''; + +const isMode = (value: unknown): value is Mode => + typeof value === 'string' && SUPPORTED_MODES.includes(value as Mode); + +const resolveMode = (req: Request): { mode: Mode; source: 'query' | 'env' } => { + const fromQuery = req.query.mode; + if (isMode(fromQuery)) return { mode: fromQuery, source: 'query' }; + return { mode: DEFAULT_MODE, source: 'env' }; +}; + +const redact = (value: string): string => { + if (!value) return ''; + if (value.length <= 6) return `${value[0]}***`; + return `${value.slice(0, 3)}…${value.slice(-3)} (${value.length} chars)`; +}; + +type SignatureResult = 'ok' | 'mismatch' | 'missing' | 'skipped'; + +const verifyRequestSignature = ( + req: Request, + rawBody: Buffer, +): { result: SignatureResult; expected?: string; received?: string } => { + if (!WEBHOOK_SECRET) return { result: 'skipped' }; + + const received = req.header('x-dynamic-signature'); + if (!received) return { result: 'missing' }; + + const expected = createHmac('sha256', WEBHOOK_SECRET) + .update(rawBody) + .digest('hex'); + + const expectedBuf = Buffer.from(expected, 'hex'); + const receivedBuf = Buffer.from(received, 'hex'); + + if ( + expectedBuf.length !== receivedBuf.length || + !timingSafeEqual(expectedBuf, receivedBuf) + ) { + return { expected, received, result: 'mismatch' }; + } + return { expected, received, result: 'ok' }; +}; + +const writeSignedJson = ( + res: Response, + body: Record, + status = 200, +): { signed: boolean; signature?: string; bodyBytes: number } => { + const json = JSON.stringify(body); + let signed = false; + let signatureB64: string | undefined; + if (privateKey) { + const signature = cryptoSign(null, Buffer.from(json, 'utf8'), privateKey); + signatureB64 = signature.toString('base64'); + res.setHeader('x-dynamic-response-signature', signatureB64); + signed = true; + } + res.status(status).setHeader('Content-Type', 'application/json').send(json); + return { + bodyBytes: Buffer.byteLength(json, 'utf8'), + signature: signatureB64, + signed, + }; +}; + +const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); + +const formatJson = (value: unknown): string => { + try { + return JSON.stringify(value, null, 2) + .split('\n') + .map((line) => ` ${line}`) + .join('\n'); + } catch { + return ` `; + } +}; + +const pickKnownFields = ( + payload: Record | null, +): Record => { + if (!payload) return {}; + const fields: Record = {}; + // Order chosen so the most useful at-a-glance fields print first. + for (const key of [ + 'requestId', + 'timestamp', + 'chain', + 'chainId', + 'operation', + 'walletAddress', + 'origin', + 'environmentId', + 'projectId', + 'walletId', + 'userId', + 'shareSetType', + 'message', + ]) { + if (key in payload) fields[key] = payload[key]; + } + if ( + payload.context && + typeof payload.context === 'object' && + payload.context !== null + ) { + fields.contextKeys = Object.keys( + payload.context as Record, + ); + } + return fields; +}; + +type Decision = { + body: Record; + status: number; +}; + +const buildDecision = (mode: Mode): Decision | null => { + switch (mode) { + case 'allow': + return { body: { proceed: true }, status: 200 }; + case 'deny': + return { + body: { proceed: false, reason: DENY_REASON }, + status: 200, + }; + case 'slow': + return { body: { proceed: true }, status: 200 }; + case 'crash': + return null; + default: + return { body: { proceed: true }, status: 200 }; + } +}; + +const signatureStatusColor = (result: SignatureResult): string => { + if (result === 'ok') return C.green; + if (result === 'skipped') return C.gray; + return C.red; +}; + +let requestCounter = 0; + +const app = express(); +app.disable('x-powered-by'); + +// Health is a liveness probe only — keep it minimal. The security posture +// (HMAC / signing / mode) is printed to the operator's console on startup, so +// there's no need to expose it over an unauthenticated HTTP endpoint. +app.get('/health', (_req, res) => { + res.json({ + status: 'ok', + }); +}); + +// Express 4 does not catch rejected promises from async route handlers, so an +// unhandled rejection (e.g. writeSignedJson throwing after the client hung up +// during `slow` mode) would crash the process. This wrapper funnels rejections +// into the error middleware below. +const asyncHandler = + (fn: (req: Request, res: Response) => Promise) => + (req: Request, res: Response, next: NextFunction): void => { + fn(req, res).catch(next); + }; + +// Minimal dependency-free fixed-window rate limiter for the webhook endpoint. +// It's a defence-in-depth nicety for a local reference server, so the default +// ceiling is deliberately generous — high enough that normal dashboard "Send +// test" usage never trips it, low enough to blunt an accidental flood / DoS. +// Tune with RATE_LIMIT_MAX / RATE_LIMIT_WINDOW_MS. +const RATE_LIMIT_WINDOW_MS = Number(process.env.RATE_LIMIT_WINDOW_MS ?? 60_000); +const RATE_LIMIT_MAX = Number(process.env.RATE_LIMIT_MAX ?? 600); +const rateLimitBuckets = new Map(); + +const rateLimit = (req: Request, res: Response, next: NextFunction): void => { + const now = Date.now(); + const key = req.ip ?? 'unknown'; + const bucket = rateLimitBuckets.get(key); + + if (!bucket || now >= bucket.resetAt) { + rateLimitBuckets.set(key, { + count: 1, + resetAt: now + RATE_LIMIT_WINDOW_MS, + }); + next(); + return; + } + + bucket.count += 1; + if (bucket.count > RATE_LIMIT_MAX) { + res.setHeader( + 'Retry-After', + String(Math.ceil((bucket.resetAt - now) / 1000)), + ); + res.status(429).json({ proceed: false, reason: 'Too many requests' }); + return; + } + + next(); +}; + +app.post( + '/webhook', + rateLimit, + express.raw({ limit: '1mb', type: 'application/json' }), + asyncHandler(async (req, res) => { + requestCounter += 1; + const requestNo = requestCounter; + const receivedAt = new Date(); + const start = Date.now(); + + // express.raw only populates req.body (as a Buffer) for requests whose + // Content-Type matches application/json; any other or missing type leaves + // it unset, which would throw a TypeError deeper in the handler. Reject + // cleanly with a 415 instead. + const rawBody = req.body; + if (!Buffer.isBuffer(rawBody)) { + res.status(415).json({ + proceed: false, + reason: 'Expected Content-Type: application/json', + }); + return; + } + + const signature = verifyRequestSignature(req, rawBody); + const { mode, source: modeSource } = resolveMode(req); + + const payload: Record | null = (() => { + try { + const parsed: unknown = JSON.parse(rawBody.toString('utf8')); + // Minimal shape guard — the handler only understands a JSON object. A + // production webhook should enforce a strict schema here (e.g. zod) + // before touching any field. + if ( + typeof parsed !== 'object' || + parsed === null || + Array.isArray(parsed) + ) { + return null; + } + return parsed as Record; + } catch { + return null; + } + })(); + + // ── Request header ────────────────────────────────────────────── + console.log(''); + console.log(`${C.dim}${HORIZ}${C.reset}`); + console.log( + `${C.bold}${C.cyan}▶ Request #${requestNo}${C.reset} ${ + C.gray + }${receivedAt.toISOString()}${C.reset}`, + ); + console.log( + ` ${C.gray}from${C.reset} ${ + req.ip ?? req.socket.remoteAddress ?? 'unknown' + } ${C.gray}ua${C.reset} ${req.header('user-agent') ?? ''}`, + ); + console.log( + ` ${C.gray}body${C.reset} ${rawBody.length} bytes ${ + C.gray + }content-type${C.reset} ${req.header('content-type') ?? ''}`, + ); + + // ── Signature ─────────────────────────────────────────────────── + const sigColor = signatureStatusColor(signature.result); + console.log( + ` ${C.gray}signature${C.reset} ${sigColor}${signature.result}${C.reset}` + + (signature.received + ? ` ${C.gray}received${C.reset} ${signature.received.slice(0, 12)}…` + : '') + + (signature.expected && signature.result === 'mismatch' + ? ` ${C.gray}expected${C.reset} ${signature.expected.slice(0, 12)}…` + : ''), + ); + + // ── Mode ──────────────────────────────────────────────────────── + console.log( + ` ${C.gray}mode${C.reset} ${C.bold}${mode}${C.reset} ${C.gray}(${modeSource})${C.reset}`, + ); + + // ── Payload ───────────────────────────────────────────────────── + if (payload) { + const known = pickKnownFields(payload); + const knownEntries = Object.entries(known); + if (knownEntries.length > 0) { + const summary = knownEntries + .map(([k, v]) => `${C.gray}${k}${C.reset}=${JSON.stringify(v)}`) + .join(' '); + console.log(` ${summary}`); + } + if (LOG_BODIES) { + console.log(`${C.gray} body:${C.reset}`); + console.log(`${C.dim}${formatJson(payload)}${C.reset}`); + } + } else { + console.log(` ${C.red}body: ${C.reset}`); + } + + // ── Short-circuit on bad signature ────────────────────────────── + if (signature.result === 'mismatch' || signature.result === 'missing') { + const { signed } = writeSignedJson( + res, + { proceed: false, reason: 'Invalid signature' }, + 401, + ); + console.log( + `${C.red}◀ Response${C.reset} HTTP 401 ${C.gray}signed${ + C.reset + } ${signed} ${C.gray}elapsed${C.reset} ${Date.now() - start}ms`, + ); + console.log( + `${C.dim} body: {"proceed":false,"reason":"Invalid signature"}${C.reset}`, + ); + return; + } + + const decision = buildDecision(mode); + + // ── Crash mode ────────────────────────────────────────────────── + if (!decision) { + console.log( + `${C.red}◀ Response${C.reset} ${C.bold}TCP destroyed${C.reset} ${ + C.gray + }elapsed${C.reset} ${Date.now() - start}ms`, + ); + req.socket.destroy(); + return; + } + + // ── Slow mode (sleep before responding) ───────────────────────── + if (mode === 'slow') { + console.log( + ` ${C.yellow}sleeping ${SLOW_MS}ms before responding…${C.reset}`, + ); + await sleep(SLOW_MS); + } + + // ── Decision ──────────────────────────────────────────────────── + const { + signed, + signature: responseSignature, + bodyBytes, + } = writeSignedJson(res, decision.body, decision.status); + const decisionColor = decision.body.proceed ? C.green : C.red; + console.log( + `${decisionColor}◀ Response${C.reset} HTTP ${decision.status} ${C.gray}proceed${C.reset} ${decisionColor}${decision.body.proceed}${C.reset}` + + (decision.body.reason + ? ` ${C.gray}reason${C.reset} ${JSON.stringify( + decision.body.reason, + )}` + : '') + + ` ${C.gray}signed${C.reset} ${ + signed ? `${C.green}yes${C.reset}` : `${C.gray}no${C.reset}` + }` + + ` ${C.gray}body${C.reset} ${bodyBytes}b` + + ` ${C.gray}elapsed${C.reset} ${Date.now() - start}ms`, + ); + if (responseSignature) { + console.log( + ` ${C.gray}x-dynamic-response-signature${C.reset} ${responseSignature}`, + ); + } + if (LOG_BODIES) { + console.log(`${C.dim}${formatJson(decision.body)}${C.reset}`); + } + }), +); + +// eslint-disable-next-line @typescript-eslint/no-unused-vars +app.use((err: Error, _req: Request, res: Response, _next: NextFunction) => { + // Bound + strip control chars from the message so a crafted error can't + // flood or inject escape sequences into log aggregation. + const safeMessage = String(err.message ?? '') + // eslint-disable-next-line no-control-regex + .replace(/[\x00-\x1f\x7f]/g, ' ') + .slice(0, 200); + console.error( + `${C.red}${C.bold}✗ Unhandled error in webhook handler:${C.reset} ${safeMessage}`, + ); + try { + if (!res.headersSent) { + res.status(500).json({ proceed: false, reason: 'Internal error' }); + } else { + res.end(); + } + } catch { + // Connection already torn down (e.g. crash/slow-mode socket destroy) — + // nothing more we can safely write. + } +}); + +const server = app.listen(PORT, () => { + console.log(''); + console.log(`${C.bold}${C.cyan}${HORIZ}${C.reset}`); + console.log( + `${C.bold}${C.cyan} Transaction Review example webhook${C.reset}`, + ); + console.log(`${C.bold}${C.cyan}${HORIZ}${C.reset}`); + console.log( + ` ${C.gray}endpoint${C.reset} POST http://localhost:${PORT}/webhook`, + ); + console.log( + ` ${C.gray}health${C.reset} GET http://localhost:${PORT}/health`, + ); + console.log( + ` ${C.gray}default mode${C.reset} ${C.bold}${DEFAULT_MODE}${C.reset}`, + ); + console.log( + ` ${C.gray}HMAC verify${C.reset} ${ + WEBHOOK_SECRET ? `${C.green}on${C.reset}` : `${C.yellow}off${C.reset}` + } ${C.gray}secret=${redact(WEBHOOK_SECRET)}${C.reset}`, + ); + let keyLabel = PRIVATE_KEY_PATH || ''; + if (PRIVATE_KEY_AUTO_DETECTED) { + keyLabel += ' (auto-detected)'; + } + if (PRIVATE_KEY_PEM_B64) { + keyLabel = ''; + } + console.log( + ` ${C.gray}Response signing${C.reset} ${ + privateKey ? `${C.green}on${C.reset}` : `${C.yellow}off${C.reset}` + } ${C.gray}key=${keyLabel}${C.reset}`, + ); + console.log(` ${C.gray}slow mode sleep${C.reset} ${SLOW_MS}ms`); + console.log( + ` ${C.gray}deny reason${C.reset} ${JSON.stringify(DENY_REASON)}`, + ); + console.log(` ${C.gray}log bodies${C.reset} ${LOG_BODIES}`); + console.log(''); + + if (publicKeyPem) { + console.log( + `${C.bold}${C.cyan} Public key${C.reset} ${C.gray}(paste into dashboard → Response Verification Key)${C.reset}`, + ); + publicKeyPem.split('\n').forEach((line) => { + console.log(` ${C.green}${line}${C.reset}`); + }); + console.log(''); + } + + console.log(`${C.gray} Override per-request:${C.reset}`); + console.log( + ` ${C.dim}?mode=allow|deny|slow|crash → switch decision mode${C.reset}`, + ); + console.log(''); + console.log(`${C.gray} Env knobs:${C.reset}`); + console.log( + ` ${C.dim}LOG_BODIES=true → log request/response bodies (off by default)${C.reset}`, + ); + console.log(` ${C.dim}LOG_COLOR=false → disable ANSI colors${C.reset}`); + console.log( + ` ${C.dim}RATE_LIMIT_MAX=600 → max /webhook requests per RATE_LIMIT_WINDOW_MS per IP${C.reset}`, + ); + console.log(''); +}); + +server.on('error', (err: NodeJS.ErrnoException) => { + if (err.code === 'EADDRINUSE') { + console.error(''); + console.error( + `${C.red}${C.bold}✗ Port ${PORT} is already in use.${C.reset}`, + ); + console.error( + `${C.gray} Likely a stale instance of this server. Find and kill it:${C.reset}`, + ); + console.error(`${C.dim} lsof -nP -iTCP:${PORT} -sTCP:LISTEN${C.reset}`); + console.error(`${C.dim} kill ${C.reset}`); + console.error(''); + console.error( + `${C.gray} Or run on a different port:${C.reset} ${C.dim}PORT=4041 npm run dev${C.reset}`, + ); + console.error(''); + process.exit(1); + } + throw err; +}); diff --git a/examples/nodejs-transaction-review-webhook/tsconfig.json b/examples/nodejs-transaction-review-webhook/tsconfig.json new file mode 100644 index 0000000..270809c --- /dev/null +++ b/examples/nodejs-transaction-review-webhook/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "Bundler", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "types": ["node"] + }, + "include": ["src/**/*.ts"] +}