diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 69b93b2..3ddc191 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -45,8 +45,6 @@ jobs: ${{ runner.OS }}- - run: npm ci - run: npm run test-ci - env: - NODE_OPTIONS: '--max-old-space-size=8192' - name: Submit test coverage to Coveralls uses: coverallsapp/github-action@v1.1.2 with: diff --git a/MIGRATION.md b/MIGRATION.md new file mode 100644 index 0000000..a0df635 --- /dev/null +++ b/MIGRATION.md @@ -0,0 +1,138 @@ +# Migration Guide for FormSG SDK from 0.x.x to 1.0.0 + +## Major Changes Regarding Public Keys +Prior to version 1.0.0, **all signing keys are hardcoded in the SDK**. +Making the SDK convenient to use, but with poor security posture in case of a key compromise. + +With hardcoded keys, key rotation involves changing the hardcoded keys, publish a new patch version, and tell all clients using our SDK to urgently update to that new version. + +**Anyway, why does rotating keys matter?** +1. **Limiting exposure after compromise**: If a private key is compromised, having a rotation process ensures the exposure window is limited to the rotation period rather than indefinitely. +2. **Defense against undetected breaches**: Even if a key compromise goes undetected, regular rotation ensures the compromised key eventually becomes invalid. + +### Open Source and International Consideration +Given the open source nature of FormSG. Moving away from hardcoded keys to better key management significantly improves FormSG's ecosystem as an open source project. +1. **Separation of security concerns**: With JWKS, sensitive key material is no longer embedded in the open source codebase, allowing anyone to use, review, and contribute to the SDK without access to production keys. +2. **Environment flexibility**: Open source contributors can point the SDK to their own JWKS endpoints for development and testing, making contributions easier without depending on official FormSG infrastructure. + +We already have fully working forks of FormSG, if interested parties want to further explore FormSG's capabilities, they will likely need this SDK down the line. + +### Fetching keys from JWKS endpoint +Blabla +```json +{ + "keys": [ + { + "kty": "OKP", + "kid": "signing-webhook-key-staging-v1", + "use": "sig", + "alg": "EdDSA", + "crv": "Ed25519", + "x": "" + }, + { + "kty": "OKP", + "kid": "signing-otp-key-staging-v1", + "use": "verify", + "alg": "EdDSA", + "crv": "Ed25519", + "x": "" + } + ] +} +``` + + +#### Why JWKS? +JWKS (JSON Web Key Set) provides a standardized way to distribute cryptographic keys used for signature verification. It offers several advantages: + +1. Keys can be rotated without requiring SDK updates +2. All public keys are hosted in one discoverable location +3. Follows well-established security standards (RFC 7517) +5. Allows multiple key versions to exist simultaneously during rotation periods + +#### Why does the key need to be in base64url format? +The key is encoded in base64url format as per the JWKS specification. This encoding ensures the key material can be safely transported in URLs and JSON documents without special character escaping issues. Base64url is a URL-safe variant of base64 that replaces '+' with '-', '/' with '_', and omits padding characters ('='). + +So a base64 key such as +``` +Tl5gfszlKcQj99/0uafLwVpT6JAu4C0dHGvLq1cHzFE= +``` + +In base64url it would be +``` +Tl5gfszlKcQj99_0uafLwVpT6JAu4C0dHGvLq1cHzFE +``` + +Notice the difference +``` +```diff +- Tl5gfszlKcQj99/0uafLwVpT6JAu4C0dHGvLq1cHzFE= ++ Tl5gfszlKcQj99_0uafLwVpT6JAu4C0dHGvLq1cHzFE +``` + +#### What happens during key rotation? +1. A new key pair is generated and the new public key is added to the JWKS endpoint with a new kid (key ID) +2. Both the old and new keys remain available in the JWKS for a transition period +3. The SDK fetches the latest keys from the JWKS endpoint automatically +4. When verifying signatures, SDK tries keys matching the kid in the signature header +5. This allows for a seamless transition as systems gradually start using the new key +6. After the transition period, the old key may be removed from the JWKS + +#### How do I generate a new pair of keys? +The keys are ED25519 keys, which, in theory, can be generated in any way you want. Be it via a script using `openssl`, or any cryptographic library you're comfortable with. + +For convenience, we have provided a `generateKey.ts` using `nacl.sign.keypair()` to generate the keys. You can just run it and just copy paste the public key in base64url format. + +See examples/generate/README.md for more details. + +#### Lightweight Caching +The JWKS response are cached in-memory, this is useful for long running applications. + +When a key rotation happens, the cache will have old keys, since we're passing the `kid` in the signature header, SDK will try refetching JWKS to get the fresh keys, ensuring no failed signature verification due to stale cache. + +### Using custom keys injected at SDK initialisation +If you don't have a JWKS endpoint set up, you can inject your custom keys when initialising the SDK instance. +```typescript +import { FormSgSdk } from '@opengovsg/formsg-sdk' + +const formsg = new FormSgSdk({ + mode: 'production', + ... +}) +todo... +``` + +#### What happens during a key rotation? +When using custom keys, the SDK instance does not automatically update when keys are rotated. You'll need to manually re-initialise the SDK instance with the fresh set of keys. This requires code changes to update the keys and restart any services using the SDK to pick up the new keys. + +### Hardcoded FormSG keys + +### Key Resolution Strategy +The SDK follows a hierarchical approach to resolving keys: + +1. **In-memory Cache**: First checks for cached keys to minimize network requests +2. **JWKS Endpoint**: If cache misses or verification fails, fetches fresh keys from the JWKS endpoint (when configured) +3. **Custom Injected Keys**: Falls back to keys provided during SDK initialization (if available) +4. **Hardcoded Keys**: As a final fallback, uses built-in keys (these will be deprecated in future versions) + +This strategy ensures maximum reliability while transitioning to the new key management system. Note that hardcoded keys will be gradually phased out once version 1.0.0 is fully adopted. + +## Method Changes + +| 0.x.x | 1.0.0 | Notes | +|-------|-------|-------| +| `some.method.before (sync)` | `some.method.after (async)` | The method is now part of the webhook verifier class | + +## Example Migrations +```typescript +// 0.x.x +const { FormSgSdk } = require('@opengovsg/formsg-sdk') +const formsg = FormSgSdk() +todo... + +// 1.0.0 +const { FormSgSdk } = require('@opengovsg/formsg-sdk') +const formsg = new FormSgSdk() +todo... +``` diff --git a/examples/.env.example b/examples/.env.example new file mode 100644 index 0000000..45b9c85 --- /dev/null +++ b/examples/.env.example @@ -0,0 +1,14 @@ +# Your form's secret key downloaded from FormSG +FORM_SECRET_KEY=YOUR_FORM_SECRET_KEY_HERE + +# Set to true if your form has file attachments +HAS_ATTACHMENTS=false + +# FormSG environment +FORMSG_ENV=staging + +# Server port +PORT=3000 + +# Optional: JWKS URL if needed +# JWKS_URL=https://your-jwks-server/.well-known/jwks.json diff --git a/examples/.gitignore b/examples/.gitignore new file mode 100644 index 0000000..2d7ec5c --- /dev/null +++ b/examples/.gitignore @@ -0,0 +1,2 @@ +.env +node_modules/ diff --git a/examples/README.md b/examples/README.md new file mode 100644 index 0000000..de07c66 --- /dev/null +++ b/examples/README.md @@ -0,0 +1,66 @@ +# FormSG Webhook Demo Server + +A simple Express server that demonstrates how to use the FormSG JavaScript SDK to receive and process form submissions via webhooks. + +## Setup + +1. Copy `.env.example` to `.env`: + ```bash + cp .env.example .env + ``` + +2. Edit `.env` and add your FormSG form secret key + +3. Install dependencies: + ```bash + npm install + ``` + +4. Start the server: + ```bash + npx nodemon webhook-server.ts + ``` + + Or use the npm script: + ```bash + npm start + ``` + +## Exposing to the internet with ngrok + +To receive webhooks from FormSG, your server needs to be accessible from the internet. You can use ngrok for this: + +1. Install ngrok if you haven't already. You can do so via brew/any other means, here's how to using npm + ```bash + npm install -g ngrok + ``` + +2. Start ngrok in a new terminal: + ```bash + ngrok http 3000 + ``` + + Or use the npm script: + ```bash + npm run start:ngrok + ``` + +3. Copy the HTTPS URL provided by ngrok (example: `https://a1b2c3d4.ngrok.io`) + +4. Configure your FormSG form's webhook to point to this URL + `/submissions` (e.g., `https://a1b2c3d4.ngrok.io/submissions`) + +## How it works + +This example server: + +1. Authenticates incoming webhook requests using the FormSG signature +2. Decrypts the form submission using your form secret key +3. Handles form submissions with or without file attachments +4. Logs the decrypted submission data + +## Environment Variables + +- `FORM_SECRET_KEY`: Your form's secret key from FormSG (required) +- `HAS_ATTACHMENTS`: Set to 'true' if your form contains file upload fields +- `FORMSG_ENV`: 'production' or 'staging' depending on which FormSG environment you're using +- `PORT`: The port to run the server on (default: 3000) diff --git a/examples/generate-keys.ts b/examples/generate-keys.ts new file mode 100644 index 0000000..b5c6f39 --- /dev/null +++ b/examples/generate-keys.ts @@ -0,0 +1,43 @@ +import * as nacl from 'tweetnacl' +import crypto from 'crypto' + +// Generate Ed25519 keypair using NaCl +const generateKeysAndJWKS = () => { + const generateUuidKid = () => { + return crypto.randomUUID() + } + + const keypair = nacl.sign.keyPair() + + // generate timestamp to epoch + const timestamp = Math.floor(Date.now() / 1000) + + // Convert keys to Base64 for storage/display + const publicKeyBase64 = Buffer.from(keypair.publicKey).toString('base64') + const privateKeyBase64 = Buffer.from(keypair.secretKey).toString('base64') + + // Convert public key to base64url format (required for JWKS format) + const publicKeyBase64Url = publicKeyBase64 + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=+$/, '') + + return { + privateKey: privateKeyBase64, + publicKey: publicKeyBase64, + publicKeyBase64Url: publicKeyBase64Url, + kid: `${timestamp}-${generateUuidKid()}`, + } +} + +const result = generateKeysAndJWKS() +console.log('Generated Ed25519 keypair:') +console.log('-------------------------') +console.log('Private key (base64):') +console.log(result.privateKey) +console.log('\nPublic key (base64):') +console.log(result.publicKey) +console.log('\nPublic key (base64url, what to put in JWKS):') +console.log(result.publicKeyBase64Url) +console.log('\nExample Key ID (UUID format):') +console.log(result.kid) diff --git a/examples/package-lock.json b/examples/package-lock.json new file mode 100644 index 0000000..6a36142 --- /dev/null +++ b/examples/package-lock.json @@ -0,0 +1,1540 @@ +{ + "name": "formsg-webhook-demo", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "formsg-webhook-demo", + "version": "1.0.0", + "dependencies": { + "dotenv": "^16.3.1", + "express": "^4.18.2" + }, + "devDependencies": { + "@types/express": "^4.17.21", + "nodemon": "^3.0.1", + "ts-node": "^10.9.1", + "typescript": "^5.2.2" + } + }, + "node_modules/@cspotcode/source-map-support": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", + "integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "0.3.9" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.0.tgz", + "integrity": "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz", + "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.0.3", + "@jridgewell/sourcemap-codec": "^1.4.10" + } + }, + "node_modules/@tsconfig/node10": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.11.tgz", + "integrity": "sha512-DcRjDCujK/kCk/cUe8Xz8ZSpm8mS3mNNpta+jGCA6USEDfktlNvm1+IuZ9eTcDbNk41BHwpHHeW+N1lKCz4zOw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tsconfig/node12": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/@tsconfig/node12/-/node12-1.0.11.tgz", + "integrity": "sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tsconfig/node14": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@tsconfig/node14/-/node14-1.0.3.tgz", + "integrity": "sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tsconfig/node16": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@tsconfig/node16/-/node16-1.0.4.tgz", + "integrity": "sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/body-parser": { + "version": "1.19.5", + "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.5.tgz", + "integrity": "sha512-fB3Zu92ucau0iQ0JMCFQE7b/dv8Ot07NI3KaZIkIUNXq82k4eBAqUaneXfleGY9JWskeS9y+u0nXMyspcuQrCg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/connect": "*", + "@types/node": "*" + } + }, + "node_modules/@types/connect": { + "version": "3.4.38", + "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", + "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/express": { + "version": "4.17.21", + "resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.21.tgz", + "integrity": "sha512-ejlPM315qwLpaQlQDTjPdsUFSc6ZsP4AN6AlWnogPjQ7CVi7PYF3YVz+CY3jE2pwYf7E/7HlDAN0rV2GxTG0HQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/body-parser": "*", + "@types/express-serve-static-core": "^4.17.33", + "@types/qs": "*", + "@types/serve-static": "*" + } + }, + "node_modules/@types/express-serve-static-core": { + "version": "4.19.6", + "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.19.6.tgz", + "integrity": "sha512-N4LZ2xG7DatVqhCZzOGb1Yi5lMbXSZcmdLDe9EzSndPV2HpWYWzRbaerl2n27irrm94EPpprqa8KpskPT085+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "@types/qs": "*", + "@types/range-parser": "*", + "@types/send": "*" + } + }, + "node_modules/@types/http-errors": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.4.tgz", + "integrity": "sha512-D0CFMMtydbJAegzOyHjtiKPLlvnm3iTZyZRSZoLq2mRhDdmLfIWOCYPfQJ4cu2erKghU++QvjcUjp/5h7hESpA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/mime": { + "version": "1.3.5", + "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz", + "integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "22.13.5", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.13.5.tgz", + "integrity": "sha512-+lTU0PxZXn0Dr1NBtC7Y8cR21AJr87dLLU953CWA6pMxxv/UDc7jYAY90upcrie1nRcD6XNG5HOYEDtgW5TxAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.20.0" + } + }, + "node_modules/@types/qs": { + "version": "6.9.18", + "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.9.18.tgz", + "integrity": "sha512-kK7dgTYDyGqS+e2Q4aK9X3D7q234CIZ1Bv0q/7Z5IwRDoADNU81xXJK/YVyLbLTZCoIwUoDoffFeF+p/eIklAA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/range-parser": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz", + "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/send": { + "version": "0.17.4", + "resolved": "https://registry.npmjs.org/@types/send/-/send-0.17.4.tgz", + "integrity": "sha512-x2EM6TJOybec7c52BX0ZspPodMsQUd5L6PRwOunVyVUhXiBSKf3AezDL8Dgvgt5o0UfKNfuA0eMLr2wLT4AiBA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/mime": "^1", + "@types/node": "*" + } + }, + "node_modules/@types/serve-static": { + "version": "1.15.7", + "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.15.7.tgz", + "integrity": "sha512-W8Ym+h8nhuRwaKPaDw34QUkwsGi6Rc4yYqvKFo5rm2FUEhCFbzVWrxXUxuKK8TASjWsysJY0nsmNCGhCOIsrOw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/http-errors": "*", + "@types/node": "*", + "@types/send": "*" + } + }, + "node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "license": "MIT", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/acorn": { + "version": "8.14.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.0.tgz", + "integrity": "sha512-cl669nCJTZBsL97OF4kUQm5g5hC2uihk0NxY3WENAC0TYdILVkAyHymAntgxGkl7K+t0cXIrH5siy5S4XkFycA==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-walk": { + "version": "8.3.4", + "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.4.tgz", + "integrity": "sha512-ueEepnujpqee2o5aIYnvHU6C0A42MNdsIDeqy5BydrkuC5R1ZuUFnm27EeFJGoEHJQgn3uleRvmTXaJgfXbt4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "acorn": "^8.11.0" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/arg": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz", + "integrity": "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==", + "dev": true, + "license": "MIT" + }, + "node_modules/array-flatten": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", + "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", + "license": "MIT" + }, + "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/binary-extensions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/body-parser": { + "version": "1.20.3", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.3.tgz", + "integrity": "sha512-7rAxByjUMqQ3/bHJy7D6OGXvx/MMc4IqBn/X0fcM1QUcAItpZrBEYhWGem+tzXH90c+G01ypMcYJBO9Y30203g==", + "license": "MIT", + "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" + }, + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/brace-expansion": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", + "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.3.tgz", + "integrity": "sha512-YTd+6wGlNlPxSuri7Y6X8tY2dmm12UMH66RpKMhiX6rsk5wXXnYgbUcOt8kiS31/AjfoTOvCsE+w8nZQLQnzHA==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "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/content-disposition": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", + "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", + "license": "MIT", + "dependencies": { + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.1.tgz", + "integrity": "sha512-6DnInpx7SJ2AK3+CTUE/ZM0vWTUboZCegxhC2xiIydHR9jNuTAASBrfEpHhiGOZw/nX51bHt6YQl8jsGo4y/0w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz", + "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==", + "license": "MIT" + }, + "node_modules/create-require": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz", + "integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/destroy": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", + "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", + "license": "MIT", + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/diff": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.2.tgz", + "integrity": "sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.3.1" + } + }, + "node_modules/dotenv": { + "version": "16.4.7", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.4.7.tgz", + "integrity": "sha512-47qPchRCykZC03FhkYAhrvwU4xDBFIj1QPqaarj6mdM/hgUzfPHcpkHJOn3mJAufFeeAxAzeGsr5X0M4k6fLZQ==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/express": { + "version": "4.21.2", + "resolved": "https://registry.npmjs.org/express/-/express-4.21.2.tgz", + "integrity": "sha512-28HqgMZAmih1Czt9ny7qr6ek2qddF4FclbMzwhCREB6OFfH+rXAnuNCwo1/wFvrtbgsQDb4kSbX9de9lFbrXnA==", + "license": "MIT", + "dependencies": { + "accepts": "~1.3.8", + "array-flatten": "1.1.1", + "body-parser": "1.20.3", + "content-disposition": "0.5.4", + "content-type": "~1.0.4", + "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" + }, + "engines": { + "node": ">= 0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/finalhandler": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.1.tgz", + "integrity": "sha512-6BN9trH7bp3qvnrRyzsBz+g3lZxTNZTbVO2EV1CS0WIcDbawYVdYvGflME/9QP0h0pYlCDBCTjYa9nZzMDpyxQ==", + "license": "MIT", + "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" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "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/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-flag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", + "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/http-errors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz", + "integrity": "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==", + "license": "MIT", + "dependencies": { + "depd": "2.0.0", + "inherits": "2.0.4", + "setprototypeof": "1.2.0", + "statuses": "2.0.1", + "toidentifier": "1.0.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ignore-by-default": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/ignore-by-default/-/ignore-by-default-1.0.1.tgz", + "integrity": "sha512-Ius2VYcGNk7T90CppJqcIkS5ooHUZyIQK+ClZfMfMNFEF9VSE73Fq+906u/CWu92x4gzZMWOwfFYckPObzdEbA==", + "dev": true, + "license": "ISC" + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "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/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/make-error": { + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz", + "integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==", + "dev": true, + "license": "ISC" + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/merge-descriptors": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", + "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/methods": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", + "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/nodemon": { + "version": "3.1.9", + "resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.9.tgz", + "integrity": "sha512-hdr1oIb2p6ZSxu3PB2JWWYS7ZQ0qvaZsc3hK8DR8f02kRzc8rjYmxAIvdz+aYC+8F2IjNaB7HMcSDg8nQpJxyg==", + "dev": true, + "license": "MIT", + "dependencies": { + "chokidar": "^3.5.2", + "debug": "^4", + "ignore-by-default": "^1.0.1", + "minimatch": "^3.1.2", + "pstree.remy": "^1.1.8", + "semver": "^7.5.3", + "simple-update-notifier": "^2.0.0", + "supports-color": "^5.5.0", + "touch": "^3.1.0", + "undefsafe": "^2.0.5" + }, + "bin": { + "nodemon": "bin/nodemon.js" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/nodemon" + } + }, + "node_modules/nodemon/node_modules/debug": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz", + "integrity": "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/nodemon/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-to-regexp": { + "version": "0.1.12", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz", + "integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==", + "license": "MIT" + }, + "node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/pstree.remy": { + "version": "1.1.8", + "resolved": "https://registry.npmjs.org/pstree.remy/-/pstree.remy-1.1.8.tgz", + "integrity": "sha512-77DZwxQmxKnu3aR542U+X8FypNzbfJ+C5XQDk3uWjWxn6151aIMGthWYRXTqT1E5oJvg+ljaa2OJi+VfvCOQ8w==", + "dev": true, + "license": "MIT" + }, + "node_modules/qs": { + "version": "6.13.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.13.0.tgz", + "integrity": "sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg==", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.0.6" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "2.5.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.2.tgz", + "integrity": "sha512-8zGqypfENjCIqGhgXToC8aB2r7YrBX+AQAfIPs/Mlk+BtPTztOvTS01NRW/3Eh60J+a48lt8qsCzirQ6loCVfA==", + "license": "MIT", + "dependencies": { + "bytes": "3.1.2", + "http-errors": "2.0.0", + "iconv-lite": "0.4.24", + "unpipe": "1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "7.7.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.1.tgz", + "integrity": "sha512-hlq8tAfn0m/61p4BVRcPzIGr6LKiMwo4VM6dGi6pt4qcRkmNzTcWq6eCEjEh+qXjkMDvPlOFFSGwQjoEa6gyMA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/send": { + "version": "0.19.0", + "resolved": "https://registry.npmjs.org/send/-/send-0.19.0.tgz", + "integrity": "sha512-dW41u5VfLXu8SJh5bwRmyYUbAoSB3c9uQh6L8h/KtsFREPWpbX1lrljJo186Jc4nmci/sGUZ9a0a0J2zgfq2hw==", + "license": "MIT", + "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" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/send/node_modules/encodeurl": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz", + "integrity": "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/send/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/serve-static": { + "version": "1.16.2", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.2.tgz", + "integrity": "sha512-VqpjJZKadQB/PEbEwvFdO43Ax5dFBZ2UECszz8bQ7pi7wt//PWe1P6MN7eCnjsatYtBT6EuiClbjSWP2WrIoTw==", + "license": "MIT", + "dependencies": { + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "parseurl": "~1.3.3", + "send": "0.19.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", + "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/simple-update-notifier": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/simple-update-notifier/-/simple-update-notifier-2.0.0.tgz", + "integrity": "sha512-a2B9Y0KlNXl9u/vsW6sTIu9vGEpfKu2wRV6l1H3XEas/0gUIzGzBoP/IouTcUQbm9JWZLH3COxyn03TYlFax6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/statuses": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", + "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/supports-color": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", + "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^3.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/touch": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/touch/-/touch-3.1.1.tgz", + "integrity": "sha512-r0eojU4bI8MnHr8c5bNo7lJDdI2qXlWWJk6a9EAFG7vbhTjElYhBVS3/miuE0uOuoLdb8Mc/rVfsmm6eo5o9GA==", + "dev": true, + "license": "ISC", + "bin": { + "nodetouch": "bin/nodetouch.js" + } + }, + "node_modules/ts-node": { + "version": "10.9.2", + "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz", + "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cspotcode/source-map-support": "^0.8.0", + "@tsconfig/node10": "^1.0.7", + "@tsconfig/node12": "^1.0.7", + "@tsconfig/node14": "^1.0.0", + "@tsconfig/node16": "^1.0.2", + "acorn": "^8.4.1", + "acorn-walk": "^8.1.1", + "arg": "^4.1.0", + "create-require": "^1.1.0", + "diff": "^4.0.1", + "make-error": "^1.1.1", + "v8-compile-cache-lib": "^3.0.1", + "yn": "3.1.1" + }, + "bin": { + "ts-node": "dist/bin.js", + "ts-node-cwd": "dist/bin-cwd.js", + "ts-node-esm": "dist/bin-esm.js", + "ts-node-script": "dist/bin-script.js", + "ts-node-transpile-only": "dist/bin-transpile.js", + "ts-script": "dist/bin-script-deprecated.js" + }, + "peerDependencies": { + "@swc/core": ">=1.2.50", + "@swc/wasm": ">=1.2.50", + "@types/node": "*", + "typescript": ">=2.7" + }, + "peerDependenciesMeta": { + "@swc/core": { + "optional": true + }, + "@swc/wasm": { + "optional": true + } + } + }, + "node_modules/type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "license": "MIT", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/typescript": { + "version": "5.7.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.7.3.tgz", + "integrity": "sha512-84MVSjMEHP+FQRPy3pX9sTVV/INIex71s9TL2Gm5FG/WG1SqXeKyZ0k7/blY/4FdOzI12CBy1vGc4og/eus0fw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undefsafe": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/undefsafe/-/undefsafe-2.0.5.tgz", + "integrity": "sha512-WxONCrssBM8TSPRqN5EmsjVrsv4A8X12J4ArBiiayv3DyyG3ZlIg6yysuuSYdZsVz3TKcTg2fd//Ujd4CHV1iA==", + "dev": true, + "license": "MIT" + }, + "node_modules/undici-types": { + "version": "6.20.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.20.0.tgz", + "integrity": "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg==", + "dev": true, + "license": "MIT" + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/v8-compile-cache-lib": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz", + "integrity": "sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==", + "dev": true, + "license": "MIT" + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/yn": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz", + "integrity": "sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + } + } +} diff --git a/examples/package.json b/examples/package.json new file mode 100644 index 0000000..c905a86 --- /dev/null +++ b/examples/package.json @@ -0,0 +1,21 @@ +{ + "name": "formsg-webhook-demo", + "version": "1.0.0", + "description": "A minimal server for testing FormSG webhooks", + "main": "webhook-server.ts", + "scripts": { + "start": "nodemon webhook-server.ts", + "start:ngrok": "ngrok http 3000", + "generate-keys": "ts-node generate-keys.ts" + }, + "dependencies": { + "express": "^4.18.2", + "dotenv": "^16.3.1" + }, + "devDependencies": { + "@types/express": "^4.17.21", + "nodemon": "^3.0.1", + "ts-node": "^10.9.1", + "typescript": "^5.2.2" + } +} diff --git a/examples/tsconfig.json b/examples/tsconfig.json new file mode 100644 index 0000000..82d82e6 --- /dev/null +++ b/examples/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "target": "ES2018", + "module": "CommonJS", + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "strict": true, + "skipLibCheck": true, + "resolveJsonModule": true, + "moduleResolution": "node" + }, + "include": ["./*.ts"] +} diff --git a/examples/webhook-server.ts b/examples/webhook-server.ts new file mode 100644 index 0000000..f8c62ce --- /dev/null +++ b/examples/webhook-server.ts @@ -0,0 +1,142 @@ +import express from 'express' +import * as dotenv from 'dotenv' +import formSgSDK from '../src' + +dotenv.config() + +const app = express() +const PORT = process.env.PORT || 3000 + +// Get the form secret key from environment variables +const formSecretKey = process.env.FORM_SECRET_KEY! +if (!formSecretKey) { + console.error('FORM_SECRET_KEY environment variable is required') + process.exit(1) +} + +const HAS_ATTACHMENTS = process.env.HAS_ATTACHMENTS === 'true' + +async function initializeFormSg() { + const formsg = await formSgSDK({ + mode: (process.env.FORMSG_ENV as 'staging' | 'production') || 'production', + ...(process.env.JWKS_URL && { + jwks: { + url: process.env.JWKS_URL, + cacheDurationMs: 60_000, // 1 minute + requestConfig: { + timeoutMs: 5_000, + retry: { + maxRetries: 3, + initialBackoffMs: 1_000, + }, + }, + }, + }), + }) + + // This should match the webhook URI you configure in FormSG + const WEBHOOK_PATH = '/submissions' + const webhookUrl = `https://256d-103-6-151-166.ngrok-free.app${WEBHOOK_PATH}` + + app.use((req, res, next) => { + console.log(`${new Date().toISOString()} - ${req.method} ${req.path}`) + console.log('Headers:', JSON.stringify(req.headers, null, 2)) + next() + }) + + app.post( + WEBHOOK_PATH, + // Authenticate the webhook signature + (req, res, next) => { + try { + const signature = req.get('X-FormSG-Signature') + if (!signature) { + console.error('No signature found in headers') + return res.status(401).send({ message: 'Signature missing' }) + } + + formsg.webhooks.authenticate(signature, webhookUrl) + console.log('Webhook authenticated successfully') + return next() + } catch (e) { + console.error('Authentication failed:', e) + return res.status(401).send({ message: 'Unauthorized' }) + } + }, + // Parse JSON from raw body + express.json(), + // Decrypt the submission + async (req, res) => { + try { + console.log('Processing submission...') + + if (!req.body.data) { + return res.status(400).send({ message: 'No data provided' }) + } + + const submission = HAS_ATTACHMENTS + ? await formsg.crypto.decryptWithAttachments( + formSecretKey, + req.body.data + ) + : formsg.crypto.decrypt(formSecretKey, req.body.data) + + if (submission) { + console.log('Submission decrypted successfully') + + // Print submission details (redacted for privacy) + if (HAS_ATTACHMENTS && 'attachments' in submission) { + console.log( + 'Contains attachments with field IDs:', + Object.keys(submission.attachments) + ) + + // Just log attachment names, not the content + Object.entries(submission.attachments).forEach( + ([fieldId, file]) => { + console.log( + `Field ${fieldId}: ${file.filename} (${file.content.byteLength} bytes)` + ) + } + ) + + console.log( + 'Form responses:', + submission.content.responses.map((field) => ({ + id: field._id, + question: field.question, + })) + ) + } + + return res + .status(200) + .send({ message: 'Submission processed successfully' }) + } else { + console.error('Could not decrypt submission') + return res.status(400).send({ message: 'Decryption failed' }) + } + } catch (e) { + console.error('Error processing submission:', e) + return res.status(500).send({ message: 'Internal server error' }) + } + } + ) + + app.listen(PORT, () => { + console.log(` + FormSG Webhook Demo Server + + Server running at http://localhost:${PORT} + Webhook endpoint: ${webhookUrl} + + To expose your local server to the internet: + Run 'npm run start:ngrok' in another terminal + `) + }) +} + +initializeFormSg().catch((error) => { + console.error('Failed to initialize FormSG SDK:', error) + process.exit(1) +}) diff --git a/jest.config.js b/jest.config.js index f01ddfe..70ccd34 100644 --- a/jest.config.js +++ b/jest.config.js @@ -1,5 +1,5 @@ module.exports = { - transform: { '^.+\\.ts?$': 'ts-jest' }, + transform: { '^.+\\.ts?$': '@swc/jest' }, testEnvironment: 'node', testRegex: '/spec/.*\\.(test|spec)?\\.(ts|tsx)$', moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'], @@ -10,4 +10,5 @@ module.exports = { functions: 80, }, }, + workerIdleMemoryLimit: '512MB', } diff --git a/newkey-request.txt b/newkey-request.txt new file mode 100644 index 0000000..e69de29 diff --git a/original-request.txt b/original-request.txt new file mode 100644 index 0000000..8d7f483 --- /dev/null +++ b/original-request.txt @@ -0,0 +1,19 @@ +POST /submissions HTTP/1.1 +Host: 256d-103-6-151-166.ngrok-free.app +User-Agent: axios/1.7.7 +Content-Length: 755 +Accept: application/json, text/plain, */* +Accept-Encoding: gzip, compress, deflate, br +Content-Type: application/json +Traceparent: 00-000000000000000055652cb5581da89e-239949fd0d6cd40e-01 +Tracestate: dd=o:rum;s:1;p:239949fd0d6cd40e +X-Datadog-Origin: rum +X-Datadog-Parent-Id: 2565162813964997646 +X-Datadog-Sampling-Priority: 1 +X-Datadog-Trace-Id: 6153373623250692254 +X-Formsg-Signature: t=1740717558471,s=67c13df6e75569539f4abccd,f=67c12c2c05ed623929929ed2,v1=eavDX1DlzEaDq+H+WZIZg1/9WQXZU/Yx8u639eyv4ZAgl2jC2jhyfnT0NNOGdnf14BXIFsO/jYzuLw77OQIpDw== +X-Forwarded-For: 18.140.124.240 +X-Forwarded-Host: 256d-103-6-151-166.ngrok-free.app +X-Forwarded-Proto: https + +{"data":{"formId":"67c12c2c05ed623929929ed2","submissionId":"67c13df6e75569539f4abccd","encryptedContent":"nXew6vPFyEht/Dqi/jJE8Z0WZL0VQxLBt+597ClVOHQ=;trtmfcs6raeCHMcfd9GmxCQ0tvVCPYjT:qPXm09rTBgcMVWFJK3YHXwMklXrZFgqblChdEjkK5iOsNQTcESlSsFHJMBquMDuhNqiwHhd8XRZEjhr5GRsbMSjEjhJ6TmKRucBqtx/GyeQAMkFQB1iBjg4MSiBIpGHvAh/G3j5Wu42shpk7TPBBkwYu4nVpKMX8ydQHNlxgro/8STZ1qPUOBjd5dmMaP6SVh4CNlZ9FRQ6ngO/c9lhuhPYLzW9LBaGMWqu4FOEyqUs45fyYTg34qNBiRz7gEIAI/UtO7wu17l2huHHLVF8yQsPted/NG5gBJwPs13qHHNuLThd+XWQUIXYkIGrKGw3koB8k5HhyBZpOVyRKpGSE2UWMqIfqPcHJd6bxta/qR38FV1L4pq8bZsBgOV697FdV/lkw6Tq/mHLd/XdVQ3wVwEkv/4Fy0406BgmVUndspslX/nh66Wsj5IQo3+1hpeZH+48dPxEWrj1sQqAdu4E=","version":2.1,"created":"2025-02-28T04:39:18.409Z","attachmentDownloadUrls":{},"paymentContent":{}}} diff --git a/package-lock.json b/package-lock.json index 9c23884..96df85b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,15 +1,16 @@ { "name": "@opengovsg/formsg-sdk", - "version": "0.13.0", + "version": "1.0.0-alpha.0", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "@opengovsg/formsg-sdk", - "version": "0.13.0", + "version": "1.0.0-alpha.0", "license": "MIT", "dependencies": { "axios": "^1.6.4", + "axios-retry": "^4.5.0", "tweetnacl": "^1.0.3", "tweetnacl-util": "^0.15.1" }, @@ -17,6 +18,8 @@ "@babel/cli": "^7.8.4", "@babel/core": "^7.9.0", "@babel/preset-env": "^7.9.0", + "@swc/core": "^1.11.8", + "@swc/jest": "^0.2.37", "@types/jest": "^29.5.8", "@types/node": "^18.18.9", "@typescript-eslint/eslint-plugin": "^4.25.0", @@ -30,7 +33,7 @@ "eslint-plugin-typesafe": "^0.5.2", "jest": "^29.7.0", "jest-mock-axios": "^4.7.3", - "ts-jest": "^29.1.1", + "nock": "^14.0.1", "typescript": "^4.9.5" } }, @@ -1847,6 +1850,19 @@ "node": "^12.13.0 || ^14.15.0 || >=16.0.0" } }, + "node_modules/@jest/create-cache-key-function": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/create-cache-key-function/-/create-cache-key-function-29.7.0.tgz", + "integrity": "sha512-4QqS3LY5PBmTRHj9sAg1HLoPzqAI0uOX6wI/TRqHIcOxlFidy6YEmCQJk6FSZjNLGCeubDMfmkWL+qaLKhSGQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "^29.6.3" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, "node_modules/@jest/environment": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/@jest/environment/-/environment-29.7.0.tgz", @@ -2301,6 +2317,24 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@mswjs/interceptors": { + "version": "0.37.6", + "resolved": "https://registry.npmjs.org/@mswjs/interceptors/-/interceptors-0.37.6.tgz", + "integrity": "sha512-wK+5pLK5XFmgtH3aQ2YVvA3HohS3xqV/OxuVOdNx9Wpnz7VE/fnC+e1A7ln6LFYeck7gOJ/dsZV6OLplOtAJ2w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@open-draft/deferred-promise": "^2.2.0", + "@open-draft/logger": "^0.3.0", + "@open-draft/until": "^2.0.0", + "is-node-process": "^1.2.0", + "outvariant": "^1.4.3", + "strict-event-emitter": "^0.5.1" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/@nicolo-ribaudo/chokidar-2": { "version": "2.1.8-no-fsevents.3", "resolved": "https://registry.npmjs.org/@nicolo-ribaudo/chokidar-2/-/chokidar-2-2.1.8-no-fsevents.3.tgz", @@ -2343,6 +2377,31 @@ "node": ">= 8" } }, + "node_modules/@open-draft/deferred-promise": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@open-draft/deferred-promise/-/deferred-promise-2.2.0.tgz", + "integrity": "sha512-CecwLWx3rhxVQF6V4bAgPS5t+So2sTbPgAzafKkVizyi7tlwpcFpdFqq+wqF2OwNBmqFuu6tOyouTuxgpMfzmA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@open-draft/logger": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@open-draft/logger/-/logger-0.3.0.tgz", + "integrity": "sha512-X2g45fzhxH238HKO4xbSr7+wBS8Fvw6ixhTDuvLd5mqh6bJJCFAPwU9mPDxbcrRtfxv4u5IHCEH77BmxvXmmxQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-node-process": "^1.2.0", + "outvariant": "^1.4.0" + } + }, + "node_modules/@open-draft/until": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@open-draft/until/-/until-2.1.0.tgz", + "integrity": "sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg==", + "dev": true, + "license": "MIT" + }, "node_modules/@sinclair/typebox": { "version": "0.27.8", "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.8.tgz", @@ -2367,6 +2426,250 @@ "@sinonjs/commons": "^3.0.0" } }, + "node_modules/@swc/core": { + "version": "1.11.8", + "resolved": "https://registry.npmjs.org/@swc/core/-/core-1.11.8.tgz", + "integrity": "sha512-UAL+EULxrc0J73flwYHfu29mO8CONpDJiQv1QPDXsyCvDUcEhqAqUROVTgC+wtJCFFqMQdyr4stAA5/s0KSOmA==", + "dev": true, + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "@swc/counter": "^0.1.3", + "@swc/types": "^0.1.19" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/swc" + }, + "optionalDependencies": { + "@swc/core-darwin-arm64": "1.11.8", + "@swc/core-darwin-x64": "1.11.8", + "@swc/core-linux-arm-gnueabihf": "1.11.8", + "@swc/core-linux-arm64-gnu": "1.11.8", + "@swc/core-linux-arm64-musl": "1.11.8", + "@swc/core-linux-x64-gnu": "1.11.8", + "@swc/core-linux-x64-musl": "1.11.8", + "@swc/core-win32-arm64-msvc": "1.11.8", + "@swc/core-win32-ia32-msvc": "1.11.8", + "@swc/core-win32-x64-msvc": "1.11.8" + }, + "peerDependencies": { + "@swc/helpers": "*" + }, + "peerDependenciesMeta": { + "@swc/helpers": { + "optional": true + } + } + }, + "node_modules/@swc/core-darwin-arm64": { + "version": "1.11.8", + "resolved": "https://registry.npmjs.org/@swc/core-darwin-arm64/-/core-darwin-arm64-1.11.8.tgz", + "integrity": "sha512-rrSsunyJWpHN+5V1zumndwSSifmIeFQBK9i2RMQQp15PgbgUNxHK5qoET1n20pcUrmZeT6jmJaEWlQchkV//Og==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-darwin-x64": { + "version": "1.11.8", + "resolved": "https://registry.npmjs.org/@swc/core-darwin-x64/-/core-darwin-x64-1.11.8.tgz", + "integrity": "sha512-44goLqQuuo0HgWnG8qC+ZFw/qnjCVVeqffhzFr9WAXXotogVaxM8ze6egE58VWrfEc8me8yCcxOYL9RbtjhS/Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-linux-arm-gnueabihf": { + "version": "1.11.8", + "resolved": "https://registry.npmjs.org/@swc/core-linux-arm-gnueabihf/-/core-linux-arm-gnueabihf-1.11.8.tgz", + "integrity": "sha512-Mzo8umKlhTWwF1v8SLuTM1z2A+P43UVhf4R8RZDhzIRBuB2NkeyE+c0gexIOJBuGSIATryuAF4O4luDu727D1w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-linux-arm64-gnu": { + "version": "1.11.8", + "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-gnu/-/core-linux-arm64-gnu-1.11.8.tgz", + "integrity": "sha512-EyhO6U+QdoGYC1MeHOR0pyaaSaKYyNuT4FQNZ1eZIbnuueXpuICC7iNmLIOfr3LE5bVWcZ7NKGVPlM2StJEcgA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-linux-arm64-musl": { + "version": "1.11.8", + "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-musl/-/core-linux-arm64-musl-1.11.8.tgz", + "integrity": "sha512-QU6wOkZnS6/QuBN1MHD6G2BgFxB0AclvTVGbqYkRA7MsVkcC29PffESqzTXnypzB252/XkhQjoB2JIt9rPYf6A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-linux-x64-gnu": { + "version": "1.11.8", + "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-gnu/-/core-linux-x64-gnu-1.11.8.tgz", + "integrity": "sha512-r72onUEIU1iJi9EUws3R28pztQ/eM3EshNpsPRBfuLwKy+qn3et55vXOyDhIjGCUph5Eg2Yn8H3h6MTxDdLd+w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-linux-x64-musl": { + "version": "1.11.8", + "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-musl/-/core-linux-x64-musl-1.11.8.tgz", + "integrity": "sha512-294k8cLpO103++f4ZUEDr3vnBeUfPitW6G0a3qeVZuoXFhFgaW7ANZIWknUc14WiLOMfMecphJAEiy9C8OeYSw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-win32-arm64-msvc": { + "version": "1.11.8", + "resolved": "https://registry.npmjs.org/@swc/core-win32-arm64-msvc/-/core-win32-arm64-msvc-1.11.8.tgz", + "integrity": "sha512-EbjOzQ+B85rumHyeesBYxZ+hq3ZQn+YAAT1ZNE9xW1/8SuLoBmHy/K9YniRGVDq/2NRmp5kI5+5h5TX0asIS9A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-win32-ia32-msvc": { + "version": "1.11.8", + "resolved": "https://registry.npmjs.org/@swc/core-win32-ia32-msvc/-/core-win32-ia32-msvc-1.11.8.tgz", + "integrity": "sha512-Z+FF5kgLHfQWIZ1KPdeInToXLzbY0sMAashjd/igKeP1Lz0qKXVAK+rpn6ASJi85Fn8wTftCGCyQUkRVn0bTDg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-win32-x64-msvc": { + "version": "1.11.8", + "resolved": "https://registry.npmjs.org/@swc/core-win32-x64-msvc/-/core-win32-x64-msvc-1.11.8.tgz", + "integrity": "sha512-j6B6N0hChCeAISS6xp/hh6zR5CSCr037BAjCxNLsT8TGe5D+gYZ57heswUWXRH8eMKiRDGiLCYpPB2pkTqxCSw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/counter": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/@swc/counter/-/counter-0.1.3.tgz", + "integrity": "sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/@swc/jest": { + "version": "0.2.37", + "resolved": "https://registry.npmjs.org/@swc/jest/-/jest-0.2.37.tgz", + "integrity": "sha512-CR2BHhmXKGxTiFr21DYPRHQunLkX3mNIFGFkxBGji6r9uyIR5zftTOVYj1e0sFNMV2H7mf/+vpaglqaryBtqfQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/create-cache-key-function": "^29.7.0", + "@swc/counter": "^0.1.3", + "jsonc-parser": "^3.2.0" + }, + "engines": { + "npm": ">= 7.0.0" + }, + "peerDependencies": { + "@swc/core": "*" + } + }, + "node_modules/@swc/types": { + "version": "0.1.19", + "resolved": "https://registry.npmjs.org/@swc/types/-/types-0.1.19.tgz", + "integrity": "sha512-WkAZaAfj44kh/UFdAQcrMP1I0nwRqpt27u+08LMBYMqmQfwwMofYoMh/48NGkMMRfC4ynpfwRbJuu8ErfNloeA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@swc/counter": "^0.1.3" + } + }, "node_modules/@types/babel__core": { "version": "7.1.16", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.1.16.tgz", @@ -3114,6 +3417,18 @@ "proxy-from-env": "^1.1.0" } }, + "node_modules/axios-retry": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/axios-retry/-/axios-retry-4.5.0.tgz", + "integrity": "sha512-aR99oXhpEDGo0UuAlYcn2iGRds30k366Zfa05XWScR9QaQD4JYiP3/1Qt1u7YlefUOK+cn0CcwoL1oefavQUlQ==", + "license": "Apache-2.0", + "dependencies": { + "is-retry-allowed": "^2.2.0" + }, + "peerDependencies": { + "axios": "0.x || 1.x" + } + }, "node_modules/axios/node_modules/form-data": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz", @@ -3306,18 +3621,6 @@ "url": "https://opencollective.com/browserslist" } }, - "node_modules/bs-logger": { - "version": "0.2.6", - "resolved": "https://registry.npmjs.org/bs-logger/-/bs-logger-0.2.6.tgz", - "integrity": "sha512-pd8DCoxmbgc7hyPKOvxtqNcjYoOsABPQdcCUjGp3d42VR2CX1ORhk2A87oqqu5R1kk+76nsxZupkmyd+MVtCog==", - "dev": true, - "dependencies": { - "fast-json-stable-stringify": "2.x" - }, - "engines": { - "node": ">= 6" - } - }, "node_modules/bser": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/bser/-/bser-2.1.1.tgz", @@ -5190,6 +5493,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-node-process": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/is-node-process/-/is-node-process-1.2.0.tgz", + "integrity": "sha512-Vg4o6/fqPxIjtxgUH5QLJhwZ7gW5diGCVlXpuUfELC62CuxM1iHcRe51f2W1FDy04Ai4KJkagKjx3XaqyfRKXw==", + "dev": true, + "license": "MIT" + }, "node_modules/is-number": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", @@ -5227,6 +5537,18 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-retry-allowed": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/is-retry-allowed/-/is-retry-allowed-2.2.0.tgz", + "integrity": "sha512-XVm7LOeLpTW4jV19QSH38vkswxoLud8sQ57YwJVTPWdiaI9I8keEhGFpBlslyVsgdQy4Opg8QOLb8YRgsyZiQg==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/is-stream": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", @@ -5432,6 +5754,7 @@ "resolved": "https://registry.npmjs.org/jest/-/jest-29.7.0.tgz", "integrity": "sha512-NIy3oAFp9shda19hy4HK0HRTWKtPJmGdnvywu01nOqNC2vZg+Z+fvJDxpMQA88eb2I9EcafcdjYgsDthnYTvGw==", "dev": true, + "license": "MIT", "dependencies": { "@jest/core": "^29.7.0", "@jest/types": "^29.6.3", @@ -6896,6 +7219,13 @@ "node": ">=6" } }, + "node_modules/jsonc-parser": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.3.1.tgz", + "integrity": "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==", + "dev": true, + "license": "MIT" + }, "node_modules/jsprim": { "version": "1.4.2", "resolved": "https://registry.npmjs.org/jsprim/-/jsprim-1.4.2.tgz", @@ -7013,12 +7343,6 @@ "integrity": "sha1-4j8/nE+Pvd6HJSnBBxhXoIblzO8=", "dev": true }, - "node_modules/lodash.memoize": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/lodash.memoize/-/lodash.memoize-4.1.2.tgz", - "integrity": "sha1-vMbEmkKihA7Zl/Mj6tpezRguC/4=", - "dev": true - }, "node_modules/lodash.merge": { "version": "4.6.2", "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", @@ -7077,12 +7401,6 @@ "node": ">=6" } }, - "node_modules/make-error": { - "version": "1.3.6", - "resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz", - "integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==", - "dev": true - }, "node_modules/makeerror": { "version": "1.0.12", "resolved": "https://registry.npmjs.org/makeerror/-/makeerror-1.0.12.tgz", @@ -7187,6 +7505,21 @@ "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==", "dev": true }, + "node_modules/nock": { + "version": "14.0.1", + "resolved": "https://registry.npmjs.org/nock/-/nock-14.0.1.tgz", + "integrity": "sha512-IJN4O9pturuRdn60NjQ7YkFt6Rwei7ZKaOwb1tvUIIqTgeD0SDDAX3vrqZD4wcXczeEy/AsUXxpGpP/yHqV7xg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@mswjs/interceptors": "^0.37.3", + "json-stringify-safe": "^5.0.1", + "propagate": "^2.0.0" + }, + "engines": { + "node": ">=18.20.0 <20 || >=20.12.1" + } + }, "node_modules/node-fetch": { "version": "2.6.7", "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.7.tgz", @@ -7361,6 +7694,13 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/outvariant": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/outvariant/-/outvariant-1.4.3.tgz", + "integrity": "sha512-+Sl2UErvtsoajRDKCE5/dBz4DIvHXQQnAxtQTF04OJxY0+DyZXSo5P5Bb7XYWOh81syohlYL24hbDwxedPUJCA==", + "dev": true, + "license": "MIT" + }, "node_modules/p-limit": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", @@ -7615,6 +7955,16 @@ "node": ">= 6" } }, + "node_modules/propagate": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/propagate/-/propagate-2.0.1.tgz", + "integrity": "sha512-vGrhOavPSTz4QVNuBNdcNXePNdNMaO1xj9yBeH1ScQPjk/rhg9sSlCXPhMkFuaNNW/syTvYqsnbIJxMBfRbbag==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, "node_modules/proxy-from-env": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", @@ -8270,6 +8620,13 @@ "node": ">=8" } }, + "node_modules/strict-event-emitter": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/strict-event-emitter/-/strict-event-emitter-0.5.1.tgz", + "integrity": "sha512-vMgjE/GGEPEFnhFub6pa4FmJBRBVOLpIII2hvCZ8Kzb7K0hlHo7mQv6xYrBvCL2LtAIBwFUK8wvuJgTVSQ5MFQ==", + "dev": true, + "license": "MIT" + }, "node_modules/string-length": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/string-length/-/string-length-4.0.2.tgz", @@ -8469,64 +8826,6 @@ "node": ">=8.0" } }, - "node_modules/ts-jest": { - "version": "29.1.1", - "resolved": "https://registry.npmjs.org/ts-jest/-/ts-jest-29.1.1.tgz", - "integrity": "sha512-D6xjnnbP17cC85nliwGiL+tpoKN0StpgE0TeOjXQTU6MVCfsB4v7aW05CgQ/1OywGb0x/oy9hHFnN+sczTiRaA==", - "dev": true, - "dependencies": { - "bs-logger": "0.x", - "fast-json-stable-stringify": "2.x", - "jest-util": "^29.0.0", - "json5": "^2.2.3", - "lodash.memoize": "4.x", - "make-error": "1.x", - "semver": "^7.5.3", - "yargs-parser": "^21.0.1" - }, - "bin": { - "ts-jest": "cli.js" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - }, - "peerDependencies": { - "@babel/core": ">=7.0.0-beta.0 <8", - "@jest/types": "^29.0.0", - "babel-jest": "^29.0.0", - "jest": "^29.0.0", - "typescript": ">=4.3 <6" - }, - "peerDependenciesMeta": { - "@babel/core": { - "optional": true - }, - "@jest/types": { - "optional": true - }, - "babel-jest": { - "optional": true - }, - "esbuild": { - "optional": true - } - } - }, - "node_modules/ts-jest/node_modules/semver": { - "version": "7.5.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.5.4.tgz", - "integrity": "sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA==", - "dev": true, - "dependencies": { - "lru-cache": "^6.0.0" - }, - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/tsconfig-paths": { "version": "3.9.0", "resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-3.9.0.tgz", @@ -10333,6 +10632,15 @@ } } }, + "@jest/create-cache-key-function": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/create-cache-key-function/-/create-cache-key-function-29.7.0.tgz", + "integrity": "sha512-4QqS3LY5PBmTRHj9sAg1HLoPzqAI0uOX6wI/TRqHIcOxlFidy6YEmCQJk6FSZjNLGCeubDMfmkWL+qaLKhSGQA==", + "dev": true, + "requires": { + "@jest/types": "^29.6.3" + } + }, "@jest/environment": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/@jest/environment/-/environment-29.7.0.tgz", @@ -10688,6 +10996,20 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "@mswjs/interceptors": { + "version": "0.37.6", + "resolved": "https://registry.npmjs.org/@mswjs/interceptors/-/interceptors-0.37.6.tgz", + "integrity": "sha512-wK+5pLK5XFmgtH3aQ2YVvA3HohS3xqV/OxuVOdNx9Wpnz7VE/fnC+e1A7ln6LFYeck7gOJ/dsZV6OLplOtAJ2w==", + "dev": true, + "requires": { + "@open-draft/deferred-promise": "^2.2.0", + "@open-draft/logger": "^0.3.0", + "@open-draft/until": "^2.0.0", + "is-node-process": "^1.2.0", + "outvariant": "^1.4.3", + "strict-event-emitter": "^0.5.1" + } + }, "@nicolo-ribaudo/chokidar-2": { "version": "2.1.8-no-fsevents.3", "resolved": "https://registry.npmjs.org/@nicolo-ribaudo/chokidar-2/-/chokidar-2-2.1.8-no-fsevents.3.tgz", @@ -10721,6 +11043,28 @@ "fastq": "^1.6.0" } }, + "@open-draft/deferred-promise": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@open-draft/deferred-promise/-/deferred-promise-2.2.0.tgz", + "integrity": "sha512-CecwLWx3rhxVQF6V4bAgPS5t+So2sTbPgAzafKkVizyi7tlwpcFpdFqq+wqF2OwNBmqFuu6tOyouTuxgpMfzmA==", + "dev": true + }, + "@open-draft/logger": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@open-draft/logger/-/logger-0.3.0.tgz", + "integrity": "sha512-X2g45fzhxH238HKO4xbSr7+wBS8Fvw6ixhTDuvLd5mqh6bJJCFAPwU9mPDxbcrRtfxv4u5IHCEH77BmxvXmmxQ==", + "dev": true, + "requires": { + "is-node-process": "^1.2.0", + "outvariant": "^1.4.0" + } + }, + "@open-draft/until": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@open-draft/until/-/until-2.1.0.tgz", + "integrity": "sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg==", + "dev": true + }, "@sinclair/typebox": { "version": "0.27.8", "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.8.tgz", @@ -10745,6 +11089,122 @@ "@sinonjs/commons": "^3.0.0" } }, + "@swc/core": { + "version": "1.11.8", + "resolved": "https://registry.npmjs.org/@swc/core/-/core-1.11.8.tgz", + "integrity": "sha512-UAL+EULxrc0J73flwYHfu29mO8CONpDJiQv1QPDXsyCvDUcEhqAqUROVTgC+wtJCFFqMQdyr4stAA5/s0KSOmA==", + "dev": true, + "requires": { + "@swc/core-darwin-arm64": "1.11.8", + "@swc/core-darwin-x64": "1.11.8", + "@swc/core-linux-arm-gnueabihf": "1.11.8", + "@swc/core-linux-arm64-gnu": "1.11.8", + "@swc/core-linux-arm64-musl": "1.11.8", + "@swc/core-linux-x64-gnu": "1.11.8", + "@swc/core-linux-x64-musl": "1.11.8", + "@swc/core-win32-arm64-msvc": "1.11.8", + "@swc/core-win32-ia32-msvc": "1.11.8", + "@swc/core-win32-x64-msvc": "1.11.8", + "@swc/counter": "^0.1.3", + "@swc/types": "^0.1.19" + } + }, + "@swc/core-darwin-arm64": { + "version": "1.11.8", + "resolved": "https://registry.npmjs.org/@swc/core-darwin-arm64/-/core-darwin-arm64-1.11.8.tgz", + "integrity": "sha512-rrSsunyJWpHN+5V1zumndwSSifmIeFQBK9i2RMQQp15PgbgUNxHK5qoET1n20pcUrmZeT6jmJaEWlQchkV//Og==", + "dev": true, + "optional": true + }, + "@swc/core-darwin-x64": { + "version": "1.11.8", + "resolved": "https://registry.npmjs.org/@swc/core-darwin-x64/-/core-darwin-x64-1.11.8.tgz", + "integrity": "sha512-44goLqQuuo0HgWnG8qC+ZFw/qnjCVVeqffhzFr9WAXXotogVaxM8ze6egE58VWrfEc8me8yCcxOYL9RbtjhS/Q==", + "dev": true, + "optional": true + }, + "@swc/core-linux-arm-gnueabihf": { + "version": "1.11.8", + "resolved": "https://registry.npmjs.org/@swc/core-linux-arm-gnueabihf/-/core-linux-arm-gnueabihf-1.11.8.tgz", + "integrity": "sha512-Mzo8umKlhTWwF1v8SLuTM1z2A+P43UVhf4R8RZDhzIRBuB2NkeyE+c0gexIOJBuGSIATryuAF4O4luDu727D1w==", + "dev": true, + "optional": true + }, + "@swc/core-linux-arm64-gnu": { + "version": "1.11.8", + "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-gnu/-/core-linux-arm64-gnu-1.11.8.tgz", + "integrity": "sha512-EyhO6U+QdoGYC1MeHOR0pyaaSaKYyNuT4FQNZ1eZIbnuueXpuICC7iNmLIOfr3LE5bVWcZ7NKGVPlM2StJEcgA==", + "dev": true, + "optional": true + }, + "@swc/core-linux-arm64-musl": { + "version": "1.11.8", + "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-musl/-/core-linux-arm64-musl-1.11.8.tgz", + "integrity": "sha512-QU6wOkZnS6/QuBN1MHD6G2BgFxB0AclvTVGbqYkRA7MsVkcC29PffESqzTXnypzB252/XkhQjoB2JIt9rPYf6A==", + "dev": true, + "optional": true + }, + "@swc/core-linux-x64-gnu": { + "version": "1.11.8", + "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-gnu/-/core-linux-x64-gnu-1.11.8.tgz", + "integrity": "sha512-r72onUEIU1iJi9EUws3R28pztQ/eM3EshNpsPRBfuLwKy+qn3et55vXOyDhIjGCUph5Eg2Yn8H3h6MTxDdLd+w==", + "dev": true, + "optional": true + }, + "@swc/core-linux-x64-musl": { + "version": "1.11.8", + "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-musl/-/core-linux-x64-musl-1.11.8.tgz", + "integrity": "sha512-294k8cLpO103++f4ZUEDr3vnBeUfPitW6G0a3qeVZuoXFhFgaW7ANZIWknUc14WiLOMfMecphJAEiy9C8OeYSw==", + "dev": true, + "optional": true + }, + "@swc/core-win32-arm64-msvc": { + "version": "1.11.8", + "resolved": "https://registry.npmjs.org/@swc/core-win32-arm64-msvc/-/core-win32-arm64-msvc-1.11.8.tgz", + "integrity": "sha512-EbjOzQ+B85rumHyeesBYxZ+hq3ZQn+YAAT1ZNE9xW1/8SuLoBmHy/K9YniRGVDq/2NRmp5kI5+5h5TX0asIS9A==", + "dev": true, + "optional": true + }, + "@swc/core-win32-ia32-msvc": { + "version": "1.11.8", + "resolved": "https://registry.npmjs.org/@swc/core-win32-ia32-msvc/-/core-win32-ia32-msvc-1.11.8.tgz", + "integrity": "sha512-Z+FF5kgLHfQWIZ1KPdeInToXLzbY0sMAashjd/igKeP1Lz0qKXVAK+rpn6ASJi85Fn8wTftCGCyQUkRVn0bTDg==", + "dev": true, + "optional": true + }, + "@swc/core-win32-x64-msvc": { + "version": "1.11.8", + "resolved": "https://registry.npmjs.org/@swc/core-win32-x64-msvc/-/core-win32-x64-msvc-1.11.8.tgz", + "integrity": "sha512-j6B6N0hChCeAISS6xp/hh6zR5CSCr037BAjCxNLsT8TGe5D+gYZ57heswUWXRH8eMKiRDGiLCYpPB2pkTqxCSw==", + "dev": true, + "optional": true + }, + "@swc/counter": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/@swc/counter/-/counter-0.1.3.tgz", + "integrity": "sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==", + "dev": true + }, + "@swc/jest": { + "version": "0.2.37", + "resolved": "https://registry.npmjs.org/@swc/jest/-/jest-0.2.37.tgz", + "integrity": "sha512-CR2BHhmXKGxTiFr21DYPRHQunLkX3mNIFGFkxBGji6r9uyIR5zftTOVYj1e0sFNMV2H7mf/+vpaglqaryBtqfQ==", + "dev": true, + "requires": { + "@jest/create-cache-key-function": "^29.7.0", + "@swc/counter": "^0.1.3", + "jsonc-parser": "^3.2.0" + } + }, + "@swc/types": { + "version": "0.1.19", + "resolved": "https://registry.npmjs.org/@swc/types/-/types-0.1.19.tgz", + "integrity": "sha512-WkAZaAfj44kh/UFdAQcrMP1I0nwRqpt27u+08LMBYMqmQfwwMofYoMh/48NGkMMRfC4ynpfwRbJuu8ErfNloeA==", + "dev": true, + "requires": { + "@swc/counter": "^0.1.3" + } + }, "@types/babel__core": { "version": "7.1.16", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.1.16.tgz", @@ -11293,6 +11753,14 @@ } } }, + "axios-retry": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/axios-retry/-/axios-retry-4.5.0.tgz", + "integrity": "sha512-aR99oXhpEDGo0UuAlYcn2iGRds30k366Zfa05XWScR9QaQD4JYiP3/1Qt1u7YlefUOK+cn0CcwoL1oefavQUlQ==", + "requires": { + "is-retry-allowed": "^2.2.0" + } + }, "babel-eslint": { "version": "10.1.0", "resolved": "https://registry.npmjs.org/babel-eslint/-/babel-eslint-10.1.0.tgz", @@ -11440,15 +11908,6 @@ "picocolors": "^1.0.0" } }, - "bs-logger": { - "version": "0.2.6", - "resolved": "https://registry.npmjs.org/bs-logger/-/bs-logger-0.2.6.tgz", - "integrity": "sha512-pd8DCoxmbgc7hyPKOvxtqNcjYoOsABPQdcCUjGp3d42VR2CX1ORhk2A87oqqu5R1kk+76nsxZupkmyd+MVtCog==", - "dev": true, - "requires": { - "fast-json-stable-stringify": "2.x" - } - }, "bser": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/bser/-/bser-2.1.1.tgz", @@ -12847,6 +13306,12 @@ "integrity": "sha512-2z6JzQvZRa9A2Y7xC6dQQm4FSTSTNWjKIYYTt4246eMTJmIo0Q+ZyOsU66X8lxK1AbB92dFeglPLrhwpeRKO6w==", "dev": true }, + "is-node-process": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/is-node-process/-/is-node-process-1.2.0.tgz", + "integrity": "sha512-Vg4o6/fqPxIjtxgUH5QLJhwZ7gW5diGCVlXpuUfELC62CuxM1iHcRe51f2W1FDy04Ai4KJkagKjx3XaqyfRKXw==", + "dev": true + }, "is-number": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", @@ -12869,6 +13334,11 @@ "has-symbols": "^1.0.2" } }, + "is-retry-allowed": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/is-retry-allowed/-/is-retry-allowed-2.2.0.tgz", + "integrity": "sha512-XVm7LOeLpTW4jV19QSH38vkswxoLud8sQ57YwJVTPWdiaI9I8keEhGFpBlslyVsgdQy4Opg8QOLb8YRgsyZiQg==" + }, "is-stream": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", @@ -14150,6 +14620,12 @@ "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", "dev": true }, + "jsonc-parser": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.3.1.tgz", + "integrity": "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==", + "dev": true + }, "jsprim": { "version": "1.4.2", "resolved": "https://registry.npmjs.org/jsprim/-/jsprim-1.4.2.tgz", @@ -14242,12 +14718,6 @@ "integrity": "sha1-4j8/nE+Pvd6HJSnBBxhXoIblzO8=", "dev": true }, - "lodash.memoize": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/lodash.memoize/-/lodash.memoize-4.1.2.tgz", - "integrity": "sha1-vMbEmkKihA7Zl/Mj6tpezRguC/4=", - "dev": true - }, "lodash.merge": { "version": "4.6.2", "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", @@ -14294,12 +14764,6 @@ "semver": "^5.6.0" } }, - "make-error": { - "version": "1.3.6", - "resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz", - "integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==", - "dev": true - }, "makeerror": { "version": "1.0.12", "resolved": "https://registry.npmjs.org/makeerror/-/makeerror-1.0.12.tgz", @@ -14383,6 +14847,17 @@ "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==", "dev": true }, + "nock": { + "version": "14.0.1", + "resolved": "https://registry.npmjs.org/nock/-/nock-14.0.1.tgz", + "integrity": "sha512-IJN4O9pturuRdn60NjQ7YkFt6Rwei7ZKaOwb1tvUIIqTgeD0SDDAX3vrqZD4wcXczeEy/AsUXxpGpP/yHqV7xg==", + "dev": true, + "requires": { + "@mswjs/interceptors": "^0.37.3", + "json-stringify-safe": "^5.0.1", + "propagate": "^2.0.0" + } + }, "node-fetch": { "version": "2.6.7", "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.7.tgz", @@ -14515,6 +14990,12 @@ "mimic-fn": "^2.1.0" } }, + "outvariant": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/outvariant/-/outvariant-1.4.3.tgz", + "integrity": "sha512-+Sl2UErvtsoajRDKCE5/dBz4DIvHXQQnAxtQTF04OJxY0+DyZXSo5P5Bb7XYWOh81syohlYL24hbDwxedPUJCA==", + "dev": true + }, "p-limit": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", @@ -14690,6 +15171,12 @@ "sisteransi": "^1.0.5" } }, + "propagate": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/propagate/-/propagate-2.0.1.tgz", + "integrity": "sha512-vGrhOavPSTz4QVNuBNdcNXePNdNMaO1xj9yBeH1ScQPjk/rhg9sSlCXPhMkFuaNNW/syTvYqsnbIJxMBfRbbag==", + "dev": true + }, "proxy-from-env": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", @@ -15188,6 +15675,12 @@ } } }, + "strict-event-emitter": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/strict-event-emitter/-/strict-event-emitter-0.5.1.tgz", + "integrity": "sha512-vMgjE/GGEPEFnhFub6pa4FmJBRBVOLpIII2hvCZ8Kzb7K0hlHo7mQv6xYrBvCL2LtAIBwFUK8wvuJgTVSQ5MFQ==", + "dev": true + }, "string-length": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/string-length/-/string-length-4.0.2.tgz", @@ -15345,33 +15838,6 @@ "is-number": "^7.0.0" } }, - "ts-jest": { - "version": "29.1.1", - "resolved": "https://registry.npmjs.org/ts-jest/-/ts-jest-29.1.1.tgz", - "integrity": "sha512-D6xjnnbP17cC85nliwGiL+tpoKN0StpgE0TeOjXQTU6MVCfsB4v7aW05CgQ/1OywGb0x/oy9hHFnN+sczTiRaA==", - "dev": true, - "requires": { - "bs-logger": "0.x", - "fast-json-stable-stringify": "2.x", - "jest-util": "^29.0.0", - "json5": "^2.2.3", - "lodash.memoize": "4.x", - "make-error": "1.x", - "semver": "^7.5.3", - "yargs-parser": "^21.0.1" - }, - "dependencies": { - "semver": { - "version": "7.5.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.5.4.tgz", - "integrity": "sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA==", - "dev": true, - "requires": { - "lru-cache": "^6.0.0" - } - } - } - }, "tsconfig-paths": { "version": "3.9.0", "resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-3.9.0.tgz", diff --git a/package.json b/package.json index d89e069..1a351ba 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@opengovsg/formsg-sdk", - "version": "0.13.0", + "version": "1.0.0-alpha.0", "repository": { "type": "git", "url": "https://github.com/opengovsg/formsg-javascript-sdk.git" @@ -9,9 +9,9 @@ "main": "./dist/index.js", "types": "./dist/index.d.ts", "scripts": { - "test": "NODE_OPTIONS=\"--max-old-space-size=8192\" jest", - "test-ci": "jest --coverage", - "test-watch": "jest --watch", + "test": "jest --logHeapUsage", + "test-ci": "jest --coverage --logHeapUsage", + "test-watch": "jest --watch --logHeapUsage", "build": "tsc", "prepare": "npm run build", "version": "auto-changelog -p && git add CHANGELOG.md" @@ -25,6 +25,7 @@ "license": "MIT", "dependencies": { "axios": "^1.6.4", + "axios-retry": "^4.5.0", "tweetnacl": "^1.0.3", "tweetnacl-util": "^0.15.1" }, @@ -32,6 +33,8 @@ "@babel/cli": "^7.8.4", "@babel/core": "^7.9.0", "@babel/preset-env": "^7.9.0", + "@swc/core": "^1.11.8", + "@swc/jest": "^0.2.37", "@types/jest": "^29.5.8", "@types/node": "^18.18.9", "@typescript-eslint/eslint-plugin": "^4.25.0", @@ -45,7 +48,7 @@ "eslint-plugin-typesafe": "^0.5.2", "jest": "^29.7.0", "jest-mock-axios": "^4.7.3", - "ts-jest": "^29.1.1", + "nock": "^14.0.1", "typescript": "^4.9.5" } } diff --git a/spec/crypto.spec.ts b/spec/crypto.spec.ts index 2ba29ca..f677911 100644 --- a/spec/crypto.spec.ts +++ b/spec/crypto.spec.ts @@ -2,9 +2,7 @@ import mockAxios from 'jest-mock-axios' import Crypto from '../src/crypto' import { SIGNING_KEYS } from '../src/resource/signing-keys' -import { - encodeBase64, -} from 'tweetnacl-util' +import { encodeBase64 } from 'tweetnacl-util' import { plaintext, @@ -27,7 +25,9 @@ jest.mock('axios', () => mockAxios) describe('Crypto', function () { afterEach(() => mockAxios.reset()) - const crypto = new Crypto({ signingPublicKey: encryptionPublicKey }) + const crypto = new Crypto({ + getSigningPublicKeys: () => Promise.resolve([encryptionPublicKey]), + }) const mockVerifiedContent = { uinFin: 'S12345679Z', @@ -40,27 +40,27 @@ describe('Crypto', function () { expect(keypair).toHaveProperty('publicKey') }) - it('should generate a keypair that is valid', () => { + it('should generate a keypair that is valid', async () => { const { publicKey, secretKey } = crypto.generate() - expect(crypto.valid(publicKey, secretKey)).toBe(true) + expect(await crypto.valid(publicKey, secretKey)).toBe(true) }) - it('should validate an existing keypair', () => { - expect(crypto.valid(formPublicKey, formSecretKey)).toBe(true) + it('should validate an existing keypair', async () => { + expect(await crypto.valid(formPublicKey, formSecretKey)).toBe(true) }) - it('should invalidate unassociated keypairs', () => { + it('should invalidate unassociated keypairs', async () => { // Act const { secretKey } = crypto.generate() const { publicKey } = crypto.generate() // Assert - expect(crypto.valid(publicKey, secretKey)).toBe(false) + expect(await crypto.valid(publicKey, secretKey)).toBe(false) }) - it('should decrypt the submission ciphertext from 2020-03-22 successfully', () => { + it('should decrypt the submission ciphertext from 2020-03-22 successfully', async () => { // Act - const decrypted = crypto.decrypt(formSecretKey, { + const decrypted = await crypto.decrypt(formSecretKey, { encryptedContent: ciphertext, version: INTERNAL_TEST_VERSION, }) @@ -69,16 +69,16 @@ describe('Crypto', function () { expect(decrypted).toHaveProperty('responses', plaintext) }) - it('should return null on unsuccessful decryption', () => { + it('should return null on unsuccessful decryption', async () => { expect( - crypto.decrypt('random', { + await crypto.decrypt('random', { encryptedContent: ciphertext, version: INTERNAL_TEST_VERSION, }) ).toBe(null) }) - it('should return null when successfully decrypted content does not fit FormField type shape', () => { + it('should return null when successfully decrypted content does not fit FormField type shape', async () => { // Arrange const { publicKey, secretKey } = crypto.generate() const malformedContent = 'just a string, not an object with FormField shape' @@ -88,20 +88,20 @@ describe('Crypto', function () { // Using correct secret key, but the decrypted object should not fit the // expected shape and thus return null. expect( - crypto.decrypt(secretKey, { + await crypto.decrypt(secretKey, { encryptedContent: malformedEncrypt, version: INTERNAL_TEST_VERSION, }) ).toBe(null) }) - it('should be able to encrypt and decrypt submissions from 2020-03-22 end-to-end successfully', () => { + it('should be able to encrypt and decrypt submissions from 2020-03-22 end-to-end successfully', async () => { // Arrange const { publicKey, secretKey } = crypto.generate() // Act const ciphertext = crypto.encrypt(plaintext, publicKey) - const decrypted = crypto.decrypt(secretKey, { + const decrypted = await crypto.decrypt(secretKey, { encryptedContent: ciphertext, version: INTERNAL_TEST_VERSION, }) @@ -109,13 +109,13 @@ describe('Crypto', function () { expect(decrypted).toHaveProperty('responses', plaintext) }) - it('should be able to encrypt and decrypt multi-language submission from 2020-06-04 end-to-end successfully', () => { + it('should be able to encrypt and decrypt multi-language submission from 2020-06-04 end-to-end successfully', async () => { // Arrange const { publicKey, secretKey } = crypto.generate() // Act const ciphertext = crypto.encrypt(plaintextMultiLang, publicKey) - const decrypted = crypto.decrypt(secretKey, { + const decrypted = await crypto.decrypt(secretKey, { encryptedContent: ciphertext, version: INTERNAL_TEST_VERSION, }) @@ -123,29 +123,29 @@ describe('Crypto', function () { expect(decrypted).toHaveProperty('responses', plaintextMultiLang) }) - it('should be able to encrypt and decrypt submissions with empty field titles from 2022-11-14 end-to-end successfully', () => { + it('should be able to encrypt and decrypt submissions with empty field titles from 2022-11-14 end-to-end successfully', async () => { // Arrange const { publicKey, secretKey } = crypto.generate() // Act const ciphertext = crypto.encrypt(plaintextEmptyTitles, publicKey) - const decrypted = crypto.decrypt(secretKey, { + const decrypted = await crypto.decrypt(secretKey, { encryptedContent: ciphertext, version: INTERNAL_TEST_VERSION, }) - + // Assert expect(decrypted).toHaveProperty('responses', plaintextEmptyTitles) }) - it('should be able to encrypt submissions without signing if signingPrivateKey is missing', () => { + it('should be able to encrypt submissions without signing if signingPrivateKey is missing', async () => { // Arrange const { publicKey, secretKey } = crypto.generate() // Act // Signing key (last parameter) is omitted. const ciphertext = crypto.encrypt(plaintext, publicKey) - const decrypted = crypto.decrypt(secretKey, { + const decrypted = await crypto.decrypt(secretKey, { encryptedContent: ciphertext, version: INTERNAL_TEST_VERSION, }) @@ -154,7 +154,7 @@ describe('Crypto', function () { expect(decrypted).toHaveProperty('responses', plaintext) }) - it('should be able to encrypt and sign submissions if signingPrivateKey is given', () => { + it('should be able to encrypt and sign submissions if signingPrivateKey is given', async () => { // Arrange const { publicKey, secretKey } = crypto.generate() @@ -168,7 +168,7 @@ describe('Crypto', function () { signingSecretKey ) // Decrypt encrypted content along with our signed+encrypted content. - const decrypted = crypto.decrypt(secretKey, { + const decrypted = await crypto.decrypt(secretKey, { encryptedContent: ciphertext, verifiedContent: signedAndEncryptedText, version: INTERNAL_TEST_VERSION, @@ -213,7 +213,7 @@ describe('Crypto', function () { expect(decrypted).toBeNull() }) - it('should throw error if class was not instantiated with a public signing key while verifying decrypted content ', () => { + it('should throw error if class was not instantiated with a public signing key while verifying decrypted content ', async () => { // Arrange const cryptoNoKey = new Crypto() const { publicKey, secretKey } = cryptoNoKey.generate() @@ -231,16 +231,16 @@ describe('Crypto', function () { // Assert // Attempt to decrypt encrypted content along with our signed+encrypted // content should throw an error - expect(() => + await expect( cryptoNoKey.decrypt(secretKey, { encryptedContent: ciphertext, verifiedContent: signedAndEncryptedText, version: INTERNAL_TEST_VERSION, }) - ).toThrow(MissingPublicKeyError) + ).rejects.toThrow(MissingPublicKeyError) }) - it('should return null if decrypting encrypted verified content failed', () => { + it('should return null if decrypting encrypted verified content failed', async () => { // Arrange const { publicKey, secretKey } = crypto.generate() // Encrypt content that is not signed. @@ -249,7 +249,7 @@ describe('Crypto', function () { const rubbishVerifiedContent = 'abcdefg' // Act + Assert - const decryptResult = crypto.decrypt(secretKey, { + const decryptResult = await crypto.decrypt(secretKey, { encryptedContent: ciphertext, verifiedContent: rubbishVerifiedContent, version: INTERNAL_TEST_VERSION, @@ -277,22 +277,31 @@ describe('Crypto', function () { const uploadedFile = { submissionPublicKey: encryptedFile.submissionPublicKey, nonce: encryptedFile.nonce, - binary: encodeBase64(encryptedFile.binary) + binary: encodeBase64(encryptedFile.binary), } // Act const decryptedFilesPromise = crypto.decryptWithAttachments(secretKey, { encryptedContent: ciphertext, - attachmentDownloadUrls: { '6e771c946b3c5100240368e5': 'https://some.s3.url/some/encrypted/file' }, + attachmentDownloadUrls: { + '6e771c946b3c5100240368e5': 'https://some.s3.url/some/encrypted/file', + }, version: INTERNAL_TEST_VERSION, }) - mockAxios.mockResponse({ data: { encryptedFile: uploadedFile }}) + await Promise.resolve() // Wait for the request to be initiated + mockAxios.mockResponse({ data: { encryptedFile: uploadedFile } }) const decryptedContentWithAttachments = await decryptedFilesPromise const decryptedFiles = decryptedContentWithAttachments!.attachments // Assert - expect(mockAxios.get).toHaveBeenCalledWith('https://some.s3.url/some/encrypted/file', { responseType: 'json' }) - expect(decryptedFiles).toHaveProperty('6e771c946b3c5100240368e5', { filename: 'my-random-file.txt', content: testFileBuffer }) + expect(mockAxios.get).toHaveBeenCalledWith( + 'https://some.s3.url/some/encrypted/file', + { responseType: 'json' } + ) + expect(decryptedFiles).toHaveProperty('6e771c946b3c5100240368e5', { + filename: 'my-random-file.txt', + content: testFileBuffer, + }) }) it('should be able to handle fields without attachmentDownloadUrls', async () => { @@ -303,10 +312,13 @@ describe('Crypto', function () { const ciphertext = crypto.encrypt(plaintext, publicKey) // Act - const decryptedContentWithAttachments = await crypto.decryptWithAttachments(secretKey, { - encryptedContent: ciphertext, - version: INTERNAL_TEST_VERSION, - }) + const decryptedContentWithAttachments = await crypto.decryptWithAttachments( + secretKey, + { + encryptedContent: ciphertext, + version: INTERNAL_TEST_VERSION, + } + ) const decryptedFiles = decryptedContentWithAttachments!.attachments // Assert @@ -347,16 +359,19 @@ describe('Crypto', function () { const uploadedFile = { submissionPublicKey: encryptedFile.submissionPublicKey, nonce: encryptedFile.nonce, - binary: 'YmFkZW5jcnlwdGVkY29udGVudHM=', // invalid data + binary: 'YmFkZW5jcnlwdGVkY29udGVudHM=', // invalid data } // Act const decryptedFilesPromise = crypto.decryptWithAttachments(secretKey, { encryptedContent: ciphertext, - attachmentDownloadUrls: { '6e771c946b3c5100240368e5': 'https://some.s3.url/some/encrypted/file' }, + attachmentDownloadUrls: { + '6e771c946b3c5100240368e5': 'https://some.s3.url/some/encrypted/file', + }, version: INTERNAL_TEST_VERSION, }) - mockAxios.mockResponse({ data: { encryptedFile: uploadedFile }}) + await Promise.resolve() // Let the request be initiated + mockAxios.mockResponse({ data: { encryptedFile: uploadedFile } }) const decryptedContents = await decryptedFilesPromise // Assert @@ -376,13 +391,15 @@ describe('Crypto', function () { const uploadedFile = { submissionPublicKey: encryptedFile.submissionPublicKey, nonce: encryptedFile.nonce, - binary: encodeBase64(encryptedFile.binary) + binary: encodeBase64(encryptedFile.binary), } // Act const decryptedFilesPromise = crypto.decryptWithAttachments(secretKey, { encryptedContent: ciphertext, - attachmentDownloadUrls: { '6e771c946b3c5100240368e5': 'https://some.s3.url/some/encrypted/file' }, + attachmentDownloadUrls: { + '6e771c946b3c5100240368e5': 'https://some.s3.url/some/encrypted/file', + }, version: INTERNAL_TEST_VERSION, }) const decryptedContents = await decryptedFilesPromise @@ -409,9 +426,12 @@ describe('Crypto', function () { // Act const decryptedFilesPromise = crypto.decryptWithAttachments(secretKey, { encryptedContent: ciphertext, - attachmentDownloadUrls: { '6e771c946b3c5100240368e5': 'https://some.s3.url/some/encrypted/file' }, + attachmentDownloadUrls: { + '6e771c946b3c5100240368e5': 'https://some.s3.url/some/encrypted/file', + }, version: INTERNAL_TEST_VERSION, }) + await Promise.resolve() // Let the request be initiated mockAxios.mockResponse({ data: {}, status: 404, @@ -420,7 +440,10 @@ describe('Crypto', function () { const decryptedContents = await decryptedFilesPromise // Assert - expect(mockAxios.get).toHaveBeenCalledWith('https://some.s3.url/some/encrypted/file', { responseType: 'json' }) + expect(mockAxios.get).toHaveBeenCalledWith( + 'https://some.s3.url/some/encrypted/file', + { responseType: 'json' } + ) expect(decryptedContents).toBe(null) }) }) diff --git a/spec/init.spec.ts b/spec/init.spec.ts index 43e84d8..4f91265 100644 --- a/spec/init.spec.ts +++ b/spec/init.spec.ts @@ -1,3 +1,4 @@ +import axios from 'axios' import formsg from '../src/index' import { SIGNING_KEYS } from '../src/resource/signing-keys' import { VERIFICATION_KEYS } from '../src/resource/verification-keys' @@ -6,33 +7,41 @@ import { getVerificationPublicKey, } from '../src/util/publicKey' +jest.mock('axios') +const mockedAxios = axios as jest.Mocked + describe('FormSG SDK', () => { + beforeEach(() => { + jest.clearAllMocks() + }) + describe('Initialisation', () => { - it('should be able to initialise without arguments', () => { - const sdk = formsg() - // Should be autopopulated with production public keys. - expect(sdk.crypto.signingPublicKey).toEqual( - SIGNING_KEYS.production.publicKey - ) - expect(sdk.verification.verificationPublicKey).toEqual( - VERIFICATION_KEYS.production.publicKey - ) - expect(sdk.webhooks.publicKey).toEqual(SIGNING_KEYS.production.publicKey) + it('should be able to initialise without arguments', async () => { + const sdk = await formsg() + const [signingKey] = await sdk.crypto.getSigningPublicKeys!() + const [verificationKey] = await sdk.verification + .getVerificationPublicKeys!() + const [webhooksKey] = await sdk.webhooks.getPublicKeys() + + expect(signingKey).toEqual(SIGNING_KEYS.production.publicKey) + expect(verificationKey).toEqual(VERIFICATION_KEYS.production.publicKey) + expect(webhooksKey).toEqual(SIGNING_KEYS.production.publicKey) }) it('should correctly assign given webhook signing key', async () => { const mockSecretKey = 'mock secret key' - const sdk = formsg({ - webhookSecretKey: mockSecretKey, + const sdk = await formsg({ + webhookOptions: { + secretKey: mockSecretKey, + }, }) expect(sdk.webhooks.secretKey).toEqual(mockSecretKey) }) - it('should be able to initialise with valid verification options', () => { - // Arrange + it('should be able to initialise with valid verification options', async () => { const TEST_TRANSACTION_EXPIRY = 10000 - const sdk = formsg({ + const sdk = await formsg({ mode: 'test', verificationOptions: { secretKey: VERIFICATION_KEYS.test.secretKey, @@ -40,9 +49,9 @@ describe('FormSG SDK', () => { }, }) - expect(sdk.verification.verificationPublicKey).toEqual( - VERIFICATION_KEYS.test.publicKey - ) + const [verificationKey] = await sdk.verification + .getVerificationPublicKeys!() + expect(verificationKey).toEqual(VERIFICATION_KEYS.test.publicKey) expect(sdk.verification.verificationSecretKey).toEqual( VERIFICATION_KEYS.test.secretKey ) @@ -85,4 +94,67 @@ describe('FormSG SDK', () => { expect(getSigningPublicKey()).toBe(SIGNING_KEYS.production.publicKey) }) }) + + describe('JWKS Initialization', () => { + const MOCK_JWKS_URL = 'https://test-jwks-endpoint.com/.well-known/jwks.json' + const MOCK_JWKS_RESPONSE = { + keys: [ + { + kty: 'OKP', + kid: 'sig-1', + use: 'sig', + alg: 'EdDSA', + crv: 'Ed25519', + x: 'mock-signing-key', // this will be converted from base64url to base64 + }, + { + kty: 'OKP', + kid: 'verify-1', + use: 'verify', + alg: 'EdDSA', + crv: 'Ed25519', + x: 'mock-verification-key', // this will be converted from base64url to base64 + }, + ], + } + + it('should initialize with JWKS configuration', async () => { + mockedAxios.get.mockResolvedValueOnce({ data: MOCK_JWKS_RESPONSE }) + + const sdk = await formsg({ + jwks: { + url: MOCK_JWKS_URL, + }, + }) + + const [signingKey] = await sdk.crypto.getSigningPublicKeys!() + const [verificationKey] = await sdk.verification + .getVerificationPublicKeys!() + + expect(mockedAxios.get).toHaveBeenCalledWith( + MOCK_JWKS_URL, + expect.any(Object) + ) + expect(signingKey).toBe('mock+signing+key') + expect(verificationKey).toBe('mock+verification+key===') + }) + + it('should fallback to static keys when JWKS endpoint fails', async () => { + mockedAxios.get.mockRejectedValueOnce(new Error('JWKS fetch failed')) + + const sdk = await formsg({ + mode: 'production', + jwks: { + url: MOCK_JWKS_URL, + }, + }) + + const [signingKey] = await sdk.crypto.getSigningPublicKeys!() + const [verificationKey] = await sdk.verification + .getVerificationPublicKeys!() + + expect(signingKey).toBe(SIGNING_KEYS.production.publicKey) + expect(verificationKey).toBe(VERIFICATION_KEYS.production.publicKey) + }) + }) }) diff --git a/spec/util/cache.spec.ts b/spec/util/cache.spec.ts new file mode 100644 index 0000000..2970e2f --- /dev/null +++ b/spec/util/cache.spec.ts @@ -0,0 +1,65 @@ +import { Cache } from '../../src/util/cache' + +describe('Cache', () => { + let now: jest.SpyInstance + + beforeEach(() => { + const initialTime = 1000 + now = jest.spyOn(Date, 'now').mockImplementation(() => initialTime) + }) + + afterEach(() => { + now.mockRestore() + }) + + it('should return null for empty cache', () => { + const cache = new Cache(1000) + expect(cache.get()).toBeNull() + }) + + it('should return cached value within duration', () => { + const cache = new Cache(1000) + cache.set('test-value') + + // Still within cache duration + now.mockImplementation(() => 1500) + expect(cache.get()).toBe('test-value') + }) + + it('should return null for expired cache', () => { + const cache = new Cache(1000) + cache.set('test-value') + + // Advance time well beyond cache duration + now.mockImplementation(() => 2002) + expect(cache.get()).toBeNull() + }) + + it('should update cache value on set', () => { + const cache = new Cache(1000) + cache.set('test-value-1') + expect(cache.get()).toBe('test-value-1') + + cache.set('test-value-2') + expect(cache.get()).toBe('test-value-2') + }) + + it('should clear cache value', () => { + const cache = new Cache(1000) + cache.set('test-value') + expect(cache.get()).toBe('test-value') + + cache.clear() + expect(cache.get()).toBeNull() + }) + + it('should work with different data types', () => { + const numberCache = new Cache(1000) + numberCache.set(123) + expect(numberCache.get()).toBe(123) + + const objectCache = new Cache<{ test: string }>(1000) + objectCache.set({ test: 'value' }) + expect(objectCache.get()).toEqual({ test: 'value' }) + }) +}) diff --git a/spec/util.spec.ts b/spec/util/crypto.spec.ts similarity index 93% rename from spec/util.spec.ts rename to spec/util/crypto.spec.ts index 34636e0..579ab18 100644 --- a/spec/util.spec.ts +++ b/spec/util/crypto.spec.ts @@ -1,4 +1,4 @@ -import { areAttachmentFieldIdsValid } from '../src/util/crypto' +import { areAttachmentFieldIdsValid } from '../../src/util/crypto' describe('utils', () => { describe('areAttachmentFieldIdsValid', () => { it('should return true when all the fieldIds are within the filenames', () => { diff --git a/spec/util/jwks.integration.spec.ts b/spec/util/jwks.integration.spec.ts new file mode 100644 index 0000000..a43a8bf --- /dev/null +++ b/spec/util/jwks.integration.spec.ts @@ -0,0 +1,71 @@ +import nock from 'nock' +import { initJwks, getSigningPublicKeysFromJwks } from '../../src/util/jwks' +import { MOCK_JWKS_URL, MOCK_JWKS_RESPONSE } from './testUtils' + +// mock http response instead of mocking axios, as mocked axios wouldn't be aware of axios-retry +describe('jwks retries', () => { + beforeEach(() => { + nock.cleanAll() + }) + + it('should retry failed requests with exponential backoff and eventually succeed', async () => { + await initJwks({ + url: MOCK_JWKS_URL, + loadOnInit: false, // don't populate cache + requestConfig: { + timeoutMs: 50, + retry: { + maxRetries: 3, + initialBackoffMs: 100, + }, + }, + }) + + // retry scenario + nock('https://test.example.com') + .get('/.well-known/jwks.json') + .times(1) + .replyWithError('Network failure') + nock('https://test.example.com') + .get('/.well-known/jwks.json') + .times(1) + .reply(500, 'Server error') + nock('https://test.example.com') + .get('/.well-known/jwks.json') + .reply(200, MOCK_JWKS_RESPONSE) + + const result = await getSigningPublicKeysFromJwks() + + expect(result).toStrictEqual(['abc+123/test', 'abc+789/test']) + expect(nock.isDone()).toBe(true) + }) + + it('should throw error when all retry attempts fail', async () => { + await initJwks({ + url: MOCK_JWKS_URL, + loadOnInit: false, + requestConfig: { + timeoutMs: 50, + retry: { + maxRetries: 3, + initialBackoffMs: 100, + }, + }, + }) + + nock('https://test.example.com') + .get('/.well-known/jwks.json') + .times(4) // always fail + .reply(500, 'Server error') + + await expect(getSigningPublicKeysFromJwks()).rejects.toThrow( + 'Failed to fetch JWKS: Request failed with status code 500' + ) + expect(nock.isDone()).toBe(true) + }) + + afterEach(() => { + jest.useRealTimers() + nock.cleanAll() + }) +}) diff --git a/spec/util/jwks.spec.ts b/spec/util/jwks.spec.ts new file mode 100644 index 0000000..83b9ace --- /dev/null +++ b/spec/util/jwks.spec.ts @@ -0,0 +1,189 @@ +import mockAxios from 'jest-mock-axios' +import { + initJwks, + getSigningPublicKeysFromJwks, + getVerificationPublicKeysFromJwks, +} from '../../src/util/jwks' +import { DEFAULT_JWKS_TIMEOUT_MS } from '../../src/util/constants' +import { MOCK_JWKS_URL, MOCK_JWKS_RESPONSE } from './testUtils' + +jest.mock('axios', () => mockAxios) + +describe('jwks', () => { + beforeEach(() => { + jest.clearAllMocks() + mockAxios.reset() + }) + + describe('initialization', () => { + it('should not pre-fetch JWKS when loadOnInit is explicitly false', async () => { + await initJwks({ url: MOCK_JWKS_URL, loadOnInit: false }) + expect(mockAxios.get).not.toHaveBeenCalled() + }) + + it('should pre-fetch JWKS by default when loadOnInit is undefined', async () => { + mockAxios.get.mockResolvedValueOnce({ data: MOCK_JWKS_RESPONSE }) + await initJwks({ url: MOCK_JWKS_URL }) + + expect(mockAxios.get).toHaveBeenCalledWith(MOCK_JWKS_URL, { + timeout: DEFAULT_JWKS_TIMEOUT_MS, + }) + }) + + it('should pre-fetch JWKS when loadOnInit is true', async () => { + mockAxios.get.mockResolvedValueOnce({ data: MOCK_JWKS_RESPONSE }) + await initJwks({ url: MOCK_JWKS_URL, loadOnInit: true }) + + expect(mockAxios.get).toHaveBeenCalledWith(MOCK_JWKS_URL, { + timeout: DEFAULT_JWKS_TIMEOUT_MS, + }) + }) + + it('should not throw if pre-fetch fails during initialization', async () => { + mockAxios.get.mockRejectedValue(new Error('Network error')) + + await expect(initJwks({ url: MOCK_JWKS_URL })).resolves.not.toThrow() + }) + + it('should reset cache when reinitializing', async () => { + mockAxios.get.mockResolvedValueOnce({ data: MOCK_JWKS_RESPONSE }) + await initJwks({ url: MOCK_JWKS_URL }) + + // Second initialization should trigger new fetch + mockAxios.get.mockResolvedValueOnce({ data: MOCK_JWKS_RESPONSE }) + await initJwks({ url: MOCK_JWKS_URL }) + + expect(mockAxios.get).toHaveBeenCalledTimes(2) + }) + }) + + it('should fetch and return all signing public keys if keyId is not given', async () => { + mockAxios.get.mockResolvedValueOnce({ data: MOCK_JWKS_RESPONSE }) + await initJwks({ url: MOCK_JWKS_URL }) + const result = await getSigningPublicKeysFromJwks() + + expect(result).toStrictEqual(['abc+123/test', 'abc+789/test']) // converted from base64url to base64 + }) + + it('should fetch and return signing public key by keyId', async () => { + mockAxios.get.mockResolvedValueOnce({ data: MOCK_JWKS_RESPONSE }) + await initJwks({ url: MOCK_JWKS_URL }) + const result = await getSigningPublicKeysFromJwks('some-new-key-id') + + expect(result).toStrictEqual(['abc+789/test']) + }) + + it('should fetch and return verification public key', async () => { + mockAxios.get.mockResolvedValueOnce({ data: MOCK_JWKS_RESPONSE }) + await initJwks({ url: MOCK_JWKS_URL }) + + const result = await getVerificationPublicKeysFromJwks() + expect(result).toStrictEqual(['def+456/test']) + }) + + it('should respect custom timeout', async () => { + const customTimeout = 5000 + mockAxios.get.mockResolvedValueOnce({ data: MOCK_JWKS_RESPONSE }) + + await initJwks({ + url: MOCK_JWKS_URL, + requestConfig: { timeoutMs: customTimeout }, + }) + + expect(mockAxios.get).toHaveBeenCalledWith(MOCK_JWKS_URL, { + timeout: customTimeout, + }) + }) + + it('should respect custom cache duration', async () => { + jest.useFakeTimers() + + const customDuration = 2000 + mockAxios.get.mockResolvedValueOnce({ data: MOCK_JWKS_RESPONSE }) + await initJwks({ url: MOCK_JWKS_URL, cacheDurationMs: customDuration }) + + // Should not call, as still cached + await getSigningPublicKeysFromJwks() + jest.advanceTimersByTime(customDuration + 100) + + mockAxios.get.mockResolvedValueOnce({ data: MOCK_JWKS_RESPONSE }) + await getSigningPublicKeysFromJwks() + + expect(mockAxios.get).toHaveBeenCalledTimes(2) + }) + + it('should use cache for subsequent requests', async () => { + mockAxios.get.mockResolvedValueOnce({ data: MOCK_JWKS_RESPONSE }) + await initJwks({ url: MOCK_JWKS_URL, cacheDurationMs: 20000 }) + + // These should use the cache from initialization + await getSigningPublicKeysFromJwks() + await getSigningPublicKeysFromJwks() + await getSigningPublicKeysFromJwks() + + expect(mockAxios.get).toHaveBeenCalledTimes(1) + }) + + it('should throw error if JWKS not initialized', async () => { + await initJwks(null as any) + await expect(getSigningPublicKeysFromJwks()).rejects.toThrow( + 'JWKS not initialized' + ) + }) + + it('should throw error if key not found', async () => { + mockAxios.get.mockResolvedValueOnce({ + data: { keys: [{ use: 'other' }] }, + }) + await initJwks({ url: MOCK_JWKS_URL }) + await expect(getSigningPublicKeysFromJwks()).rejects.toThrow( + 'No keys with use="sig" found in JWKS' + ) + }) + + it('should force refreshing cache and try refetch if keyId not found', async () => { + mockAxios.get.mockResolvedValueOnce({ data: { keys: [] } }) + mockAxios.get.mockResolvedValueOnce({ data: MOCK_JWKS_RESPONSE }) + await initJwks({ url: MOCK_JWKS_URL }) + expect(await getSigningPublicKeysFromJwks('1-old-key')).toStrictEqual([ + 'abc+123/test', + ]) + }) + + it('should throw error if keyId not found even after refreshing cache', async () => { + mockAxios.get.mockResolvedValue({ data: MOCK_JWKS_RESPONSE }) + await initJwks({ url: MOCK_JWKS_URL }) + await expect( + getSigningPublicKeysFromJwks('nonexistent-key-id') + ).rejects.toThrow('Key with kid="nonexistent-key-id" not found in JWKS') + }) + + it('should throw error on network failure', async () => { + mockAxios.get.mockRejectedValueOnce(new Error('Network error')) + await initJwks({ url: MOCK_JWKS_URL }) + + // Should still fail on subsequent request + mockAxios.get.mockRejectedValueOnce(new Error('Network error')) + await expect(getSigningPublicKeysFromJwks()).rejects.toThrow( + 'Failed to fetch JWKS: Network error' + ) + }) + + it('should throw error when request times out', async () => { + const timeoutError = new Error('timeout of 5000ms exceeded') + timeoutError.name = 'TimeoutError' + + // Every get returns a timeout error, init shouldn't throw + mockAxios.get.mockRejectedValue(timeoutError) + await expect(initJwks({ url: MOCK_JWKS_URL })).resolves.not.toThrow() + + // Should still fail on subsequent request + await expect(getSigningPublicKeysFromJwks()).rejects.toThrow( + 'Failed to fetch JWKS: timeout of 5000ms exceeded' + ) + }) + + afterEach(() => { + jest.useRealTimers() + }) +}) diff --git a/spec/util/keys.spec.ts b/spec/util/keys.spec.ts new file mode 100644 index 0000000..e08080d --- /dev/null +++ b/spec/util/keys.spec.ts @@ -0,0 +1,190 @@ +import { JwksConfig } from '../../src/types' +import { getPublicKeys } from '../../src/util/keys' +import * as jwksModule from '../../src/util/jwks' +import * as publicKeyModule from '../../src/util/publicKey' +import { mock } from 'node:test' + +// Mock imported modules +jest.mock('../../src/util/jwks') +jest.mock('../../src/util/publicKey') + +describe('getPublicKeys', () => { + const mockJwks: JwksConfig = { url: 'https://example.com/jwks.json' } + const mockWebhookPublicKey = 'mock-webhook-public-key' + const mockVerificationPublicKey = 'mock-verification-public-key' + const mockSigningKey = 'mock-signing-key' + const mockVerificationKey = 'mock-verification-key' + const mockJwksSigningKeys = [ + 'mock-jwks-signing-key-1', + 'mock-jwks-signing-key-2', + ] + const mockJwksVerificationKeys = [ + 'mock-jwks-verification-key-1', + 'mock-jwks-verification-key-2', + ] + + beforeEach(() => { + jest.resetAllMocks() + + // Mock jwks module + jest.mocked(jwksModule.initJwks).mockResolvedValue(undefined) + jest + .mocked(jwksModule.getSigningPublicKeysFromJwks) + .mockResolvedValue(mockJwksSigningKeys) + jest + .mocked(jwksModule.getVerificationPublicKeysFromJwks) + .mockResolvedValue(mockJwksVerificationKeys) + + // Mock publicKey module + jest + .mocked(publicKeyModule.getSigningPublicKey) + .mockReturnValue(mockSigningKey) + jest + .mocked(publicKeyModule.getVerificationPublicKey) + .mockReturnValue(mockVerificationKey) + }) + + describe('signingPublicKeys', () => { + it('should return keys from JWKS when JWKS URL is provided', async () => { + const { signingPublicKeys } = await getPublicKeys({ jwks: mockJwks }) + const keys = await signingPublicKeys() + + expect(jwksModule.initJwks).toHaveBeenCalledWith(mockJwks) + expect(jwksModule.getSigningPublicKeysFromJwks).toHaveBeenCalled() + expect(keys).toEqual(mockJwksSigningKeys) + }) + + it('should pass keyId to getSigningPublicKeysFromJwks when provided', async () => { + const keyId = 'test-key-id' + const { signingPublicKeys } = await getPublicKeys({ jwks: mockJwks }) + await signingPublicKeys(keyId) + + expect(jwksModule.getSigningPublicKeysFromJwks).toHaveBeenCalledWith( + keyId + ) + }) + + it('should return webhookPublicKey when JWKS URL is not provided but webhookPublicKey is', async () => { + const { signingPublicKeys } = await getPublicKeys({ + webhookPublicKey: mockWebhookPublicKey, + }) + const keys = await signingPublicKeys() + + expect(jwksModule.initJwks).not.toHaveBeenCalled() + expect(keys).toEqual([mockWebhookPublicKey]) + }) + + it('should return default signing key when neither JWKS URL nor webhookPublicKey are provided', async () => { + const mode = 'production' + const { signingPublicKeys } = await getPublicKeys({ mode: mode }) + const keys = await signingPublicKeys() + + expect(publicKeyModule.getSigningPublicKey).toHaveBeenCalledWith(mode) + expect(keys).toEqual([mockSigningKey]) + }) + + it('should fallback to webhookPublicKey when JWKS retrieval fails', async () => { + ;(jwksModule.getSigningPublicKeysFromJwks as jest.Mock).mockRejectedValue( + new Error('JWKS error') + ) + const { signingPublicKeys } = await getPublicKeys({ + jwks: mockJwks, + webhookPublicKey: mockWebhookPublicKey, + }) + const keys = await signingPublicKeys() + + expect(jwksModule.getSigningPublicKeysFromJwks).toHaveBeenCalled() + expect(keys).toEqual([mockWebhookPublicKey]) + }) + + it('should fallback to default key when JWKS fails and no webhookPublicKey is provided', async () => { + ;(jwksModule.getSigningPublicKeysFromJwks as jest.Mock).mockRejectedValue( + new Error('JWKS error') + ) + const mode = 'production' + const { signingPublicKeys } = await getPublicKeys({ + jwks: mockJwks, + mode: mode, + }) + const keys = await signingPublicKeys() + + expect(jwksModule.getSigningPublicKeysFromJwks).toHaveBeenCalled() + expect(publicKeyModule.getSigningPublicKey).toHaveBeenCalledWith(mode) + expect(keys).toEqual([mockSigningKey]) + }) + }) + + describe('verificationPublicKeys', () => { + it('should return keys from JWKS when JWKS URL is provided', async () => { + const { verificationPublicKeys } = await getPublicKeys({ jwks: mockJwks }) + const keys = await verificationPublicKeys() + + expect(jwksModule.initJwks).toHaveBeenCalledWith(mockJwks) + expect(jwksModule.getVerificationPublicKeysFromJwks).toHaveBeenCalled() + expect(keys).toEqual(mockJwksVerificationKeys) + }) + + it('should pass keyId to getVerificationPublicKeysFromJwks when provided', async () => { + const keyId = 'test-key-id' + const { verificationPublicKeys } = await getPublicKeys({ jwks: mockJwks }) + await verificationPublicKeys(keyId) + + expect(jwksModule.getVerificationPublicKeysFromJwks).toHaveBeenCalledWith( + keyId + ) + }) + + it('should return verificationPublicKey when JWKS URL is not provided but verificationPublicKey is', async () => { + const { verificationPublicKeys } = await getPublicKeys({ + verificationPublicKey: mockVerificationPublicKey, + }) + const keys = await verificationPublicKeys() + + expect(jwksModule.initJwks).not.toHaveBeenCalled() + expect(keys).toEqual([mockVerificationPublicKey]) + }) + + it('should return default verification key when neither JWKS URL nor verificationPublicKey are provided', async () => { + const mode = 'production' + const { verificationPublicKeys } = await getPublicKeys({ mode: mode }) + const keys = await verificationPublicKeys() + + expect(publicKeyModule.getVerificationPublicKey).toHaveBeenCalledWith( + mode + ) + expect(keys).toEqual([mockVerificationKey]) + }) + + it('should fallback to verificationPublicKey when JWKS retrieval fails', async () => { + ;( + jwksModule.getVerificationPublicKeysFromJwks as jest.Mock + ).mockRejectedValue(new Error('JWKS error')) + const { verificationPublicKeys } = await getPublicKeys({ + jwks: mockJwks, + verificationPublicKey: mockVerificationPublicKey, + }) + const keys = await verificationPublicKeys() + + expect(jwksModule.getVerificationPublicKeysFromJwks).toHaveBeenCalled() + expect(keys).toEqual([mockVerificationPublicKey]) + }) + + it('should fallback to default key when JWKS fails and no verificationPublicKey is provided', async () => { + ;( + jwksModule.getVerificationPublicKeysFromJwks as jest.Mock + ).mockRejectedValue(new Error('JWKS error')) + const mode = 'production' + const { verificationPublicKeys } = await getPublicKeys({ + jwks: mockJwks, + mode: mode, + }) + const keys = await verificationPublicKeys() + + expect(jwksModule.getVerificationPublicKeysFromJwks).toHaveBeenCalled() + expect(publicKeyModule.getVerificationPublicKey).toHaveBeenCalledWith( + mode + ) + expect(keys).toEqual([mockVerificationKey]) + }) + }) +}) diff --git a/spec/util/testUtils.ts b/spec/util/testUtils.ts new file mode 100644 index 0000000..5e10911 --- /dev/null +++ b/spec/util/testUtils.ts @@ -0,0 +1,30 @@ +export const MOCK_JWKS_URL = 'https://test.example.com/.well-known/jwks.json' + +export const MOCK_JWKS_RESPONSE = { + keys: [ + { + kty: 'OKP', + kid: '1-old-key', + use: 'sig', + alg: 'EdDSA', + crv: 'Ed25519', + x: 'abc-123_test', // base64url which should be converted to base64 + }, + { + kty: 'OKP', + kid: '2-old-key', + use: 'verify', + alg: 'EdDSA', + crv: 'Ed25519', + x: 'def-456_test', + }, + { + kty: 'OKP', + kid: 'some-new-key-id', + use: 'sig', + alg: 'EdDSA', + crv: 'Ed25519', + x: 'abc-789_test', + }, + ], +} diff --git a/spec/verification/verification.spec.ts b/spec/verification/verification.spec.ts index 02d8eea..38121a2 100644 --- a/spec/verification/verification.spec.ts +++ b/spec/verification/verification.spec.ts @@ -14,6 +14,7 @@ const TEST_PARAMS = { } const TIME = 1588658696255 const VALID_SIGNATURE = `f=formId,v=transactionId,t=${TIME},s=XLF1V4RDu8dEJLq1yK3UN92TwiekVoif7PX4V8cXr5ERfIQXlOcO+ZOFAawawKWhFSqScg5z1Ro+Y+bMeNmRAg==` +const VALID_SIGNATURE_WITH_KEY_ID = `f=formId,v=transactionId,t=${TIME},s=XLF1V4RDu8dEJLq1yK3UN92TwiekVoif7PX4V8cXr5ERfIQXlOcO+ZOFAawawKWhFSqScg5z1Ro+Y+bMeNmRAg==,kid=some-key-id` const INVALID_SIGNATURE = `f=formId,v=transactionId,t=${TIME},s=InvalidSignatureyK3UN92TwiekVoif7PX4V8cXr5ERfIQXlOcO+ZOFAawawKWhFSqScg5z1Ro+Y+bMeNmRAg==` const DEFORMED_SIGNATURE = `abcdefg` @@ -39,26 +40,28 @@ describe('Verification', () => { ) }) - it('should not authenticate if public key is not provided', () => { + it('should not authenticate if public key getter is not provided', async () => { const verification = new Verification({ - // No public key provided. + // No public key getter provided. transactionExpiry: TEST_TRANSACTION_EXPIRY, secretKey: TEST_SECRET_KEY, }) - expect(() => verification.authenticate(VALID_AUTH_PAYLOAD)).toThrow( - MissingPublicKeyError - ) + await expect( + verification.authenticate(VALID_AUTH_PAYLOAD) + ).rejects.toThrow(MissingPublicKeyError) }) - it('should not authenticate if transaction expiry is not provided', () => { + it('should not authenticate if transaction expiry is not provided', async () => { const verification = new Verification({ // No transaction expiry provided. - publicKey: TEST_PUBLIC_KEY, + getVerificationPublicKeys: () => Promise.resolve([TEST_PUBLIC_KEY]), secretKey: TEST_SECRET_KEY, }) - expect(() => verification.authenticate(VALID_AUTH_PAYLOAD)).toThrow( + await expect( + verification.authenticate(VALID_AUTH_PAYLOAD) + ).rejects.toThrow( 'Provide a transaction expiry when when initializing the FormSG SDK to use this function.' ) }) @@ -68,15 +71,13 @@ describe('Verification', () => { const verification = new Verification({ transactionExpiry: TEST_TRANSACTION_EXPIRY, secretKey: TEST_SECRET_KEY, - publicKey: TEST_PUBLIC_KEY, + getVerificationPublicKeys: () => Promise.resolve([TEST_PUBLIC_KEY]), }) let now: jest.MockInstance beforeAll(() => { - now = jest.spyOn(Date, 'now').mockImplementation(() => { - return TIME - }) + now = jest.spyOn(Date, 'now').mockImplementation(() => TIME) }) afterAll(() => { @@ -87,38 +88,44 @@ describe('Verification', () => { expect(verification.generateSignature(TEST_PARAMS)).toBe(VALID_SIGNATURE) }) - it('should successfully authenticate a valid signature', () => { - expect(verification.authenticate(VALID_AUTH_PAYLOAD)).toBe(true) + it('should generate a signature with keyId in the header', () => { + expect( + verification.generateSignature({ ...TEST_PARAMS, keyId: 'some-key-id' }) + ).toBe(VALID_SIGNATURE_WITH_KEY_ID) + }) + + it('should successfully authenticate a valid signature', async () => { + expect(await verification.authenticate(VALID_AUTH_PAYLOAD)).toBe(true) }) - it('should fail to authenticate a valid signature if it is expired', () => { + it('should fail to authenticate a valid signature if it is expired', async () => { const payload = { signatureString: VALID_SIGNATURE, submissionCreatedAt: TIME + TEST_TRANSACTION_EXPIRY * 2000, fieldId: TEST_PARAMS.fieldId, answer: TEST_PARAMS.answer, } - expect(verification.authenticate(payload)).toBe(false) + expect(await verification.authenticate(payload)).toBe(false) }) - it('should fail to authenticate an invalid signature', () => { + it('should fail to authenticate an invalid signature', async () => { const payload = { signatureString: INVALID_SIGNATURE, submissionCreatedAt: TIME + 1, fieldId: TEST_PARAMS.fieldId, answer: TEST_PARAMS.answer, } - expect(verification.authenticate(payload)).toBe(false) + expect(await verification.authenticate(payload)).toBe(false) }) - it('should fail to authenticate a deformed signature', () => { + it('should fail to authenticate a deformed signature', async () => { const payload = { signatureString: DEFORMED_SIGNATURE, submissionCreatedAt: TIME + 1, fieldId: TEST_PARAMS.fieldId, answer: TEST_PARAMS.answer, } - expect(verification.authenticate(payload)).toBe(false) + expect(await verification.authenticate(payload)).toBe(false) }) }) }) diff --git a/spec/webhooks.spec.ts b/spec/webhooks.spec.ts index 44c8987..48dfe2e 100644 --- a/spec/webhooks.spec.ts +++ b/spec/webhooks.spec.ts @@ -11,12 +11,12 @@ describe('Webhooks', () => { const formId = 'someFormId' const webhooks = new Webhooks({ - publicKey: webhooksPublicKey, + getPublicKeys: () => Promise.resolve([webhooksPublicKey]), secretKey: signingSecretKey, }) const webhooksNoSecret = new Webhooks({ - publicKey: webhooksPublicKey, + getPublicKeys: () => Promise.resolve([webhooksPublicKey]), }) /** @@ -34,12 +34,17 @@ describe('Webhooks', () => { /** * Helper method to construct a test header. */ - const constructTestHeader = (epoch: number, signature: string) => { + const constructTestHeader = ( + epoch: number, + signature: string, + keyId?: string + ) => { return webhooks.constructHeader({ epoch, submissionId, formId, signature, + keyId, }) } @@ -57,33 +62,43 @@ describe('Webhooks', () => { ) }) - it('should authenticate a signature that was recently generated', () => { + it('should include kid in X-FormSG-Signature header if keyId is given', () => { + const epoch = 1583136171649 + const signature = 'some-signature' + const header = constructTestHeader(epoch, signature, 'some-new-key-id') + + expect(header).toBe( + `t=1583136171649,s=someSubmissionId,f=someFormId,v1=some-signature,kid=some-new-key-id` + ) + }) + + it('should authenticate a signature that was recently generated', async () => { const epoch = Date.now() const signature = generateTestSignature(epoch) const header = constructTestHeader(epoch, signature) - const authentiateResult = webhooks.authenticate(header, uri) - expect(authentiateResult).toBe(true) + const authenticateResult = await webhooks.authenticate(header, uri) + expect(authenticateResult).toBe(true) }) - it('should reject signatures generated more than 5 minutes ago', () => { + it('should reject signatures generated more than 5 minutes ago', async () => { const epoch = Date.now() - 5 * 60 * 1000 - 1 const signature = generateTestSignature(epoch) const header = constructTestHeader(epoch, signature) - expect(() => webhooks.authenticate(header, uri)).toThrow( + await expect(webhooks.authenticate(header, uri)).rejects.toThrow( WebhookAuthenticateError ) }) - it('should reject invalid signature headers', () => { + it('should reject invalid signature headers', async () => { const invalidHeader = 'invalidHeader' - expect(() => webhooks.authenticate(invalidHeader, uri)).toThrow( + await expect(webhooks.authenticate(invalidHeader, uri)).rejects.toThrow( WebhookAuthenticateError ) }) - it('should reject if signature header cannot be verified', () => { + it('should reject if signature header cannot be verified', async () => { // Create valid header const epoch = Date.now() const signature = generateTestSignature(epoch) @@ -91,10 +106,11 @@ describe('Webhooks', () => { // Create a new Webhook class with a different publicKey const webhooksAlt = new Webhooks({ - publicKey: 'ReObXacwevg7CaNtg5QwvtW32S0V6md15up4szRdWUY=', + getPublicKeys: () => + Promise.resolve(['ReObXacwevg7CaNtg5QwvtW32S0V6md15up4szRdWUY=']), }) - expect(() => webhooksAlt.authenticate(header, uri)).toThrow( + await expect(webhooksAlt.authenticate(header, uri)).rejects.toThrow( WebhookAuthenticateError ) }) @@ -176,7 +192,7 @@ describe('Webhooks', () => { ).toThrow(MissingSecretKeyError) }) - it('should reject signatures generated more than 5 minutes ago', () => { + it('should reject signatures generated more than 5 minutes ago', async () => { const epoch = Date.now() - 5 * 60 * 1000 - 1 // 5min 1s into the past const signature = webhooks.generateSignature({ uri, @@ -191,10 +207,10 @@ describe('Webhooks', () => { signature, }) as string - expect(() => webhooks.authenticate(header, uri)).toThrow() + await expect(webhooks.authenticate(header, uri)).rejects.toThrow() }) - it('should accept signatures generated within 5 minutes', () => { + it('should accept signatures generated within 5 minutes', async () => { const epoch = Date.now() - 5 * 60 * 1000 + 1000 // 4min 59s into the past const signature = webhooks.generateSignature({ uri, @@ -209,10 +225,10 @@ describe('Webhooks', () => { signature, }) as string - expect(() => webhooks.authenticate(header, uri)).not.toThrow() + await expect(webhooks.authenticate(header, uri)).resolves.not.toThrow() }) - it('should authenticate signatures if Form server drifts 4m59s into the future', () => { + it('should authenticate signatures if Form server drifts 4m59s into the future', async () => { const epoch = Date.now() + 5 * 60 * 1000 - 1000 // 4min 59s into the future const signature = webhooks.generateSignature({ uri, @@ -227,10 +243,10 @@ describe('Webhooks', () => { signature, }) as string - expect(() => webhooks.authenticate(header, uri)).not.toThrow() + await expect(webhooks.authenticate(header, uri)).resolves.not.toThrow() }) - it('should reject signatures if Form server drifts 5m1s into the future', () => { + it('should reject signatures if Form server drifts 5m1s into the future', async () => { const epoch = Date.now() + 5 * 60 * 1000 + 1000 // 5min 1s into the future const signature = webhooks.generateSignature({ uri, @@ -245,6 +261,6 @@ describe('Webhooks', () => { signature, }) as string - expect(() => webhooks.authenticate(header, uri)).toThrow() + await expect(webhooks.authenticate(header, uri)).rejects.toThrow() }) }) diff --git a/src/crypto.ts b/src/crypto.ts index 59086d8..ff35bce 100644 --- a/src/crypto.ts +++ b/src/crypto.ts @@ -24,11 +24,15 @@ import { } from './types' export default class Crypto extends CryptoBase { - signingPublicKey?: string + getSigningPublicKeys?: () => Promise - constructor({ signingPublicKey }: { signingPublicKey?: string } = {}) { + constructor({ + getSigningPublicKeys, + }: { + getSigningPublicKeys?: () => Promise + } = {}) { super() - this.signingPublicKey = signingPublicKey + this.getSigningPublicKeys = getSigningPublicKeys } /** @@ -59,13 +63,13 @@ export default class Crypto extends CryptoBase { * @param decryptParams.encryptedContent The encrypted content encoded with base-64. * @param decryptParams.version The version of the payload. Used to determine the decryption process to decrypt the content with. * @param decryptParams.verifiedContent Optional. The encrypted and signed verified content. If given, the signingPublicKey will be used to attempt to open the signed message. - * @returns The decrypted content if successful. Else, null will be returned. - * @throws {MissingPublicKeyError} if a public key is not provided when instantiating this class and is needed for verifying signed content. + * @returns A promise that resolves to the decrypted content if successful. Otherwise, resolves to null. + * @throws {MissingPublicKeyError} if a public key getter is not provided when instantiating this class and is needed for verifying signed content. */ - decrypt = ( + decrypt = async ( formSecretKey: string, decryptParams: DecryptParams - ): DecryptedContent | null => { + ): Promise => { try { const { encryptedContent, verifiedContent } = decryptParams @@ -87,9 +91,17 @@ export default class Crypto extends CryptoBase { } if (verifiedContent) { - if (!this.signingPublicKey) { + if (!this.getSigningPublicKeys) { + throw new MissingPublicKeyError( + 'Public signing key getter must be provided when instantiating the Crypto class in order to verify verified content' + ) + } + + // Get fresh public keys when verifying + const signingPublicKeys = await this.getSigningPublicKeys() + if (!signingPublicKeys || signingPublicKeys.length === 0) { throw new MissingPublicKeyError( - 'Public signing key must be provided when instantiating the Crypto class in order to verify verified content' + 'Public signing keys must be provided when instantiating the Crypto class in order to verify verified content' ) } // Only care if it is the correct shape if verifiedContent exists, since @@ -103,10 +115,27 @@ export default class Crypto extends CryptoBase { // Returns null if decrypting verified content failed. throw new Error('Failed to decrypt verified content') } - const decryptedVerifiedObject = verifySignedMessage( - decryptedVerifiedContent, - this.signingPublicKey - ) + + let decryptedVerifiedObject = null + for (const publicKey of signingPublicKeys) { + try { + decryptedVerifiedObject = verifySignedMessage( + decryptedVerifiedContent, + publicKey + ) + if (decryptedVerifiedObject) { + break + } + } catch (err) { + continue + } + } + + if (!decryptedVerifiedObject) { + throw new Error( + 'Failed to verify signed content with provided public keys' + ) + } returnedObject.verified = decryptedVerifiedObject } @@ -127,20 +156,24 @@ export default class Crypto extends CryptoBase { * Returns true if a pair of public & secret keys are associated with each other * @param publicKey The public key to verify against. * @param secretKey The private key to verify against. + * @returns A promise that resolves to true if the keys are valid, false otherwise. */ - valid = (publicKey: string, secretKey: string) => { - const testResponse: FormField[] = [] - const internalValidationVersion = 1 - - const cipherResponse = this.encrypt(testResponse, publicKey) - // Use toString here since the return should be an empty array. - return ( - testResponse.toString() === - this.decrypt(secretKey, { + valid = async (publicKey: string, secretKey: string): Promise => { + try { + const testResponse: FormField[] = [] + const internalValidationVersion = 1 + + const cipherResponse = this.encrypt(testResponse, publicKey) + const decryptedResponse = await this.decrypt(secretKey, { encryptedContent: cipherResponse, version: internalValidationVersion, - })?.responses.toString() - ) + }) + + // Use toString here since the return should be an empty array. + return decryptedResponse?.responses.toString() === testResponse.toString() + } catch { + return false + } } /** @@ -159,7 +192,7 @@ export default class Crypto extends CryptoBase { const attachmentRecords: EncryptedAttachmentRecords = decryptParams.attachmentDownloadUrls ?? {} - const decryptedContent = this.decrypt(formSecretKey, decryptParams) + const decryptedContent = await this.decrypt(formSecretKey, decryptParams) if (decryptedContent === null) return null // Retrieve all original filenames for attachments for easy lookup diff --git a/src/index.ts b/src/index.ts index 9022d92..fc8670f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,4 +1,4 @@ -import { getSigningPublicKey, getVerificationPublicKey } from './util/publicKey' +import { getPublicKeys } from './util/keys' import Crypto from './crypto' import CryptoV3 from './crypto-v3' import { PackageInitParams } from './types' @@ -13,27 +13,40 @@ import Webhooks from './webhooks' * @param {string?} [config.webhookSecretKey] Optional. base64 secret key for signing webhooks. If provided, enables generating signature and headers to authenticate webhook data. * @param {VerificationOptions?} [config.verificationOptions] Optional. If provided, enables the usage of the verification module. */ -export = function (config: PackageInitParams = {}) { - const { webhookSecretKey, mode, verificationOptions } = config +export = async function (config: PackageInitParams = {}): Promise<{ + webhooks: Webhooks + crypto: Crypto + cryptoV3: CryptoV3 + verification: Verification +}> { + const { webhookOptions, verificationOptions, jwks, mode } = config + /** - * Public key is used for decrypting signed verified content in the `crypto` module, and + * signingPublicKey is used for decrypting signed verified content in the `crypto` module, and * also for verifying webhook signatures' authenticity in the `wehbooks` module. + * + * verificationPublicKey is used for verifying verified field signatures' authenticity in the `verification` module. + * + * Both keys are fetched from the JWKS endpoint if provided, else they are fetched from the static public keys. */ - const signingPublicKey = getSigningPublicKey(mode || 'production') - /** - * Public key is used for verifying verified field signatures' authenticity in the `verification` module. - */ - const verificationPublicKey = getVerificationPublicKey(mode || 'production') + const keyGetters = await getPublicKeys({ + jwks, + webhookPublicKey: webhookOptions?.publicKey, + verificationPublicKey: verificationOptions?.publicKey, + mode, + }) return { webhooks: new Webhooks({ - publicKey: signingPublicKey, - secretKey: webhookSecretKey, + getPublicKeys: keyGetters.signingPublicKeys, + secretKey: webhookOptions?.secretKey, + }), + crypto: new Crypto({ + getSigningPublicKeys: keyGetters.signingPublicKeys, }), - crypto: new Crypto({ signingPublicKey }), cryptoV3: new CryptoV3(), verification: new Verification({ - publicKey: verificationPublicKey, + getVerificationPublicKeys: keyGetters.verificationPublicKeys, secretKey: verificationOptions?.secretKey, transactionExpiry: verificationOptions?.transactionExpiry, }), diff --git a/src/resource/mock-jwks.json b/src/resource/mock-jwks.json new file mode 100644 index 0000000..e06aa6c --- /dev/null +++ b/src/resource/mock-jwks.json @@ -0,0 +1,36 @@ +{ + "keys": [ + { + "kty": "OKP", + "kid": "signing-webhook-key-staging-v2", + "use": "sig", + "alg": "EdDSA", + "crv": "Ed25519", + "x": "NEW_KEY_HERE_IN_BASE64" + }, + { + "kty": "OKP", + "kid": "signing-webhook-key-staging-v1", + "use": "sig", + "alg": "EdDSA", + "crv": "Ed25519", + "x": "rjv41kYqZwcbe3r6ymMEEKQ-Vd-DPuogN-Gzq3lP2Og" + }, + { + "kty": "OKP", + "kid": "signing-otp-key-staging-v2", + "use": "verify", + "alg": "EdDSA", + "crv": "Ed25519", + "x": "NEW_KEY_HERE_IN_BASE64" + }, + { + "kty": "OKP", + "kid": "signing-otp-key-staging-v1", + "use": "verify", + "alg": "EdDSA", + "crv": "Ed25519", + "x": "bDgK1223JbrDNePFIrj7b0z02Z5nSiBzkRYRqDdVPfA" + } + ] +} diff --git a/src/types.ts b/src/types.ts index aa47918..7afb09a 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,10 +1,54 @@ +export type JwksConfig = { + /** URL to fetch JWKS from */ + url: string + /** + * Duration in milliseconds to cache JWKS. + * @default DEFAULT_JWKS_CACHE_DURATION_MS from constants.ts + */ + cacheDurationMs?: number + /** + * Whether to load JWKS during initialization. + * @default true + */ + loadOnInit?: boolean + /** HTTP request configuration for JWKS fetching */ + requestConfig?: { + /** + * Timeout in milliseconds for JWKS fetch request. + * @default DEFAULT_JWKS_TIMEOUT_MS from constants.ts + */ + timeoutMs?: number + retry?: { + /** + * Maximum number of retries for JWKS fetch request. + * @default JWKS_MAX_RETRIES from constants.ts + */ + maxRetries?: number + /** + * Initial backoff duration in milliseconds for JWKS fetch request retries. + * @default JWKS_INITIAL_BACKOFF_MS from constants.ts + */ + initialBackoffMs?: number + } + } +} + export type PackageInitParams = { - /** base64 secret key for signing webhooks. If provided, enables generating signature and headers to authenticate webhook data. */ - webhookSecretKey?: string + webhookOptions?: { + /** base64 secret key for signing webhooks. If provided, enables generating signature and headers to authenticate webhook data. */ + secretKey?: string + publicKey?: string + } /** If provided, enables the usage of the verification module. */ - verificationOptions?: VerificationOptions + verificationOptions?: { + publicKey?: string + secretKey?: string + transactionExpiry?: number + } /** Initializes public key used for verifying and decrypting in this package. If not given, will default to "production". */ mode?: PackageMode + /** JWKS configuration */ + jwks?: JwksConfig } // A field type available in FormSG as a string @@ -130,7 +174,7 @@ export type Keypair = { export type PackageMode = 'staging' | 'production' | 'development' | 'test' export type VerificationOptions = { - publicKey?: string + getVerificationPublicKeys?: (keyId?: string) => Promise secretKey?: string transactionExpiry?: number } @@ -145,6 +189,7 @@ export type VerifiedAnswer = { export type VerificationSignatureOptions = VerifiedAnswer & { transactionId: string formId: string + keyId?: string } // Creating a basestring requires the epoch in addition to signature requirements diff --git a/src/util/base64.ts b/src/util/base64.ts new file mode 100644 index 0000000..e203887 --- /dev/null +++ b/src/util/base64.ts @@ -0,0 +1,16 @@ +/** + * base64 to base64url magic + * - Replaces + with - + * - Replaces / with _ + * - Removes padding (=) + */ +export const toBase64Url = (base64: string): string => { + return base64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '') +} + +/** + * Example: + * const base64 = 'rjv41kYqZwcbe3r6ymMEEKQ+Vd+DPuogN+Gzq3lP2Og=' + * const base64url = toBase64Url(base64) + * // Result: 'rjv41kYqZwcbe3r6ymMEEKQ-Vd-DPuogN-Gzq3lP2Og' + */ diff --git a/src/util/cache.ts b/src/util/cache.ts new file mode 100644 index 0000000..921d752 --- /dev/null +++ b/src/util/cache.ts @@ -0,0 +1,30 @@ +export type CacheData = { + value: T + timestamp: number +} + +export class Cache { + private data: CacheData | null = null + private readonly duration: number + + constructor(cacheDurationMs: number) { + this.duration = cacheDurationMs + } + + get(): T | null { + if (!this.data) return null + if (Date.now() - this.data.timestamp > this.duration) return null + return this.data.value + } + + set(value: T): void { + this.data = { + value, + timestamp: Date.now(), + } + } + + clear(): void { + this.data = null + } +} diff --git a/src/util/constants.ts b/src/util/constants.ts new file mode 100644 index 0000000..bb9a3fd --- /dev/null +++ b/src/util/constants.ts @@ -0,0 +1,9 @@ +// Safe max value to avoid floating point precision issues (Number.MAX_SAFE_INTEGER = 9007199254740991) +export const DEFAULT_JWKS_CACHE_DURATION_MS = 3_600_000 // 1 hour +export const DEFAULT_JWKS_TIMEOUT_MS = 5_000 +export const MAX_CACHE_DURATION_MS = 86_400_000 // 24 hours, current upper bound + +// JWKS retry configuration +export const JWKS_MAX_RETRIES = 3 +export const JWKS_RETRY_STATUS_CODES = [408, 429, 500, 502, 503, 504] +export const JWKS_INITIAL_BACKOFF_MS = 1000 // Initial backoff period for exponential delay diff --git a/src/util/jwks.ts b/src/util/jwks.ts new file mode 100644 index 0000000..34ffdf2 --- /dev/null +++ b/src/util/jwks.ts @@ -0,0 +1,168 @@ +import axios from 'axios' +import axiosRetry from 'axios-retry' + +import { JwksConfig } from '../types' + +import { Cache } from './cache' +import { + DEFAULT_JWKS_CACHE_DURATION_MS, + DEFAULT_JWKS_TIMEOUT_MS, + JWKS_INITIAL_BACKOFF_MS, + JWKS_MAX_RETRIES, + JWKS_RETRY_STATUS_CODES, +} from './constants' + +interface JwksKey { + kty: 'OKP' + kid: string + use: 'sig' | 'verify' + alg: 'EdDSA' + crv: 'Ed25519' + x: string +} + +interface JwksResponse { + keys: JwksKey[] +} + +let jwksCache: Cache | null = null +let jwksConfig: JwksConfig | null = null + +/** + * Convert base64url to standard base64 + * Spec: https://tools.ietf.org/html/rfc4648#section-5 + */ +const base64UrlToBase64 = (base64url: string): string => { + // Convert URL-safe characters back to standard base64 characters + const converted = base64url.replace(/-/g, '+').replace(/_/g, '/') + + // Add padding if necessary + const pad = converted.length % 4 + if (pad) { + return converted + '='.repeat(4 - pad) + } + return converted +} + +const findKeysByUse = ( + jwks: JwksResponse, + use: 'sig' | 'verify', + keyId?: string +): string[] => { + if (keyId) { + const key = jwks.keys.find((k) => k.kid === keyId) + if (!key) { + throw new Error(`Key with kid="${keyId}" not found in JWKS`) + } + return [base64UrlToBase64(key.x)] + } + + const keys = jwks.keys.filter((k) => k.use === use) + if (keys.length === 0) { + throw new Error(`No keys with use="${use}" found in JWKS`) + } + + // Keys should be used in the order they appear in the JWKS response + // Server should return keys in priority order + return keys.map((k) => base64UrlToBase64(k.x)) +} + +const getJwks = async (getJwksOptions?: { + forceCacheRefresh?: boolean +}): Promise => { + if (!jwksConfig) throw new Error('JWKS not initialized') + + const forceCacheRefresh = getJwksOptions?.forceCacheRefresh ?? false + const cached = jwksCache?.get() + if (cached && !forceCacheRefresh) return cached + + if (!jwksCache) { + jwksCache = new Cache( + jwksConfig.cacheDurationMs ?? DEFAULT_JWKS_CACHE_DURATION_MS + ) + } + + try { + // NOTE: is compile-time type assertion, if the endpoint returns a malformed response, it will still throw an error + const { data } = await axios.get(jwksConfig.url, { + timeout: jwksConfig.requestConfig?.timeoutMs ?? DEFAULT_JWKS_TIMEOUT_MS, + }) + + // NOTE: better if we do runtime validation here using zod/ajv, but that would need to import a new dependency + jwksCache.set(data) + + return data + } catch (error) { + throw new Error( + `Failed to fetch JWKS: ${ + error instanceof Error ? error.message : 'Unknown error' + }` + ) + } +} + +export const initJwks = async (config: JwksConfig): Promise => { + jwksConfig = config + jwksCache = null + + if (!jwksConfig) return + + axiosRetry(axios, { + retries: config.requestConfig?.retry?.maxRetries ?? JWKS_MAX_RETRIES, + retryDelay: (...arg) => + axiosRetry.exponentialDelay( + ...arg, + config.requestConfig?.retry?.initialBackoffMs ?? JWKS_INITIAL_BACKOFF_MS + ), + retryCondition: (error) => { + return ( + axiosRetry.isNetworkOrIdempotentRequestError(error) || + JWKS_RETRY_STATUS_CODES.includes(error.response?.status ?? 0) + ) + }, + shouldResetTimeout: true, // each retry will wait for the full timeout duration + }) + + // Default to true if not specified + if (jwksConfig.loadOnInit !== false) { + try { + await getJwks() + } catch (error) { + console.warn('Failed to pre-fetch JWKS during initialization:', error) + } + } +} + +export const getSigningPublicKeysFromJwks = async ( + keyId?: string +): Promise => { + try { + const jwks = await getJwks() + return findKeysByUse(jwks, 'sig', keyId) + } catch (error) { + if (keyId) { + // force a cache refresh and try again in case of stale cache + const refreshedJwks = await getJwks({ forceCacheRefresh: true }) + return findKeysByUse(refreshedJwks, 'sig', keyId) + } + + throw error + } +} + +export const getVerificationPublicKeysFromJwks = async ( + keyId?: string +): Promise => { + try { + const jwks = await getJwks() + return findKeysByUse(jwks, 'verify', keyId) + } catch (error) { + if (keyId) { + // force a cache refresh and try again in case of stale cache + const refreshedJwks = await getJwks({ forceCacheRefresh: true }) + return findKeysByUse(refreshedJwks, 'verify', keyId) + } + + throw error + } +} diff --git a/src/util/keys.ts b/src/util/keys.ts new file mode 100644 index 0000000..ce09ccf --- /dev/null +++ b/src/util/keys.ts @@ -0,0 +1,60 @@ +import { JwksConfig, PackageMode } from '../types' + +import { + getSigningPublicKeysFromJwks, + getVerificationPublicKeysFromJwks, + initJwks, +} from './jwks' +import { getSigningPublicKey, getVerificationPublicKey } from './publicKey' + +export const getPublicKeys = async ({ + jwks, + webhookPublicKey, + verificationPublicKey, + mode, +}: { + jwks?: JwksConfig + webhookPublicKey?: string + verificationPublicKey?: string + mode?: PackageMode +}): Promise<{ + signingPublicKeys: (keyId?: string) => Promise + verificationPublicKeys: (keyId?: string) => Promise +}> => { + if (jwks?.url) { + await initJwks(jwks) + } + + return { + signingPublicKeys: async (keyId?: string) => { + if (jwks?.url) { + try { + return await getSigningPublicKeysFromJwks(keyId) + } catch (error) { + console.warn('Failed to get signing key from JWKS:', error) + } + } + + if (webhookPublicKey) { + return [webhookPublicKey] + } + + return [getSigningPublicKey(mode)] + }, + verificationPublicKeys: async (keyId?: string) => { + if (jwks?.url) { + try { + return await getVerificationPublicKeysFromJwks(keyId) + } catch (error) { + console.warn('Failed to get verification key from JWKS:', error) + } + } + + if (verificationPublicKey) { + return [verificationPublicKey] + } + + return [getVerificationPublicKey(mode)] + }, + } +} diff --git a/src/util/parser.ts b/src/util/parser.ts index b059831..33e2424 100644 --- a/src/util/parser.ts +++ b/src/util/parser.ts @@ -8,6 +8,8 @@ export type HeaderSignature = { s: string // The form ID, usually the MongoDB form ObjectId f: string + // The public key ID used for signing, optional + kid?: string } // The constituents of the verification signature @@ -22,6 +24,8 @@ export type VerificationSignature = { s: string // The form ID, usually the MongoDB form ObjectId f: string + // The public key ID used for signing, optional + kid?: string } /** diff --git a/src/verification/index.ts b/src/verification/index.ts index 54cfb71..317f211 100644 --- a/src/verification/index.ts +++ b/src/verification/index.ts @@ -16,14 +16,18 @@ import { parseVerificationSignature } from '../util/parser' import { formatToBaseString, isSignatureTimeValid } from './utils' export default class Verification { - verificationPublicKey?: string + getVerificationPublicKeys?: (keyId?: string) => Promise verificationSecretKey?: string transactionExpiry?: number - constructor(params?: VerificationOptions) { - this.verificationPublicKey = params?.publicKey - this.verificationSecretKey = params?.secretKey - this.transactionExpiry = params?.transactionExpiry + constructor({ + getVerificationPublicKeys, + secretKey, + transactionExpiry, + }: VerificationOptions) { + this.getVerificationPublicKeys = getVerificationPublicKeys + this.verificationSecretKey = secretKey + this.transactionExpiry = transactionExpiry } /** @@ -35,7 +39,7 @@ export default class Verification { * @param {string} data.answer * @param {string} data.publicKey */ - authenticate = ({ + authenticate = async ({ signatureString, submissionCreatedAt, fieldId, @@ -47,7 +51,7 @@ export default class Verification { ) } - if (!this.verificationPublicKey) { + if (!this.getVerificationPublicKeys) { throw new MissingPublicKeyError() } @@ -57,12 +61,18 @@ export default class Verification { t: time, f: formId, s: signature, + kid: keyId, } = parseVerificationSignature(signatureString) if (!time) { throw new Error('Malformed signature string was passed into function') } + const verificationPublicKeys = await this.getVerificationPublicKeys(keyId) + if (!verificationPublicKeys.length) { + throw new MissingPublicKeyError() + } + if ( isSignatureTimeValid(time, submissionCreatedAt, this.transactionExpiry) ) { @@ -74,11 +84,19 @@ export default class Verification { time, }) - return nacl.sign.detached.verify( - decodeUTF8(data), - decodeBase64(signature), - decodeBase64(this.verificationPublicKey) - ) + // Try each public key until one works + for (const publicKey of verificationPublicKeys) { + if ( + nacl.sign.detached.verify( + decodeUTF8(data), + decodeBase64(signature), + decodeBase64(publicKey) + ) + ) { + return true + } + } + return false } else { console.info( `Signature was expired for signatureString="${signatureString}" signatureDate="${time}" submissionCreatedAt="${submissionCreatedAt}"` @@ -101,6 +119,7 @@ export default class Verification { formId, fieldId, answer, + keyId, }: VerificationSignatureOptions): string => { if (!this.verificationSecretKey) { throw new MissingSecretKeyError( @@ -120,8 +139,14 @@ export default class Verification { decodeUTF8(data), decodeBase64(this.verificationSecretKey) ) - return `f=${formId},v=${transactionId},t=${time},s=${encodeBase64( + + const result = `f=${formId},v=${transactionId},t=${time},s=${encodeBase64( signature )}` + if (keyId) { + return `${result},kid=${keyId}` + } + + return result } } diff --git a/src/webhooks.ts b/src/webhooks.ts index 9a0d7ff..d233448 100644 --- a/src/webhooks.ts +++ b/src/webhooks.ts @@ -6,17 +6,17 @@ import { hasEpochExpired, isSignatureHeaderValid } from './util/webhooks' import { MissingSecretKeyError, WebhookAuthenticateError } from './errors' export default class Webhooks { - publicKey: string + getPublicKeys: (keyId?: string) => Promise secretKey?: string constructor({ - publicKey, + getPublicKeys, secretKey, }: { - publicKey: string + getPublicKeys: (keyId?: string) => Promise secretKey?: string }) { - this.publicKey = publicKey + this.getPublicKeys = getPublicKeys this.secretKey = secretKey } @@ -27,7 +27,7 @@ export default class Webhooks { * @returns true if the header is verified * @throws {WebhookAuthenticateError} If the signature or uri cannot be verified */ - authenticate = (header: string, uri: string) => { + authenticate = async (header: string, uri: string) => { // Parse the header const signatureHeader = parseSignatureHeader(header) const { @@ -35,24 +35,28 @@ export default class Webhooks { t: epoch, s: submissionId, f: formId, + kid: keyId, } = signatureHeader - // Verify signature authenticity - if (!isSignatureHeaderValid(uri, signatureHeader, this.publicKey)) { - throw new WebhookAuthenticateError( - `Signature could not be verified for uri=${uri} submissionId=${submissionId} formId=${formId} epoch=${epoch} signature=${signature}` - ) - } + // Get fresh public keys on each signature verification, and try to get keyId if provided + const publicKeys = await this.getPublicKeys(keyId) - // Verify epoch recency - if (hasEpochExpired(epoch)) { - throw new WebhookAuthenticateError( - `Signature is not recent for uri=${uri} submissionId=${submissionId} formId=${formId} epoch=${epoch} signature=${signature}` - ) + // If keyId isn't provided. Try each public key until one works or all fail + for (const publicKey of publicKeys) { + if (isSignatureHeaderValid(uri, signatureHeader, publicKey)) { + if (!hasEpochExpired(epoch)) { + return true + } + // If epoch expired, no need to try other keys + throw new WebhookAuthenticateError( + `Signature is not recent for uri=${uri} submissionId=${submissionId} formId=${formId} epoch=${epoch} signature=${signature}` + ) + } } - // All checks pass. - return true + throw new WebhookAuthenticateError( + `Signature could not be verified for uri=${uri} submissionId=${submissionId} formId=${formId} epoch=${epoch} signature=${signature}` + ) } /** @@ -107,16 +111,23 @@ export default class Webhooks { submissionId, formId, signature, + keyId, }: { epoch: number submissionId: string formId: string signature: string + keyId?: string }) => { if (!this.secretKey) { throw new MissingSecretKeyError() } - return `t=${epoch},s=${submissionId},f=${formId},v1=${signature}` + const header = `t=${epoch},s=${submissionId},f=${formId},v1=${signature}` + if (keyId) { + return `${header},kid=${keyId}` + } + + return header } } diff --git a/tsconfig.json b/tsconfig.json index 41d0b16..8cb9a6e 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -9,6 +9,7 @@ "types": ["jest", "node"], "esModuleInterop": true, "rootDir": "src", + "resolveJsonModule": true, "lib": ["WebWorker"] }, "include": ["src"],