From 905d28a50c0edb80c530964bf18b4b10ffc40fb8 Mon Sep 17 00:00:00 2001 From: Andreas Engberg Date: Mon, 8 Jun 2026 12:04:10 +0200 Subject: [PATCH 01/17] update gitignore --- .gitignore | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.gitignore b/.gitignore index 1dbb787..0579491 100644 --- a/.gitignore +++ b/.gitignore @@ -27,5 +27,10 @@ dist-ssr *.sln *.sw? +# Firebase service account keys +*service-account*.json +*adminsdk*.json +*-firebase-*.json + # Build files *.tsbuildinfo From c32bccd067438a38a08bde8d7c0828c8830e07b3 Mon Sep 17 00:00:00 2001 From: Andreas Engberg Date: Tue, 9 Jun 2026 13:26:02 +0200 Subject: [PATCH 02/17] project initialization --- .env-sample | 14 + package-lock.json | 3724 +++++++++++++++++++++++++++++++++++++++++++++ package.json | 28 + tsconfig.json | 24 + 4 files changed, 3790 insertions(+) create mode 100644 .env-sample create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 tsconfig.json diff --git a/.env-sample b/.env-sample new file mode 100644 index 0000000..e558e51 --- /dev/null +++ b/.env-sample @@ -0,0 +1,14 @@ +SERVER_API_PATH=/api/v1 + +# Address and port of the frontend http server listening on fetch requests +HTTP_PORT = 25000 + +# Address and port of the backend wormhole server listening for parallel connections +WORMHOLE_PORT = 25001 + +# Admin SDK file paths +ADMIN_AUTH_SDK_FILEPATH = "" +ADMIN_DB_SDK_FILEPATH = "" + +# Realtime database config +DATABASE_FIREBASE_DATABASE_URL = "" diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..d335fd9 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,3724 @@ +{ + "name": "wormhole-server", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "wormhole-server", + "version": "1.0.0", + "dependencies": { + "cors": "^2.8.6", + "dotenv": "^17.4.2", + "express": "^5.2.1", + "firebase-admin": "^13.10.0", + "pino": "^10.3.1", + "zod": "^4.4.3" + }, + "devDependencies": { + "@types/cors": "^2.8.19", + "@types/express": "^5.0.6", + "@types/node": "^25.9.2", + "pino-pretty": "^13.1.3", + "tsx": "^4.19.4", + "typescript": "^6.0.3" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.0.tgz", + "integrity": "sha512-lhRUCeuOyJQURhTxl4WkpFTjIsbDayJHih5kZC1giwE+MhIzAb7mEsQMqMf18rHLsrb5qI1tafG20mLxEWcWlA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.0.tgz", + "integrity": "sha512-wqh0ByljabXLKHeWXYLqoJ5jKC4XBaw6Hk08OfMrCRd2nP2ZQ5eleDZC41XHyCNgktBGYMbqnrJKq/K/lzPMSQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.0.tgz", + "integrity": "sha512-+WzIXQOSaGs33tLEgYPYe/yQHf0WTU0X42Jca3y8NWMbUVhp7rUnw+vAsRC/QiDrdD31IszMrZy+qwPOPjd+rw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.0.tgz", + "integrity": "sha512-+VJggoaKhk2VNNqVL7f6S189UzShHC/mR9EE8rDdSkdpN0KflSwWY/gWjDrNxxisg8Fp1ZCD9jLMo4m0OUfeUA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.0.tgz", + "integrity": "sha512-0T+A9WZm+bZ84nZBtk1ckYsOvyA3x7e2Acj1KdVfV4/2tdG4fzUp91YHx+GArWLtwqp77pBXVCPn2We7Letr0Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.0.tgz", + "integrity": "sha512-fyzLm/DLDl/84OCfp2f/XQ4flmORsjU7VKt8HLjvIXChJoFFOIL6pLJPH4Yhd1n1gGFF9mPwtlN5Wf82DZs+LQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.0.tgz", + "integrity": "sha512-l9GeW5UZBT9k9brBYI+0WDffcRxgHQD8ShN2Ur4xWq/NFzUKm3k5lsH4PdaRgb2w7mI9u61nr2gI2mLI27Nh3Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.0.tgz", + "integrity": "sha512-BXoQai/A0wPO6Es3yFJ7APCiKGc1tdAEOgeTNy3SsB491S3aHn4S4r3e976eUnPdU+NbdtmBuLncYir2tMU9Nw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.0.tgz", + "integrity": "sha512-CjaaREJagqJp7iTaNQjjidaNbCKYcd4IDkzbwwxtSvjI7NZm79qiHc8HqciMddQ6CKvJT6aBd8lO9kN/ZudLlw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.0.tgz", + "integrity": "sha512-RVyzfb3FWsGA55n6WY0MEIEPURL1FcbhFE6BffZEMEekfCzCIMtB5yyDcFnVbTnwk+CLAgTujmV/Lgvih56W+A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.0.tgz", + "integrity": "sha512-KBnSTt1kxl9x70q+ydterVdl+Cn0H18ngRMRCEQfrbqdUuntQQ0LoMZv47uB97NljZFzY6HcfqEZ2SAyIUTQBQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.0.tgz", + "integrity": "sha512-zpSlUce1mnxzgBADvxKXX5sl8aYQHo2ezvMNI8I0lbblJtp8V4odlm3Yzlj7gPyt3T8ReksE6bK+pT3WD+aJRg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.0.tgz", + "integrity": "sha512-2jIfP6mmjkdmeTlsX/9vmdmhBmKADrWqN7zcdtHIeNSCH1SqIoNI63cYsjQR8J+wGa4Y5izRcSHSm8K3QWmk3w==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.0.tgz", + "integrity": "sha512-bc0FE9wWeC0WBm49IQMPSPILRocGTQt3j5KPCA8os6VprfuJ7KD+5PzESSrJ6GmPIPJK965ZJHTUlSA6GNYEhg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.0.tgz", + "integrity": "sha512-SQPZOwoTTT/HXFXQJG/vBX8sOFagGqvZyXcgLA3NhIqcBv1BJU1d46c0rGcrij2B56Z2rNiSLaZOYW5cUk7yLQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.0.tgz", + "integrity": "sha512-SCfR0HN8CEEjnYnySJTd2cw0k9OHB/YFzt5zgJEwa+wL/T/raGWYMBqwDNAC6dqFKmJYZoQBRfHjgwLHGSrn3Q==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.0.tgz", + "integrity": "sha512-us0dSb9iFxIi8srnpl931Nvs65it/Jd2a2K3qs7fz2WfGPHqzfzZTfec7oxZJRNPXPnNYZtanmRc4AL/JwVzHQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.0.tgz", + "integrity": "sha512-CR/RYotgtCKwtftMwJlUU7xCVNg3lMYZ0RzTmAHSfLCXw3NtZtNpswLEj/Kkf6kEL3Gw+BpOekRX0BYCtklhUw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.0.tgz", + "integrity": "sha512-nU1yhmYutL+fQ71Kxnhg8uEOdC0pwEW9entHykTgEbna2pw2dkbFSMeqjjyHZoCmt8SBkOSvV+yNmm94aUrrqw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.0.tgz", + "integrity": "sha512-cXb5vApOsRsxsEl4mcZ1XY3D4DzcoMxR/nnc4IyqYs0rTI8ZKmW6kyyg+11Z8yvgMfAEldKzP7AdP64HnSC/6g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.0.tgz", + "integrity": "sha512-8wZM2qqtv9UP3mzy7HiGYNH/zjTA355mpeuA+859TyR+e+Tc08IHYpLJuMsfpDJwoLo1ikIJI8jC3GFjnRClzA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.0.tgz", + "integrity": "sha512-FLGfyizszcef5C3YtoyQDACyg95+dndv79i2EekILBofh5wpCa1KuBqOWKrEHZg3zrL3t5ouE5jgr94vA+Wb2w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.0.tgz", + "integrity": "sha512-1ZgjUoEdHZZl/YlV76TSCz9Hqj9h9YmMGAgAPYd+q4SicWNX3G5GCyx9uhQWSLcbvPW8Ni7lj4gDa1T40akdlw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.0.tgz", + "integrity": "sha512-Q9StnDmQ/enxnpxCCLSg0oo4+34B9TdXpuyPeTedN/6+iXBJ4J+zwfQI28u/Jl40nOYAxGoNi7mFP40RUtkmUA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.0.tgz", + "integrity": "sha512-zF3ag/gfiCe6U2iczcRzSYJKH1DCI+ByzSENHlM2FcDbEeo5Zd2C86Aq0tKUYAJJ1obRP84ymxIAksZUcdztHA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.0.tgz", + "integrity": "sha512-pEl1bO9mfAmIC+tW5btTmrKaujg3zGtUmWNdCw/xs70FBjwAL3o9OEKNHvNmnyylD6ubxUERiEhdsL0xBQ9efw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@fastify/busboy": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@fastify/busboy/-/busboy-3.2.0.tgz", + "integrity": "sha512-m9FVDXU3GT2ITSe0UaMA5rU3QkfC/UXtCU8y0gSN/GugTqtVldOBWIB5V6V3sbmenVZUIpU6f+mPEO2+m5iTaA==", + "license": "MIT" + }, + "node_modules/@firebase/app-check-interop-types": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/@firebase/app-check-interop-types/-/app-check-interop-types-0.3.4.tgz", + "integrity": "sha512-zz3i6e13B8BfWiLy8MABtTh8aGIACgKbf9UVnyHcWs+yQzJXgQcl8A46b0zfaiJHdQ+niF0ouAfcpuf+3LMPQg==", + "license": "Apache-2.0" + }, + "node_modules/@firebase/app-types": { + "version": "0.9.5", + "resolved": "https://registry.npmjs.org/@firebase/app-types/-/app-types-0.9.5.tgz", + "integrity": "sha512-YevqTjvo7Iujsa9Dwowmd6dSoElhzmD63ZSrq6bzjvQ6POjYgNjOFHLmNIgJs48eNO093NCERibuFnxbfOvU7A==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/logger": "0.5.1" + } + }, + "node_modules/@firebase/auth-interop-types": { + "version": "0.2.5", + "resolved": "https://registry.npmjs.org/@firebase/auth-interop-types/-/auth-interop-types-0.2.5.tgz", + "integrity": "sha512-1Li/YuBDBAXcKv7BzY4U28gontUmAaw53sYiqbaVOMCFb2lFKK/c3CGMUWqtwe7+TXrl3poWnTCL5umYBg85Eg==", + "license": "Apache-2.0" + }, + "node_modules/@firebase/component": { + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/@firebase/component/-/component-0.7.3.tgz", + "integrity": "sha512-wFofIaa2879ogD/WvkjYXJxRmfnL0scen6ORgaC3na1FNOR9ASIUANQdhqQcmWu/h77/pVHY7ch5flewa5Bcew==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/util": "1.15.1", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@firebase/database": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@firebase/database/-/database-1.1.3.tgz", + "integrity": "sha512-XwWCa+E4TvNGpGwXrycLRNfdogADwFcvuhyow6wDWma9W54roaQIhe+4PM0KiLsIftBdSCGI7OKCXrdSRHbIhw==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/app-check-interop-types": "0.3.4", + "@firebase/auth-interop-types": "0.2.5", + "@firebase/component": "0.7.3", + "@firebase/logger": "0.5.1", + "@firebase/util": "1.15.1", + "faye-websocket": "0.11.4", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@firebase/database-compat": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@firebase/database-compat/-/database-compat-2.1.4.tgz", + "integrity": "sha512-3pK35F1MAgmqFJQlf2nhQl44vtAXQO1uaCaQOEUI9kCRtLFqi7N+QRKR7lFZPg+xIZIyubgxQaxY69YgfZRZWg==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.7.3", + "@firebase/database": "1.1.3", + "@firebase/database-types": "1.0.20", + "@firebase/logger": "0.5.1", + "@firebase/util": "1.15.1", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@firebase/database-types": { + "version": "1.0.20", + "resolved": "https://registry.npmjs.org/@firebase/database-types/-/database-types-1.0.20.tgz", + "integrity": "sha512-kegbOk/w8iU64pr0q6k2ItyNGjnQBMHFhwS7ohdWI4W+pc0/zhhdGXTdFj6X1oxItRjPoYOsSQmERgBkn/ihxw==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/app-types": "0.9.5", + "@firebase/util": "1.15.1" + } + }, + "node_modules/@firebase/logger": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/@firebase/logger/-/logger-0.5.1.tgz", + "integrity": "sha512-vZKLsqE1ABOy8OjQiE7cUTFn4gvaqlk88yp8N94Pk/sDpq61YqZGqmVFZTvOyflTwuYFcWirBdYGoJgbDaXKYQ==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@firebase/util": { + "version": "1.15.1", + "resolved": "https://registry.npmjs.org/@firebase/util/-/util-1.15.1.tgz", + "integrity": "sha512-LUdM4Wg7YM9Pq/49nGYySJA0CSQEKnGffFzWV8+6gXN7mGxn+FL1IqvFbuZUtAQcfZgHYDwCE1wwlK7rB7gl2g==", + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@google-cloud/firestore": { + "version": "7.11.6", + "resolved": "https://registry.npmjs.org/@google-cloud/firestore/-/firestore-7.11.6.tgz", + "integrity": "sha512-EW/O8ktzwLfyWBOsNuhRoMi8lrC3clHM5LVFhGvO1HCsLozCOOXRAlHrYBoE6HL42Sc8yYMuCb2XqcnJ4OOEpw==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@opentelemetry/api": "^1.3.0", + "fast-deep-equal": "^3.1.1", + "functional-red-black-tree": "^1.0.1", + "google-gax": "^4.3.3", + "protobufjs": "^7.2.6" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@google-cloud/paginator": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/@google-cloud/paginator/-/paginator-5.0.2.tgz", + "integrity": "sha512-DJS3s0OVH4zFDB1PzjxAsHqJT6sKVbRwwML0ZBP9PbU7Yebtu/7SWMRzvO2J3nUi9pRNITCfu4LJeooM2w4pjg==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "arrify": "^2.0.0", + "extend": "^3.0.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@google-cloud/projectify": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@google-cloud/projectify/-/projectify-4.0.0.tgz", + "integrity": "sha512-MmaX6HeSvyPbWGwFq7mXdo0uQZLGBYCwziiLIGq5JVX+/bdI3SAq6bP98trV5eTWfLuvsMcIC1YJOF2vfteLFA==", + "license": "Apache-2.0", + "optional": true, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@google-cloud/promisify": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@google-cloud/promisify/-/promisify-4.0.0.tgz", + "integrity": "sha512-Orxzlfb9c67A15cq2JQEyVc7wEsmFBmHjZWZYQMUyJ1qivXyMwdyNOs9odi79hze+2zqdTtu1E19IM/FtqZ10g==", + "license": "Apache-2.0", + "optional": true, + "engines": { + "node": ">=14" + } + }, + "node_modules/@google-cloud/storage": { + "version": "7.19.0", + "resolved": "https://registry.npmjs.org/@google-cloud/storage/-/storage-7.19.0.tgz", + "integrity": "sha512-n2FjE7NAOYyshogdc7KQOl/VZb4sneqPjWouSyia9CMDdMhRX5+RIbqalNmC7LOLzuLAN89VlF2HvG8na9G+zQ==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@google-cloud/paginator": "^5.0.0", + "@google-cloud/projectify": "^4.0.0", + "@google-cloud/promisify": "<4.1.0", + "abort-controller": "^3.0.0", + "async-retry": "^1.3.3", + "duplexify": "^4.1.3", + "fast-xml-parser": "^5.3.4", + "gaxios": "^6.0.2", + "google-auth-library": "^9.6.3", + "html-entities": "^2.5.2", + "mime": "^3.0.0", + "p-limit": "^3.0.1", + "retry-request": "^7.0.0", + "teeny-request": "^9.0.0", + "uuid": "^8.0.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@google-cloud/storage/node_modules/gcp-metadata": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-6.1.1.tgz", + "integrity": "sha512-a4tiq7E0/5fTjxPAaH4jpjkSv/uCaU2p5KC6HVGrvl0cDjA8iBZv4vv1gyzlmK0ZUKqwpOyQMKzZQe3lTit77A==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "gaxios": "^6.1.1", + "google-logging-utils": "^0.0.2", + "json-bigint": "^1.0.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@google-cloud/storage/node_modules/google-auth-library": { + "version": "9.15.1", + "resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-9.15.1.tgz", + "integrity": "sha512-Jb6Z0+nvECVz+2lzSMt9u98UsoakXxA2HGHMCxh+so3n90XgYWkq5dur19JAJV7ONiJY22yBTyJB1TSkvPq9Ng==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "base64-js": "^1.3.0", + "ecdsa-sig-formatter": "^1.0.11", + "gaxios": "^6.1.1", + "gcp-metadata": "^6.1.0", + "gtoken": "^7.0.0", + "jws": "^4.0.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@google-cloud/storage/node_modules/google-logging-utils": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/google-logging-utils/-/google-logging-utils-0.0.2.tgz", + "integrity": "sha512-NEgUnEcBiP5HrPzufUkBzJOD/Sxsco3rLNo1F1TNf7ieU8ryUzBhqba8r756CjLX7rn3fHl6iLEwPYuqpoKgQQ==", + "license": "Apache-2.0", + "optional": true, + "engines": { + "node": ">=14" + } + }, + "node_modules/@grpc/grpc-js": { + "version": "1.14.4", + "resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.14.4.tgz", + "integrity": "sha512-k9Dj3DV/itK9D06Y8f190Qgop7/Ui+D0njFV3LHMPwPT75DpXLQohE9Wmz0QElrJnzsjB7KPWiKJbOl7IPDArQ==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@grpc/proto-loader": "^0.8.0", + "@js-sdsl/ordered-map": "^4.4.2" + }, + "engines": { + "node": ">=12.10.0" + } + }, + "node_modules/@grpc/grpc-js/node_modules/@grpc/proto-loader": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/@grpc/proto-loader/-/proto-loader-0.8.1.tgz", + "integrity": "sha512-wtF6h+DY6M3YaDBPAmvuuA6jV8Sif9MjtOI5euKFWRgCDl5PeDpPsHR9u2l6St5ceY8AZgoNDww5+HvEsXFsGg==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "lodash.camelcase": "^4.3.0", + "long": "^5.0.0", + "protobufjs": "^7.5.5", + "yargs": "^17.7.2" + }, + "bin": { + "proto-loader-gen-types": "build/bin/proto-loader-gen-types.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/@grpc/proto-loader": { + "version": "0.7.15", + "resolved": "https://registry.npmjs.org/@grpc/proto-loader/-/proto-loader-0.7.15.tgz", + "integrity": "sha512-tMXdRCfYVixjuFK+Hk0Q1s38gV9zDiDJfWL3h1rv4Qc39oILCu1TRTDt7+fGUI8K4G1Fj125Hx/ru3azECWTyQ==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "lodash.camelcase": "^4.3.0", + "long": "^5.0.0", + "protobufjs": "^7.2.5", + "yargs": "^17.7.2" + }, + "bin": { + "proto-loader-gen-types": "build/bin/proto-loader-gen-types.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/@js-sdsl/ordered-map": { + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/@js-sdsl/ordered-map/-/ordered-map-4.4.2.tgz", + "integrity": "sha512-iUKgm52T8HOE/makSxjqoWhe95ZJA1/G1sYsGev2JDKUSS14KAgg1LHb+Ba+IPow0xflbnSkOsZcO08C7w1gYw==", + "license": "MIT", + "optional": true, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/js-sdsl" + } + }, + "node_modules/@nodable/entities": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@nodable/entities/-/entities-2.1.1.tgz", + "integrity": "sha512-Pig3HxDIoMgjdEH8OCf/dkcTmLFjJRjWuq8jSnklu284/TKOPibSRERmOykiwmyXTtv61mP+44f3GMx0tLAyjg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/nodable" + } + ], + "license": "MIT", + "optional": true + }, + "node_modules/@opentelemetry/api": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.1.tgz", + "integrity": "sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q==", + "license": "Apache-2.0", + "optional": true, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/@pinojs/redact": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz", + "integrity": "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==", + "license": "MIT" + }, + "node_modules/@protobufjs/aspromise": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", + "integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==", + "license": "BSD-3-Clause", + "optional": true + }, + "node_modules/@protobufjs/base64": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz", + "integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==", + "license": "BSD-3-Clause", + "optional": true + }, + "node_modules/@protobufjs/codegen": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.5.tgz", + "integrity": "sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g==", + "license": "BSD-3-Clause", + "optional": true + }, + "node_modules/@protobufjs/eventemitter": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.1.tgz", + "integrity": "sha512-vW1GmwMZNnL+gMRaovlh9yZX74kc+TTU3FObkkurpMaRtBfLP3ldjS9KQWlwZgraRE0+dheEEoAxdzcJQ8eXZg==", + "license": "BSD-3-Clause", + "optional": true + }, + "node_modules/@protobufjs/fetch": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.1.tgz", + "integrity": "sha512-GpptLrs57adMSuHi3VNj0mAF8dwh36LMaYF6XyJ6JMWlVsc+t42tm1HSEDmOs3A8fC9yyeisgLhsTVQokOZ0zw==", + "license": "BSD-3-Clause", + "optional": true, + "dependencies": { + "@protobufjs/aspromise": "^1.1.1" + } + }, + "node_modules/@protobufjs/float": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz", + "integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==", + "license": "BSD-3-Clause", + "optional": true + }, + "node_modules/@protobufjs/inquire": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/inquire/-/inquire-1.1.2.tgz", + "integrity": "sha512-pa0vFRuws4wkvaXKK1uXZMAwAX4/t8ANaJo45iw/oQHNQ9q5xUzwgFmVJGXiga2BeN+zpX7Vf9vmsiIa2J+MUw==", + "license": "BSD-3-Clause", + "optional": true + }, + "node_modules/@protobufjs/path": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz", + "integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==", + "license": "BSD-3-Clause", + "optional": true + }, + "node_modules/@protobufjs/pool": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz", + "integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==", + "license": "BSD-3-Clause", + "optional": true + }, + "node_modules/@protobufjs/utf8": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.1.tgz", + "integrity": "sha512-oOAWABowe8EAbMyWKM0tYDKi8Yaox52D+HWZhAIJqQXbqe0xI/GV7FhLWqlEKreMkfDjshR5FKgi3mnle0h6Eg==", + "license": "BSD-3-Clause", + "optional": true + }, + "node_modules/@tootallnate/once": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-2.0.1.tgz", + "integrity": "sha512-HqmEUIGRJ5fSXchkVgR5F7qn48bDBzv0kWj/Kfu5e6uci4UlEeng4331LnBkWffb++Ei3FOVLxo8JJWMFBDMeQ==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 10" + } + }, + "node_modules/@types/body-parser": { + "version": "1.19.6", + "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz", + "integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/connect": "*", + "@types/node": "*" + } + }, + "node_modules/@types/caseless": { + "version": "0.12.5", + "resolved": "https://registry.npmjs.org/@types/caseless/-/caseless-0.12.5.tgz", + "integrity": "sha512-hWtVTC2q7hc7xZ/RLbxapMvDMgUnDvKvMOpKal4DrMyfGBUfB1oKaZlIRr6mJL+If3bAP6sV/QneGzF6tJjZDg==", + "license": "MIT", + "optional": true + }, + "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/cors": { + "version": "2.8.19", + "resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.19.tgz", + "integrity": "sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/express": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/@types/express/-/express-5.0.6.tgz", + "integrity": "sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/body-parser": "*", + "@types/express-serve-static-core": "^5.0.0", + "@types/serve-static": "^2" + } + }, + "node_modules/@types/express-serve-static-core": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-5.1.1.tgz", + "integrity": "sha512-v4zIMr/cX7/d2BpAEX3KNKL/JrT1s43s96lLvvdTmza1oEvDudCqK9aF/djc/SWgy8Yh0h30TZx5VpzqFCxk5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "@types/qs": "*", + "@types/range-parser": "*", + "@types/send": "*" + } + }, + "node_modules/@types/http-errors": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz", + "integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/jsonwebtoken": { + "version": "9.0.10", + "resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-9.0.10.tgz", + "integrity": "sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==", + "license": "MIT", + "dependencies": { + "@types/ms": "*", + "@types/node": "*" + } + }, + "node_modules/@types/long": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/long/-/long-4.0.2.tgz", + "integrity": "sha512-MqTGEo5bj5t157U6fA/BiDynNkn0YknVdh48CMPkTSpFTVmvao5UQmm7uEF6xBEo7qIMAlY/JSleYaE6VOdpaA==", + "license": "MIT", + "optional": true + }, + "node_modules/@types/ms": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", + "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "25.9.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.2.tgz", + "integrity": "sha512-G05zqtJhcDLb8uslf5EjCxXg9G1KQxiV8OS0R26IC//Eoyitzqe8z37I7cqvnZlrlSfgocQRfSn/AHBZJJFyGw==", + "license": "MIT", + "dependencies": { + "undici-types": ">=7.24.0 <7.24.7" + } + }, + "node_modules/@types/qs": { + "version": "6.15.1", + "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.15.1.tgz", + "integrity": "sha512-GZHUBZR9hckSUhrxmp1nG6NwdpM9fCunJwyThLW1X3AyHgd9IlHb6VANpQQqDr2o/qQp6McZ3y/IA2rVzKzSbw==", + "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/request": { + "version": "2.48.13", + "resolved": "https://registry.npmjs.org/@types/request/-/request-2.48.13.tgz", + "integrity": "sha512-FGJ6udDNUCjd19pp0Q3iTiDkwhYup7J8hpMW9c4k53NrccQFFWKRho6hvtPPEhnXWKvukfwAlB6DbDz4yhH5Gg==", + "license": "MIT", + "optional": true, + "dependencies": { + "@types/caseless": "*", + "@types/node": "*", + "@types/tough-cookie": "*", + "form-data": "^2.5.5" + } + }, + "node_modules/@types/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz", + "integrity": "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/serve-static": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-2.2.0.tgz", + "integrity": "sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/http-errors": "*", + "@types/node": "*" + } + }, + "node_modules/@types/tough-cookie": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/@types/tough-cookie/-/tough-cookie-4.0.5.tgz", + "integrity": "sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA==", + "license": "MIT", + "optional": true + }, + "node_modules/abort-controller": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", + "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==", + "license": "MIT", + "optional": true, + "dependencies": { + "event-target-shim": "^5.0.0" + }, + "engines": { + "node": ">=6.5" + } + }, + "node_modules/accepts": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "license": "MIT", + "dependencies": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "license": "MIT", + "optional": true, + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/arrify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/arrify/-/arrify-2.0.1.tgz", + "integrity": "sha512-3duEwti880xqi4eAMN8AyR4a0ByT90zoYdLlevfrvU43vb0YZwZVfxOgxWrLXXXpyugL0hNZc9G6BiB5B3nUug==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/async-retry": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/async-retry/-/async-retry-1.3.3.tgz", + "integrity": "sha512-wfr/jstw9xNi/0teMHrRW7dsz3Lt5ARhYNZ2ewpadnhaIp5mbALhOAP+EAdsC7t4Z6wqsDVv9+W6gm1Dk9mEyw==", + "license": "MIT", + "optional": true, + "dependencies": { + "retry": "0.13.1" + } + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT", + "optional": true + }, + "node_modules/atomic-sleep": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz", + "integrity": "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==", + "license": "MIT", + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "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/bignumber.js": { + "version": "9.3.1", + "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.3.1.tgz", + "integrity": "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==", + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/body-parser": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz", + "integrity": "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==", + "license": "MIT", + "dependencies": { + "bytes": "^3.1.2", + "content-type": "^1.0.5", + "debug": "^4.4.3", + "http-errors": "^2.0.0", + "iconv-lite": "^0.7.0", + "on-finished": "^2.4.1", + "qs": "^6.14.1", + "raw-body": "^3.0.1", + "type-is": "^2.0.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", + "license": "BSD-3-Clause" + }, + "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.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "license": "ISC", + "optional": true, + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "license": "MIT", + "optional": true + }, + "node_modules/colorette": { + "version": "2.0.20", + "resolved": "https://registry.npmjs.org/colorette/-/colorette-2.0.20.tgz", + "integrity": "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==", + "dev": true, + "license": "MIT" + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "optional": true, + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/content-disposition": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz", + "integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "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.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, + "node_modules/cors": { + "version": "2.8.6", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", + "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/data-uri-to-buffer": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz", + "integrity": "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/dateformat": { + "version": "4.6.3", + "resolved": "https://registry.npmjs.org/dateformat/-/dateformat-4.6.3.tgz", + "integrity": "sha512-2P0p0pFGzHS5EMnhdxQi7aJN+iMheud0UhG4dlE1DLAlvL8JHjJJTX/CSm4JXwV0Ka5nGk3zC5mcb5bUQUxxMA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=0.4.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/dotenv": { + "version": "17.4.2", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.2.tgz", + "integrity": "sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==", + "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/duplexify": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/duplexify/-/duplexify-4.1.3.tgz", + "integrity": "sha512-M3BmBhwJRZsSx38lZyhE53Csddgzl5R7xGJNk7CVddZD6CcmwMCH8J+7AprIrQKH7TonKxaCjcv27Qmf+sQ+oA==", + "license": "MIT", + "optional": true, + "dependencies": { + "end-of-stream": "^1.4.1", + "inherits": "^2.0.3", + "readable-stream": "^3.1.1", + "stream-shift": "^1.0.2" + } + }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, + "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/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT", + "optional": true + }, + "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/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "once": "^1.4.0" + } + }, + "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.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "optional": true, + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.0.tgz", + "integrity": "sha512-sNR9MHpXSUV/XB4zmsFKN+QgVG82Cc7+/aaxJ8Adi8hyOac+EXptIp45QBPaVyX3N70664wRbTcLTOemCAnyqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.0", + "@esbuild/android-arm": "0.28.0", + "@esbuild/android-arm64": "0.28.0", + "@esbuild/android-x64": "0.28.0", + "@esbuild/darwin-arm64": "0.28.0", + "@esbuild/darwin-x64": "0.28.0", + "@esbuild/freebsd-arm64": "0.28.0", + "@esbuild/freebsd-x64": "0.28.0", + "@esbuild/linux-arm": "0.28.0", + "@esbuild/linux-arm64": "0.28.0", + "@esbuild/linux-ia32": "0.28.0", + "@esbuild/linux-loong64": "0.28.0", + "@esbuild/linux-mips64el": "0.28.0", + "@esbuild/linux-ppc64": "0.28.0", + "@esbuild/linux-riscv64": "0.28.0", + "@esbuild/linux-s390x": "0.28.0", + "@esbuild/linux-x64": "0.28.0", + "@esbuild/netbsd-arm64": "0.28.0", + "@esbuild/netbsd-x64": "0.28.0", + "@esbuild/openbsd-arm64": "0.28.0", + "@esbuild/openbsd-x64": "0.28.0", + "@esbuild/openharmony-arm64": "0.28.0", + "@esbuild/sunos-x64": "0.28.0", + "@esbuild/win32-arm64": "0.28.0", + "@esbuild/win32-ia32": "0.28.0", + "@esbuild/win32-x64": "0.28.0" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=6" + } + }, + "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/event-target-shim": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", + "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/express": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", + "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", + "license": "MIT", + "dependencies": { + "accepts": "^2.0.0", + "body-parser": "^2.2.1", + "content-disposition": "^1.0.0", + "content-type": "^1.0.5", + "cookie": "^0.7.1", + "cookie-signature": "^1.2.1", + "debug": "^4.4.0", + "depd": "^2.0.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "finalhandler": "^2.1.0", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "merge-descriptors": "^2.0.0", + "mime-types": "^3.0.0", + "on-finished": "^2.4.1", + "once": "^1.4.0", + "parseurl": "^1.3.3", + "proxy-addr": "^2.0.7", + "qs": "^6.14.0", + "range-parser": "^1.2.1", + "router": "^2.2.0", + "send": "^1.1.0", + "serve-static": "^2.2.0", + "statuses": "^2.0.1", + "type-is": "^2.0.1", + "vary": "^1.1.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/extend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", + "license": "MIT" + }, + "node_modules/farmhash-modern": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/farmhash-modern/-/farmhash-modern-1.1.0.tgz", + "integrity": "sha512-6ypT4XfgqJk/F3Yuv4SX26I3doUjt0GTG4a+JgWxXQpxXzTBq8fPUeGHfcYMMDPHJHm3yPOSjaeBwBGAHWXCdA==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/fast-copy": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/fast-copy/-/fast-copy-4.0.3.tgz", + "integrity": "sha512-58apWr0GUiDFM8+3afrO6eYwJBn9ZAhDOzG3L+/9llab/haCARS2UIfffmOurYLwbgDRs8n0rfr6qAAPEAuAQw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT" + }, + "node_modules/fast-safe-stringify": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz", + "integrity": "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-xml-builder": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.2.0.tgz", + "integrity": "sha512-00aAWieqff+ZJhsXA4g1g7M8k+7AYoMUUHF+/zFb5U6Uv/P0Vl4QZo84/IcufzYalLuEj9928bXN9PbbFzMF0Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "optional": true, + "dependencies": { + "path-expression-matcher": "^1.5.0", + "xml-naming": "^0.1.0" + } + }, + "node_modules/fast-xml-parser": { + "version": "5.8.0", + "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.8.0.tgz", + "integrity": "sha512-6bIM7fsJxeo3uXv7OncQYsBAMPJ7V16Slahl/6M98C/i2q+vB1+4a0MtrvYwDFEUrwDSbAmeLDRXsOBwrL7yAg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "optional": true, + "dependencies": { + "@nodable/entities": "^2.1.0", + "fast-xml-builder": "^1.2.0", + "path-expression-matcher": "^1.5.0", + "strnum": "^2.3.0", + "xml-naming": "^0.1.0" + }, + "bin": { + "fxparser": "src/cli/cli.js" + } + }, + "node_modules/faye-websocket": { + "version": "0.11.4", + "resolved": "https://registry.npmjs.org/faye-websocket/-/faye-websocket-0.11.4.tgz", + "integrity": "sha512-CzbClwlXAuiRQAlUyfqPgvPoNKTckTPGfwZV4ZdAhVcP2lh9KUxJg2b5GkE7XbjKQ3YJnQ9z6D9ntLAlB+tP8g==", + "license": "Apache-2.0", + "dependencies": { + "websocket-driver": ">=0.5.1" + }, + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/fetch-blob": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz", + "integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "paypal", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "dependencies": { + "node-domexception": "^1.0.0", + "web-streams-polyfill": "^3.0.3" + }, + "engines": { + "node": "^12.20 || >= 14.13" + } + }, + "node_modules/finalhandler": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", + "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/firebase-admin": { + "version": "13.10.0", + "resolved": "https://registry.npmjs.org/firebase-admin/-/firebase-admin-13.10.0.tgz", + "integrity": "sha512-rbuCrJvYRwqBqvbccMS8fj/x2zsaMisdf5RQbRzQzr14Rbq9r2UlpuBHqWAwrO6c9dIRF56xF/xoepXsD5yDuQ==", + "license": "Apache-2.0", + "dependencies": { + "@fastify/busboy": "^3.0.0", + "@firebase/database-compat": "^2.0.0", + "@firebase/database-types": "^1.0.6", + "farmhash-modern": "^1.1.0", + "fast-deep-equal": "^3.1.1", + "google-auth-library": "^10.6.1", + "jsonwebtoken": "^9.0.0", + "jwks-rsa": "^3.1.0" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@google-cloud/firestore": "^7.11.0", + "@google-cloud/storage": "^7.19.0" + } + }, + "node_modules/form-data": { + "version": "2.5.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-2.5.5.tgz", + "integrity": "sha512-jqdObeR2rxZZbPSGL+3VckHMYtu+f9//KXBsVny6JSX/pa38Fy+bGjuG8eW/H6USNQWhLi8Num++cU2yOCNz4A==", + "license": "MIT", + "optional": true, + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.35", + "safe-buffer": "^5.2.1" + }, + "engines": { + "node": ">= 0.12" + } + }, + "node_modules/form-data/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", + "optional": true, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/form-data/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", + "optional": true, + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/formdata-polyfill": { + "version": "4.0.10", + "resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz", + "integrity": "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==", + "license": "MIT", + "dependencies": { + "fetch-blob": "^3.1.2" + }, + "engines": { + "node": ">=12.20.0" + } + }, + "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": "2.0.0", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "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/functional-red-black-tree": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/functional-red-black-tree/-/functional-red-black-tree-1.0.1.tgz", + "integrity": "sha512-dsKNQNdj6xA3T+QlADDA7mOSlX0qiMINjn0cgr+eGHGsbSHzTabcIogz2+p/iqP1Xs6EP/sS2SbqH+brGTbq0g==", + "license": "MIT", + "optional": true + }, + "node_modules/gaxios": { + "version": "6.7.1", + "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-6.7.1.tgz", + "integrity": "sha512-LDODD4TMYx7XXdpwxAVRAIAuB0bzv0s+ywFonY46k126qzQHT9ygyoa9tncmOiQmmDrik65UYsEkv3lbfqQ3yQ==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "extend": "^3.0.2", + "https-proxy-agent": "^7.0.1", + "is-stream": "^2.0.0", + "node-fetch": "^2.6.9", + "uuid": "^9.0.1" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/gaxios/node_modules/uuid": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", + "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", + "deprecated": "uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028).", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "optional": true, + "bin": { + "uuid": "dist/bin/uuid" + } + }, + "node_modules/gcp-metadata": { + "version": "8.1.2", + "resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-8.1.2.tgz", + "integrity": "sha512-zV/5HKTfCeKWnxG0Dmrw51hEWFGfcF2xiXqcA3+J90WDuP0SvoiSO5ORvcBsifmx/FoIjgQN3oNOGaQ5PhLFkg==", + "license": "Apache-2.0", + "dependencies": { + "gaxios": "^7.0.0", + "google-logging-utils": "^1.0.0", + "json-bigint": "^1.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/gcp-metadata/node_modules/gaxios": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-7.1.4.tgz", + "integrity": "sha512-bTIgTsM2bWn3XklZISBTQX7ZSddGW+IO3bMdGaemHZ3tbqExMENHLx6kKZ/KlejgrMtj8q7wBItt51yegqalrA==", + "license": "Apache-2.0", + "dependencies": { + "extend": "^3.0.2", + "https-proxy-agent": "^7.0.1", + "node-fetch": "^3.3.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/gcp-metadata/node_modules/node-fetch": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz", + "integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==", + "license": "MIT", + "dependencies": { + "data-uri-to-buffer": "^4.0.0", + "fetch-blob": "^3.1.4", + "formdata-polyfill": "^4.0.10" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/node-fetch" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "license": "ISC", + "optional": true, + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "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/google-auth-library": { + "version": "10.6.2", + "resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-10.6.2.tgz", + "integrity": "sha512-e27Z6EThmVNNvtYASwQxose/G57rkRuaRbQyxM2bvYLLX/GqWZ5chWq2EBoUchJbCc57eC9ArzO5wMsEmWftCw==", + "license": "Apache-2.0", + "dependencies": { + "base64-js": "^1.3.0", + "ecdsa-sig-formatter": "^1.0.11", + "gaxios": "^7.1.4", + "gcp-metadata": "8.1.2", + "google-logging-utils": "1.1.3", + "jws": "^4.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/google-auth-library/node_modules/gaxios": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-7.1.4.tgz", + "integrity": "sha512-bTIgTsM2bWn3XklZISBTQX7ZSddGW+IO3bMdGaemHZ3tbqExMENHLx6kKZ/KlejgrMtj8q7wBItt51yegqalrA==", + "license": "Apache-2.0", + "dependencies": { + "extend": "^3.0.2", + "https-proxy-agent": "^7.0.1", + "node-fetch": "^3.3.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/google-auth-library/node_modules/node-fetch": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz", + "integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==", + "license": "MIT", + "dependencies": { + "data-uri-to-buffer": "^4.0.0", + "fetch-blob": "^3.1.4", + "formdata-polyfill": "^4.0.10" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/node-fetch" + } + }, + "node_modules/google-gax": { + "version": "4.6.1", + "resolved": "https://registry.npmjs.org/google-gax/-/google-gax-4.6.1.tgz", + "integrity": "sha512-V6eky/xz2mcKfAd1Ioxyd6nmA61gao3n01C+YeuIwu3vzM9EDR6wcVzMSIbLMDXWeoi9SHYctXuKYC5uJUT3eQ==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@grpc/grpc-js": "^1.10.9", + "@grpc/proto-loader": "^0.7.13", + "@types/long": "^4.0.0", + "abort-controller": "^3.0.0", + "duplexify": "^4.0.0", + "google-auth-library": "^9.3.0", + "node-fetch": "^2.7.0", + "object-hash": "^3.0.0", + "proto3-json-serializer": "^2.0.2", + "protobufjs": "^7.3.2", + "retry-request": "^7.0.0", + "uuid": "^9.0.1" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/google-gax/node_modules/gcp-metadata": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-6.1.1.tgz", + "integrity": "sha512-a4tiq7E0/5fTjxPAaH4jpjkSv/uCaU2p5KC6HVGrvl0cDjA8iBZv4vv1gyzlmK0ZUKqwpOyQMKzZQe3lTit77A==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "gaxios": "^6.1.1", + "google-logging-utils": "^0.0.2", + "json-bigint": "^1.0.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/google-gax/node_modules/google-auth-library": { + "version": "9.15.1", + "resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-9.15.1.tgz", + "integrity": "sha512-Jb6Z0+nvECVz+2lzSMt9u98UsoakXxA2HGHMCxh+so3n90XgYWkq5dur19JAJV7ONiJY22yBTyJB1TSkvPq9Ng==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "base64-js": "^1.3.0", + "ecdsa-sig-formatter": "^1.0.11", + "gaxios": "^6.1.1", + "gcp-metadata": "^6.1.0", + "gtoken": "^7.0.0", + "jws": "^4.0.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/google-gax/node_modules/google-logging-utils": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/google-logging-utils/-/google-logging-utils-0.0.2.tgz", + "integrity": "sha512-NEgUnEcBiP5HrPzufUkBzJOD/Sxsco3rLNo1F1TNf7ieU8ryUzBhqba8r756CjLX7rn3fHl6iLEwPYuqpoKgQQ==", + "license": "Apache-2.0", + "optional": true, + "engines": { + "node": ">=14" + } + }, + "node_modules/google-gax/node_modules/uuid": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", + "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", + "deprecated": "uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028).", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "optional": true, + "bin": { + "uuid": "dist/bin/uuid" + } + }, + "node_modules/google-logging-utils": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/google-logging-utils/-/google-logging-utils-1.1.3.tgz", + "integrity": "sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA==", + "license": "Apache-2.0", + "engines": { + "node": ">=14" + } + }, + "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/gtoken": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/gtoken/-/gtoken-7.1.0.tgz", + "integrity": "sha512-pCcEwRi+TKpMlxAQObHDQ56KawURgyAf6jtIY046fJ5tIv3zDe/LEIubckAO8fj6JnAxLdmWkUfNyulQ2iKdEw==", + "license": "MIT", + "optional": true, + "dependencies": { + "gaxios": "^6.0.0", + "jws": "^4.0.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "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/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "optional": true, + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/help-me": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/help-me/-/help-me-5.0.0.tgz", + "integrity": "sha512-7xgomUX6ADmcYzFik0HzAxh/73YlKR9bmFzf51CZwR+b6YtzU2m0u49hQCqV6SvlqIqsaxovfwdvbnsw3b/zpg==", + "dev": true, + "license": "MIT" + }, + "node_modules/html-entities": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/html-entities/-/html-entities-2.6.0.tgz", + "integrity": "sha512-kig+rMn/QOVRvr7c86gQ8lWXq+Hkv6CbAH1hLu+RG338StTpE8Z0b44SDVaqVu7HGKf27frdmUYEs9hTUX/cLQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/mdevils" + }, + { + "type": "patreon", + "url": "https://patreon.com/mdevils" + } + ], + "license": "MIT", + "optional": true + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/http-parser-js": { + "version": "0.5.10", + "resolved": "https://registry.npmjs.org/http-parser-js/-/http-parser-js-0.5.10.tgz", + "integrity": "sha512-Pysuw9XpUq5dVc/2SMHpuTY01RFl8fttgcyunjL7eEMhGM3cI4eOmiCycJDVCo/7O7ClfQD3SaI6ftDzqOXYMA==", + "license": "MIT" + }, + "node_modules/http-proxy-agent": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-5.0.0.tgz", + "integrity": "sha512-n2hY8YdoRE1i7r6M0w9DIw5GgZN0G25P8zLCRQ8rjXtTU3vsNFBI/vWK/UIeE6g5MUUz6avwAPXmL6Fy9D/90w==", + "license": "MIT", + "optional": true, + "dependencies": { + "@tootallnate/once": "2", + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/http-proxy-agent/node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/iconv-lite": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", + "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/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-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-promise": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", + "license": "MIT" + }, + "node_modules/is-stream": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", + "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/jose": { + "version": "4.15.9", + "resolved": "https://registry.npmjs.org/jose/-/jose-4.15.9.tgz", + "integrity": "sha512-1vUQX+IdDMVPj4k8kOxgUqlcK518yluMuGZwqlr44FS1ppZB/5GWh4rZG89erpOBOJjU/OBsnCVFfapsRz6nEA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/joycon": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/joycon/-/joycon-3.1.1.tgz", + "integrity": "sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/json-bigint": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-bigint/-/json-bigint-1.0.0.tgz", + "integrity": "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==", + "license": "MIT", + "dependencies": { + "bignumber.js": "^9.0.0" + } + }, + "node_modules/jsonwebtoken": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz", + "integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==", + "license": "MIT", + "dependencies": { + "jws": "^4.0.1", + "lodash.includes": "^4.3.0", + "lodash.isboolean": "^3.0.3", + "lodash.isinteger": "^4.0.4", + "lodash.isnumber": "^3.0.3", + "lodash.isplainobject": "^4.0.6", + "lodash.isstring": "^4.0.1", + "lodash.once": "^4.0.0", + "ms": "^2.1.1", + "semver": "^7.5.4" + }, + "engines": { + "node": ">=12", + "npm": ">=6" + } + }, + "node_modules/jwa": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", + "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jwks-rsa": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/jwks-rsa/-/jwks-rsa-3.2.2.tgz", + "integrity": "sha512-BqTyEDV+lS8F2trk3A+qJnxV5Q9EqKCBJOPti3W97r7qTympCZjb7h2X6f2kc+0K3rsSTY1/6YG2eaXKoj497w==", + "license": "MIT", + "dependencies": { + "@types/jsonwebtoken": "^9.0.4", + "debug": "^4.3.4", + "jose": "^4.15.4", + "limiter": "^1.1.5", + "lru-memoizer": "^2.2.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/jws": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", + "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", + "license": "MIT", + "dependencies": { + "jwa": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/limiter": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/limiter/-/limiter-1.1.5.tgz", + "integrity": "sha512-FWWMIEOxz3GwUI4Ts/IvgVy6LPvoMPgjMdQ185nN6psJyBJ4yOpzqm695/h5umdLJg2vW3GR5iG11MAkR2AzJA==" + }, + "node_modules/lodash.camelcase": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz", + "integrity": "sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==", + "license": "MIT", + "optional": true + }, + "node_modules/lodash.clonedeep": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz", + "integrity": "sha512-H5ZhCF25riFd9uB5UCkVKo61m3S/xZk1x4wA6yp/L3RFP6Z/eHH1ymQcGLo7J3GMPfm0V/7m1tryHuGVxpqEBQ==", + "license": "MIT" + }, + "node_modules/lodash.includes": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", + "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==", + "license": "MIT" + }, + "node_modules/lodash.isboolean": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", + "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==", + "license": "MIT" + }, + "node_modules/lodash.isinteger": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", + "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==", + "license": "MIT" + }, + "node_modules/lodash.isnumber": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz", + "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==", + "license": "MIT" + }, + "node_modules/lodash.isplainobject": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", + "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", + "license": "MIT" + }, + "node_modules/lodash.isstring": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz", + "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==", + "license": "MIT" + }, + "node_modules/lodash.once": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", + "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==", + "license": "MIT" + }, + "node_modules/long": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", + "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", + "license": "Apache-2.0", + "optional": true + }, + "node_modules/lru-cache": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", + "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/lru-memoizer": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/lru-memoizer/-/lru-memoizer-2.3.0.tgz", + "integrity": "sha512-GXn7gyHAMhO13WSKrIiNfztwxodVsP8IoZ3XfrJV4yH2x0/OeTO/FIaAHTY5YekdGgW94njfuKmyyt1E0mR6Ug==", + "license": "MIT", + "dependencies": { + "lodash.clonedeep": "^4.5.0", + "lru-cache": "6.0.0" + } + }, + "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": "1.1.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", + "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/merge-descriptors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/mime": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-3.0.0.tgz", + "integrity": "sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A==", + "license": "MIT", + "optional": true, + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "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/negotiator": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", + "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/node-domexception": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", + "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==", + "deprecated": "Use your platform's native DOMException instead", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "github", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "engines": { + "node": ">=10.5.0" + } + }, + "node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "license": "MIT", + "optional": true, + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-hash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", + "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 6" + } + }, + "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-exit-leak-free": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz", + "integrity": "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "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/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "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-expression-matcher": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.5.0.tgz", + "integrity": "sha512-cbrerZV+6rvdQrrD+iGMcZFEiiSrbv9Tfdkvnusy6y0x0GKBXREFg/Y65GhIfm0tnLntThhzCnfKwp1WRjeCyQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "optional": true, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/path-to-regexp": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", + "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/pino": { + "version": "10.3.1", + "resolved": "https://registry.npmjs.org/pino/-/pino-10.3.1.tgz", + "integrity": "sha512-r34yH/GlQpKZbU1BvFFqOjhISRo1MNx1tWYsYvmj6KIRHSPMT2+yHOEb1SG6NMvRoHRF0a07kCOox/9yakl1vg==", + "license": "MIT", + "dependencies": { + "@pinojs/redact": "^0.4.0", + "atomic-sleep": "^1.0.0", + "on-exit-leak-free": "^2.1.0", + "pino-abstract-transport": "^3.0.0", + "pino-std-serializers": "^7.0.0", + "process-warning": "^5.0.0", + "quick-format-unescaped": "^4.0.3", + "real-require": "^0.2.0", + "safe-stable-stringify": "^2.3.1", + "sonic-boom": "^4.0.1", + "thread-stream": "^4.0.0" + }, + "bin": { + "pino": "bin.js" + } + }, + "node_modules/pino-abstract-transport": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/pino-abstract-transport/-/pino-abstract-transport-3.0.0.tgz", + "integrity": "sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg==", + "license": "MIT", + "dependencies": { + "split2": "^4.0.0" + } + }, + "node_modules/pino-pretty": { + "version": "13.1.3", + "resolved": "https://registry.npmjs.org/pino-pretty/-/pino-pretty-13.1.3.tgz", + "integrity": "sha512-ttXRkkOz6WWC95KeY9+xxWL6AtImwbyMHrL1mSwqwW9u+vLp/WIElvHvCSDg0xO/Dzrggz1zv3rN5ovTRVowKg==", + "dev": true, + "license": "MIT", + "dependencies": { + "colorette": "^2.0.7", + "dateformat": "^4.6.3", + "fast-copy": "^4.0.0", + "fast-safe-stringify": "^2.1.1", + "help-me": "^5.0.0", + "joycon": "^3.1.1", + "minimist": "^1.2.6", + "on-exit-leak-free": "^2.1.0", + "pino-abstract-transport": "^3.0.0", + "pump": "^3.0.0", + "secure-json-parse": "^4.0.0", + "sonic-boom": "^4.0.1", + "strip-json-comments": "^5.0.2" + }, + "bin": { + "pino-pretty": "bin.js" + } + }, + "node_modules/pino-std-serializers": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/pino-std-serializers/-/pino-std-serializers-7.1.0.tgz", + "integrity": "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==", + "license": "MIT" + }, + "node_modules/process-warning": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.0.0.tgz", + "integrity": "sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/proto3-json-serializer": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/proto3-json-serializer/-/proto3-json-serializer-2.0.2.tgz", + "integrity": "sha512-SAzp/O4Yh02jGdRc+uIrGoe87dkN/XtwxfZ4ZyafJHymd79ozp5VG5nyZ7ygqPM5+cpLDjjGnYFUkngonyDPOQ==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "protobufjs": "^7.2.5" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/protobufjs": { + "version": "7.6.1", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.6.1.tgz", + "integrity": "sha512-4K0myLaWL5EteuSAro91EGFgcfVgxb64Jx+7oDAY6GOkXD4M69yuSEljNcInGVCA5sOPxmZ/EqDLj2x0Q0+Ygg==", + "hasInstallScript": true, + "license": "BSD-3-Clause", + "optional": true, + "dependencies": { + "@protobufjs/aspromise": "^1.1.2", + "@protobufjs/base64": "^1.1.2", + "@protobufjs/codegen": "^2.0.5", + "@protobufjs/eventemitter": "^1.1.1", + "@protobufjs/fetch": "^1.1.1", + "@protobufjs/float": "^1.0.2", + "@protobufjs/inquire": "^1.1.2", + "@protobufjs/path": "^1.1.2", + "@protobufjs/pool": "^1.1.0", + "@protobufjs/utf8": "^1.1.1", + "@types/node": ">=13.7.0", + "long": "^5.3.2" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "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/pump": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", + "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, + "node_modules/qs": { + "version": "6.15.2", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz", + "integrity": "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/quick-format-unescaped": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz", + "integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==", + "license": "MIT" + }, + "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": "3.0.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", + "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.7.0", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "optional": true, + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/real-require": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/real-require/-/real-require-0.2.0.tgz", + "integrity": "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==", + "license": "MIT", + "engines": { + "node": ">= 12.13.0" + } + }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/retry": { + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/retry/-/retry-0.13.1.tgz", + "integrity": "sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 4" + } + }, + "node_modules/retry-request": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/retry-request/-/retry-request-7.0.2.tgz", + "integrity": "sha512-dUOvLMJ0/JJYEn8NrpOaGNE7X3vpI5XlZS/u0ANjqtcZVKnIxP7IgCFwrKTxENw29emmwug53awKtaMm4i9g5w==", + "license": "MIT", + "optional": true, + "dependencies": { + "@types/request": "^2.48.8", + "extend": "^3.0.2", + "teeny-request": "^9.0.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/router": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", + "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "depd": "^2.0.0", + "is-promise": "^4.0.0", + "parseurl": "^1.3.3", + "path-to-regexp": "^8.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "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/safe-stable-stringify": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz", + "integrity": "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "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/secure-json-parse": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/secure-json-parse/-/secure-json-parse-4.1.0.tgz", + "integrity": "sha512-l4KnYfEyqYJxDwlNVyRfO2E4NTHfMKAWdUuA8J0yve2Dz/E/PdBepY03RvyJpssIpRFwJoCD55wA+mEDs6ByWA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/semver": { + "version": "7.8.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.1.tgz", + "integrity": "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", + "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.1", + "mime-types": "^3.0.2", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/serve-static": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", + "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", + "license": "MIT", + "dependencies": { + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.2.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "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.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "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/sonic-boom": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.1.tgz", + "integrity": "sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==", + "license": "MIT", + "dependencies": { + "atomic-sleep": "^1.0.0" + } + }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "license": "ISC", + "engines": { + "node": ">= 10.x" + } + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/stream-events": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/stream-events/-/stream-events-1.0.5.tgz", + "integrity": "sha512-E1GUzBSgvct8Jsb3v2X15pjzN1tYebtbLaMg+eBOUOAxgbLoSbT2NS91ckc5lJD1KfLjId+jXJRgo0qnV5Nerg==", + "license": "MIT", + "optional": true, + "dependencies": { + "stubs": "^3.0.0" + } + }, + "node_modules/stream-shift": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/stream-shift/-/stream-shift-1.0.3.tgz", + "integrity": "sha512-76ORR0DO1o1hlKwTbi/DM3EXWGf3ZJYO8cXX5RJwnul2DEg2oyoZyjLNoQM8WsvZiFKCRfC1O0J7iCvie3RZmQ==", + "license": "MIT", + "optional": true + }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "license": "MIT", + "optional": true, + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "optional": true, + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "optional": true, + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-json-comments": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-5.0.3.tgz", + "integrity": "sha512-1tB5mhVo7U+ETBKNf92xT4hrQa3pm0MZ0PQvuDnWgAAGHDsfp4lPSpiS6psrSiet87wyGPh9ft6wmhOMQ0hDiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/strnum": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.3.0.tgz", + "integrity": "sha512-ums3KNd42PGyx5xaoVTO1mjU1bH3NpY4vsrVlnv9PNGqQj8wd7rJ6nEypLrJ7z5vxK5RP0yMLo6J/Gsm62DI5Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "optional": true + }, + "node_modules/stubs": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/stubs/-/stubs-3.0.0.tgz", + "integrity": "sha512-PdHt7hHUJKxvTCgbKX9C1V/ftOcjJQgz8BZwNfV5c4B6dcGqlpelTbJ999jBGZ2jYiPAwcX5dP6oBwVlBlUbxw==", + "license": "MIT", + "optional": true + }, + "node_modules/teeny-request": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/teeny-request/-/teeny-request-9.0.0.tgz", + "integrity": "sha512-resvxdc6Mgb7YEThw6G6bExlXKkv6+YbuzGg9xuXxSgxJF7Ozs+o8Y9+2R3sArdWdW8nOokoQb1yrpFB0pQK2g==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "http-proxy-agent": "^5.0.0", + "https-proxy-agent": "^5.0.0", + "node-fetch": "^2.6.9", + "stream-events": "^1.0.5", + "uuid": "^9.0.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/teeny-request/node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/teeny-request/node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "license": "MIT", + "optional": true, + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/teeny-request/node_modules/uuid": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", + "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", + "deprecated": "uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028).", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "optional": true, + "bin": { + "uuid": "dist/bin/uuid" + } + }, + "node_modules/thread-stream": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-4.2.0.tgz", + "integrity": "sha512-e2zZ96wSChazBsbENf/Pcm/4swHt2cEKQ92rhUjkL9GCKiTDJIaTBenjE/m9DXi0QBmTMDkFDdOomUy20A1tDQ==", + "license": "MIT", + "dependencies": { + "real-require": "^1.0.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/thread-stream/node_modules/real-require": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/real-require/-/real-require-1.0.0.tgz", + "integrity": "sha512-P4nbQYQfePJxRSmY+v/KINxVucm4NF3p3s7pJveMTtom52FR4YGltUQLB8idDXwDDWW+eYrWDFbuzUnjoWHF7g==", + "license": "MIT" + }, + "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/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", + "license": "MIT", + "optional": true + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/tsx": { + "version": "4.22.3", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.22.3.tgz", + "integrity": "sha512-mdoNxBC/cSQObGGVQ5Bpn5i+yv7j68gk3Nfm3wFjcJg3Z0Mix9jzAFfP12prmm5eVGmDKtp0yyArrs0Q+8gZHg==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.28.0" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/type-is": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz", + "integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==", + "license": "MIT", + "dependencies": { + "content-type": "^2.0.0", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/type-is/node_modules/content-type": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz", + "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/typescript": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", + "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "7.24.6", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz", + "integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==", + "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/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT", + "optional": true + }, + "node_modules/uuid": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", + "deprecated": "uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028).", + "license": "MIT", + "optional": true, + "bin": { + "uuid": "dist/bin/uuid" + } + }, + "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/web-streams-polyfill": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz", + "integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==", + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", + "license": "BSD-2-Clause", + "optional": true + }, + "node_modules/websocket-driver": { + "version": "0.7.4", + "resolved": "https://registry.npmjs.org/websocket-driver/-/websocket-driver-0.7.4.tgz", + "integrity": "sha512-b17KeDIQVjvb0ssuSDF2cYXSg2iztliJ4B9WdsuB6J952qCPKmnVq4DyW5motImXHDC1cBT/1UezrJVsKw5zjg==", + "license": "Apache-2.0", + "dependencies": { + "http-parser-js": ">=0.5.1", + "safe-buffer": ">=5.1.0", + "websocket-extensions": ">=0.1.1" + }, + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/websocket-extensions": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/websocket-extensions/-/websocket-extensions-0.1.4.tgz", + "integrity": "sha512-OqedPIGOfsDlo31UNwYbCFMSaO9m9G/0faIHj5/dZFDMFqPTcx6UwqyOy3COEaEOg/9VsGIpdqn62W5KhoKSpg==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "license": "MIT", + "optional": true, + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, + "node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "license": "MIT", + "optional": true, + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + }, + "node_modules/xml-naming": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/xml-naming/-/xml-naming-0.1.0.tgz", + "integrity": "sha512-k8KO9hrMyNk6tUWqUfkTEZbezRRpONVOzUTnc97VnCvyj6Tf9lyUR9EDAIeiVLv56jsMcoXEwjW8Kv5yPY52lw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "optional": true, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "license": "ISC", + "optional": true, + "engines": { + "node": ">=10" + } + }, + "node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "license": "ISC" + }, + "node_modules/yargs": { + "version": "17.7.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", + "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", + "license": "MIT", + "optional": true, + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "license": "ISC", + "optional": true, + "engines": { + "node": ">=12" + } + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/zod": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", + "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..6ad85c5 --- /dev/null +++ b/package.json @@ -0,0 +1,28 @@ +{ + "name": "wormhole-server", + "version": "1.0.0", + "description": "Backend server for synchronizing multiple OpenSpace instances", + "main": "dist/index.js", + "type": "commonjs", + "scripts": { + "build": "tsc", + "start": "node dist/index.js", + "dev": "tsx watch src/index.ts" + }, + "dependencies": { + "cors": "^2.8.6", + "dotenv": "^17.4.2", + "express": "^5.2.1", + "firebase-admin": "^13.10.0", + "pino": "^10.3.1", + "zod": "^4.4.3" + }, + "devDependencies": { + "@types/cors": "^2.8.19", + "@types/express": "^5.0.6", + "@types/node": "^25.9.2", + "pino-pretty": "^13.1.3", + "tsx": "^4.19.4", + "typescript": "^6.0.3" + } +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..8c3d980 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,24 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "nodenext", + "moduleResolution": "nodenext", + "rootDir": "src", + "outDir": "dist", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "resolveJsonModule": true, + "sourceMap": true, + "allowUnreachableCode": false, + "forceConsistentCasingInFileNames": true, + "noFallthroughCasesInSwitch": true, + "noImplicitAny": true, + "noImplicitOverride": true, + "noImplicitReturns": true, + "noUnusedLocals": true, + "noUnusedParameters": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +} From 173d7b255728a20c7c4224144fd81a6d4747b1ba Mon Sep 17 00:00:00 2001 From: Andreas Engberg Date: Wed, 10 Jun 2026 15:00:10 +0200 Subject: [PATCH 03/17] Update wormhole wip --- .env-sample | 6 + package.json | 4 +- src/adminApi.ts | 381 +++++++++++++++++++++++++++++ src/adminFirebaseConfig.ts | 58 +++++ src/app.ts | 331 +++++++++++++++++++++++++ src/config/env.ts | 23 ++ src/index.ts | 38 +++ src/session.ts | 484 +++++++++++++++++++++++++++++++++++++ src/types/env.d.ts | 16 ++ src/types/types.ts | 80 ++++++ src/utils.ts | 64 +++++ src/wormhole.ts | 397 ++++++++++++++++++++++++++++++ 12 files changed, 1880 insertions(+), 2 deletions(-) create mode 100644 src/adminApi.ts create mode 100644 src/adminFirebaseConfig.ts create mode 100644 src/app.ts create mode 100644 src/config/env.ts create mode 100644 src/index.ts create mode 100644 src/session.ts create mode 100644 src/types/env.d.ts create mode 100644 src/types/types.ts create mode 100644 src/utils.ts create mode 100644 src/wormhole.ts diff --git a/.env-sample b/.env-sample index e558e51..3a8a47f 100644 --- a/.env-sample +++ b/.env-sample @@ -1,9 +1,11 @@ SERVER_API_PATH=/api/v1 # Address and port of the frontend http server listening on fetch requests +#ADDRESS = "http://localhost" HTTP_PORT = 25000 # Address and port of the backend wormhole server listening for parallel connections +#WORMHOLE_ADDRESS = "localhost" WORMHOLE_PORT = 25001 # Admin SDK file paths @@ -12,3 +14,7 @@ ADMIN_DB_SDK_FILEPATH = "" # Realtime database config DATABASE_FIREBASE_DATABASE_URL = "" + +CORS_ORIGIN = "" + +DEBUG = "true" \ No newline at end of file diff --git a/package.json b/package.json index 6ad85c5..e7d5439 100644 --- a/package.json +++ b/package.json @@ -1,12 +1,12 @@ { - "name": "wormhole-server", + "name": "wormhole", "version": "1.0.0", "description": "Backend server for synchronizing multiple OpenSpace instances", "main": "dist/index.js", "type": "commonjs", "scripts": { "build": "tsc", - "start": "node dist/index.js", + "preview": "node dist/index.js", "dev": "tsx watch src/index.ts" }, "dependencies": { diff --git a/src/adminApi.ts b/src/adminApi.ts new file mode 100644 index 0000000..e627407 --- /dev/null +++ b/src/adminApi.ts @@ -0,0 +1,381 @@ +/***************************************************************************************** + * * + * Wormhole * + * * + * Copyright (c) 2026-2026 * + * * + * Permission is hereby granted, free of charge, to any person obtaining a copy of this * + * software and associated documentation files (the "Software"), to deal in the Software * + * without restriction, including without limitation the rights to use, copy, modify, * + * merge, publish, distribute, sublicense, and/or sell copies of the Software, and to * + * permit persons to whom the Software is furnished to do so, subject to the following * + * conditions: * + * * + * The above copyright notice and this permission notice shall be included in all copies * + * or substantial portions of the Software. * + * * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, * + * INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A * + * PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT * + * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF * + * CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE * + * OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. * + ****************************************************************************************/ + +import { adminAuthApp, adminDbApp } from './adminFirebaseConfig'; +import { Session } from './session'; +import { InstanceHistoryData, ServerInstanceData, StatisticData } from './types/types'; +import { LDEBUG, LERROR, LINFO } from './utils'; +import { getAuth, UserRecord } from 'firebase-admin/auth'; +import { DataSnapshot, EventType, getDatabase, Reference } from 'firebase-admin/database'; + +/** + * Set admin rights for a user in the firebase auth database. + * + * @param uid The user uid to set admin rights for + * @param secret The secret to verify the request + */ +export async function setAdminRights(uid: string, secret: string) { + // Verify the provided secret against the database secret + const db = getDatabase(adminDbApp); + const dbSecret = await db.ref('Admin/secret').get(); + + if (!dbSecret.exists()) { + throw new Error('Could not find admin secret in database'); + } + + if (secret !== dbSecret.val()) { + throw new Error('Invalid secret provided'); + } + + const auth = getAuth(adminAuthApp); + try { + await auth.setCustomUserClaims(uid, { admin: true }); + LINFO(`Successfully set admin rights for user: ${uid}`); + } catch (error) { + LERROR('Internal error', (error as Error).message, `uid: '${uid}'`); + throw error; + } +} + +/** + * Authorize a user to the firebase auth database. + * + * @param token The client user id token to authorize + * @return A promise that resolves with the user firebase uid + */ +export async function authorizeUser(token: string): Promise { + const auth = getAuth(adminAuthApp); + const decodedToken = await auth.verifyIdToken(token); + return decodedToken.uid; +} + +/** + * Fetch the display name of a user given their uid. + * + * @param token Token to fetch user information + * @return A promise that resolves with the user information + */ +export async function getUserByID(token: string): Promise { + const auth = getAuth(adminAuthApp); + return await auth.getUser(token); +} + +/** + * @return A promise that resolves with an array of server instances or empty array + */ +export async function getServerInstancesFromDB(): Promise { + const db = getDatabase(adminDbApp); + const snapshot = await db.ref('InstanceData').once('value'); + if (!snapshot.exists()) { + return []; + } + + const data = snapshot.val(); + const instances = Object.values(data); + return instances; +} + +/** + * Subscribe to the database at `path` and listen for `eventType` events. + * + * @param path The path to the database to subscribe to + * @param eventType The type of event to listen for + * @param callback The callback function when an event is triggered + * @param onError The callback function when an error occurs + */ +export async function subscribeToDatabase( + path: string | Reference, + eventType: EventType, + callback: (snapshot: DataSnapshot, b?: string | null | undefined) => any, + onError: (error: Error) => any +) { + const db = getDatabase(adminDbApp); + const instanceRef = db.ref(path); + instanceRef.on(eventType, callback, onError); +} + +/** + * Adds a new server instance to the database. + * + * @param server The server instance to add to the database + * @param profile The OpenSpace profile this instance is running + * @param isPrivate True if instance is private, false otherwise + * @param uid The uid of the user who created this instance + */ +export async function postServerInstance( + server: Session, + profile: string, + isPrivate: boolean, + uid: string | null +): Promise { + const db = getDatabase(adminDbApp); + const instanceRef = db.ref('InstanceData'); + const newPostRef = instanceRef.push(); + const postID = newPostRef.key!; + + const metadata = server.getSessionMetadata(); + const data: ServerInstanceData = { + active: false, + inactiveTimeStamp: Date.now(), + created: Date.now(), + usage: 0, + password: metadata.password, + hostPassword: metadata.hostPassword, + nPeers: 0, + currentHost: 'null', + roomName: metadata.sessionName, + profile: profile, + id: postID, + isPrivate: isPrivate, + owner: uid + }; + await newPostRef.set(data, (error) => { + if (error) { + LERROR('Error posting instance to database:', error); + } else { + LDEBUG('Posted a new instance to database:', data); + } + }); + return data; +} + +/** + * Add the `instance` to the history database. + * + * @param instance The instance data to add to the history database + */ +export async function postInstanceHistoryData( + instance: ServerInstanceData +): Promise { + try { + const db = getDatabase(adminDbApp); + const uptime = Date.now() - instance.created; + const historyRef = db.ref(`InstanceHistory/${instance.id}`); + const history: InstanceHistoryData = { + id: instance.id, + inactiveTimeStamp: instance.inactiveTimeStamp, + created: instance.created, + uptime: uptime, + usage: instance.usage, + roomName: instance.roomName, + owner: instance.owner + }; + await historyRef.set(history, (error) => { + if (error) { + throw error; + } else { + LDEBUG('Posted history data to database:', history); + } + }); + } catch (error) { + LERROR('Error posting history data:', error); + } +} + +/** + * Attempts to remove the server instance with the given ID from the database. + * + * @param instanceID The ID of the server instance to remove + * @return A promise once the operation is complete or an exception if an error occurs + */ +export async function removeServerInstanceFromDb(instanceID: string): Promise { + try { + const db = getDatabase(adminDbApp); + const instanceRef = db.ref(`InstanceData/${instanceID}`); + + const snapshot = await instanceRef.get(); + + if (!snapshot.exists()) { + const errorMessage = `Error removing instance from database: "${instanceID}" does not exist`; + LDEBUG(errorMessage); + throw new Error(errorMessage); + } + + await postInstanceHistoryData(snapshot.val()); + await instanceRef.remove((error) => { + if (error) { + LERROR('Error removing server instance from database:', error); + } else { + LINFO(`Instance with id ${instanceID} was removed from the database`); + } + }); + } catch (error) { + const errorMessage = `Error removing instance "${instanceID}" from DB: ${(error as Error).message}`; + LERROR(errorMessage); + throw new Error(errorMessage); + } +} + +/** + * Updates the active status of a server instance in the database. + * + * @param instanceID The ID of the server instance to update + * @param online The new status of the server instance + */ +export async function updateActiveSessionStatus( + instanceID: string, + online: boolean +): Promise { + const onUpdate = (error: Error | null) => { + if (error) { + throw error; + } else { + LDEBUG(`Updated session status for "${instanceID}" to: ${online}`); + } + }; + + try { + const db = getDatabase(adminDbApp); + const instanceRef = db.ref(`InstanceData/${instanceID}`); + const snapshot = await instanceRef.get(); + + if (!snapshot.exists()) { + throw new Error(`Could not find instance with id "${instanceID}"`); + } + // if instance is offline we also need to update the inactive timestamp + if (!online) { + await instanceRef.update( + { active: online, inactiveTimeStamp: Date.now() }, + onUpdate + ); + } else { + await instanceRef.update({ active: online }, onUpdate); + } + } catch (error) { + LERROR(`Error updating session status for "${instanceID}:"`, error); + } +} + +/** + * Updates the current number of active users in a server instance. + * + * @param instanceID The ID of the server instance to update + * @param nPeers The new number of active users + */ +export async function updateCurrentActiveUsers( + instanceID: string, + nPeers: number +): Promise { + try { + const db = getDatabase(adminDbApp); + const instanceRef = db.ref(`InstanceData/${instanceID}`); + const snapshot = await instanceRef.get(); + + if (!snapshot) { + throw new Error(`Could not find instance with id "${instanceID}"`); + } + + await instanceRef.update({ nPeers: nPeers }, (error) => { + if (error) { + throw error; + } else { + LDEBUG(`Updated # active users for "${instanceID}" to: ${nPeers}`); + } + }); + } catch (error) { + LERROR(`Error updating # active users for "${instanceID}:"`, error); + } + await updateStatistics(instanceID, nPeers); +} + +/** + * Add a new statistics entry for the given `instanceID` instance. + * + * @param instanceID The ID of the server instance to update + * @param nPeers The new number of active users + */ +export async function updateStatistics( + instanceID: string, + nPeers: number +): Promise { + try { + const db = getDatabase(adminDbApp); + const statisticsRef = db.ref(`Statistics/${instanceID}`); + const stats: StatisticData = { + nPeers: nPeers, + timestamp: Date.now() + }; + + await statisticsRef.push(stats, (error) => { + if (error) { + throw error; + } else { + LDEBUG('Pushed new statistics to database:', stats); + } + }); + } catch (error) { + LERROR(`Error updating statistics for "${instanceID}:"`, error); + } +} +/** + * Updates the current host of a server instance. + * + * @param instanceID The ID of the server instance to update + * @param host The new host of the server instance + */ +export async function updateCurrentHost(instanceID: string, host: string) { + try { + const db = getDatabase(adminDbApp); + const instanceRef = db.ref(`InstanceData/${instanceID}`); + const snapshot = await instanceRef.get(); + if (!snapshot.exists()) { + throw new Error(`Could not find instance with id "${instanceID}"`); + } + await instanceRef.update({ currentHost: host }, (error) => { + if (error) { + throw error; + } else { + LDEBUG(`Updated current host for "${instanceID}" to: ${host}`); + } + }); + } catch (error) { + LERROR(`Error updating current host for ${instanceID}:`, error); + } +} + +/** + * Updates the total number of users connected to a server instance. + * + * @param instanceID The ID of the server instance to update + */ +export async function updateUsage(instanceID: string) { + try { + const db = getDatabase(adminDbApp); + const instanceRef = db.ref(`InstanceData/${instanceID}`); + const snapshot = await instanceRef.get(); + if (!snapshot.exists()) { + throw new Error(`Could not find instance with id "${instanceID}"`); + } + const data = snapshot.val() as ServerInstanceData; + await instanceRef.update({ usage: data.usage + 1 }, (error) => { + if (error) { + throw error; + } else { + LDEBUG(`Updated usage for "${instanceID}" to: ${data.usage + 1}`); + } + }); + } catch (error) { + LERROR(`Error updating instance ${instanceID} usage:`, error); + } +} diff --git a/src/adminFirebaseConfig.ts b/src/adminFirebaseConfig.ts new file mode 100644 index 0000000..1d43f8b --- /dev/null +++ b/src/adminFirebaseConfig.ts @@ -0,0 +1,58 @@ +/***************************************************************************************** + * * + * Wormhole * + * * + * Copyright (c) 2026-2026 * + * * + * Permission is hereby granted, free of charge, to any person obtaining a copy of this * + * software and associated documentation files (the "Software"), to deal in the Software * + * without restriction, including without limitation the rights to use, copy, modify, * + * merge, publish, distribute, sublicense, and/or sell copies of the Software, and to * + * permit persons to whom the Software is furnished to do so, subject to the following * + * conditions: * + * * + * The above copyright notice and this permission notice shall be included in all copies * + * or substantial portions of the Software. * + * * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, * + * INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A * + * PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT * + * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF * + * CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE * + * OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. * + ****************************************************************************************/ + +import { readFileSync } from 'fs'; +import { cert, initializeApp } from 'firebase-admin/app'; +import { env } from './config/env'; + +function loadServiceAccount(filePath: string, envVar: string): object { + try { + return JSON.parse(readFileSync(filePath, 'utf-8')); + } catch (err) { + throw new Error( + `Failed to load Firebase service account from ${envVar}="${filePath}": ${(err as Error).message}` + ); + } +} + +// Setup admin auth app +export const adminAuthApp = initializeApp( + { + credential: cert( + loadServiceAccount(env.ADMIN_AUTH_SDK_FILEPATH, 'ADMIN_AUTH_SDK_FILEPATH') + ) + }, + 'adminAuth' +); + +// Setup admin database app +export const adminDbApp = initializeApp( + { + credential: cert( + loadServiceAccount(env.ADMIN_DB_SDK_FILEPATH, 'ADMIN_DB_SDK_FILEPATH') + ), + databaseURL: env.DATABASE_FIREBASE_DATABASE_URL + }, + 'adminDb' +); diff --git a/src/app.ts b/src/app.ts new file mode 100644 index 0000000..a250a5a --- /dev/null +++ b/src/app.ts @@ -0,0 +1,331 @@ +/***************************************************************************************** + * * + * Wormhole * + * * + * Copyright (c) 2026-2026 * + * * + * Permission is hereby granted, free of charge, to any person obtaining a copy of this * + * software and associated documentation files (the "Software"), to deal in the Software * + * without restriction, including without limitation the rights to use, copy, modify, * + * merge, publish, distribute, sublicense, and/or sell copies of the Software, and to * + * permit persons to whom the Software is furnished to do so, subject to the following * + * conditions: * + * * + * The above copyright notice and this permission notice shall be included in all copies * + * or substantial portions of the Software. * + * * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, * + * INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A * + * PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT * + * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF * + * CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE * + * OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. * + ****************************************************************************************/ + +import { + authorizeUser, + getServerInstancesFromDB, + getUserByID, + postServerInstance, + removeServerInstanceFromDb, + setAdminRights, + subscribeToDatabase +} from './adminApi'; +import { Wormhole } from './wormhole'; +import { LERROR, LINFO } from './utils'; +import cors from 'cors'; +import express, { Response, Request } from 'express'; +import { DataSnapshot } from 'firebase-admin/database'; +import { env } from './config/env'; +import { ServerInstanceData } from './types/types'; + +/** + * Top-level application coordinator. Owns the HTTP REST API and the Wormhole TCP server. + * Responsible for session lifecycle management, Firebase persistence, and authentication. + */ +export class App { + /** + * The Wormhole instance managing all active sessions + */ + private _wormhole: Wormhole; + + /** + * The express server that is listening to incoming HTTP requests + */ + private _app; + + /** + * @param httpPort The port on which the HTTP server will listen for incoming requests + * @param wormholePort The port on which the Wormhole will listen for incoming peer connections + * @param apiPath The path to the API endpoint for the server, e.g. `/api` + */ + constructor(httpPort: number, wormholePort: number, apiPath: string) { + this._wormhole = new Wormhole(wormholePort); + + // Setup express server for handling HTTP requests + this._app = express(); + this._app.use(cors({ origin: env.CORS_ORIGIN })); + this._app.use(express.json()); + + this._app.post( + `${apiPath}/request-server-instance`, + this.handleRequestSession.bind(this) + ); + this._app.post( + `${apiPath}/request-admin-rights`, + this.handleRequestSetAdminRights.bind(this) + ); + this._app.delete( + `${apiPath}/remove-server-instance/:id`, + this.handleRemoveSession.bind(this) + ); + this._app.get( + `${apiPath}/fetch-user-name/:token`, + this.handleRequestgetUserNameByID.bind(this) + ); + this._app.listen(httpPort, () => { + LINFO(`Server listening on port ${httpPort}`); + }); + } + + /** + * Starts the Wormhole to accept incoming peer connections. + */ + public startWormhole(): void { + this._wormhole.start(); + } + + /** + * Fetch the display name of a user given their uid. + * + * @param req Request object containing the user token + * @param res Response object containing user name + */ + private async handleRequestgetUserNameByID(req: Request, res: Response): Promise { + const token = req.params.token; + + if (!token || typeof token !== 'string') { + res.status(400).json({ error: 'Invalid token' }); + return; + } + + try { + const user = await getUserByID(token); + res.json({ name: user.displayName }); + } catch (error) { + res.status(400).json({ error: (error as Error).message }); + } + } + + /** + * Loads existing sessions from the database and restores them in the Wormhole. + */ + public async loadSessionsFromDB(): Promise { + const instances = await getServerInstancesFromDB(); + + if (!instances || !instances.length) { + LINFO('No existing sessions found in database'); + return; + } + // Add sessions to internal registry + for (const instance of instances) { + await this._wormhole.addSession( + instance.roomName, + instance.password, + instance.hostPassword, + instance.id + ); + } + + LINFO(`Loaded ${this._wormhole.activeSessions()} existing sessions from database`); + } + + /** + * Handle the request to set admin rights for a user. + * + * @param req Request object must contain the user id token and secret in the body + */ + private async handleRequestSetAdminRights(req: Request, res: Response): Promise { + const uid = req.body.uid; + const secret = req.body.secret; + + if (!uid || !secret || typeof uid !== 'string' || typeof secret !== 'string') { + res.status(400).json({ error: 'Missing uid or secret' }); + return; + } + + try { + await setAdminRights(uid, secret); + res.status(200).json({ message: `Successfully set admin rights for user: ${uid}` }); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } + } + + /** + * Handle the request to remove a session. + * + * @param req Request object must contain the session id as a params `/:id` + */ + private async handleRemoveSession(req: Request, res: Response): Promise { + const instanceID = req.params.id; + + if (!instanceID || typeof instanceID !== 'string') { + res.status(400).json({ error: 'Instance ID is required' }); + return; + } + + try { + const msg = await this.removeSession(instanceID); + res.status(200).json({ message: msg }); + } catch (e) { + res.status(500).json({ error: (e as Error).message }); + } + } + + /** + * Shuts down and removes a session from the Wormhole and the database. + * + * @param instanceID The id of the session to shut down + * @return A promise that resolves with a success message, or rejects with an error + */ + private async removeSession(instanceID: string): Promise { + const msg = await this._wormhole.removeSession(instanceID); + await removeServerInstanceFromDb(instanceID); + return msg; + } + + /** + * Handle the request to create a new session. + * + * @param req Request body must contain password, hostpassword, roomname, profile, and token + * @return A promise that resolves with the session metadata or rejects with an error + */ + private async handleRequestSession(req: Request, res: Response): Promise { + const password = req.body.password; + const hostPassword = req.body.hostpassword; + const roomName = req.body.roomname; + const profile = req.body.profile; + const isPrivateRoom = req.body.roomaccess ? true : false; + const tokenID: string | null = req.body.token ?? null; + + if (!tokenID) { + res.status(401).json({ error: 'Unauthorized: Could not verify user' }); + return; + } + // A server without passwords are not allowed + if ( + !password || + !hostPassword || + typeof password !== 'string' || + typeof hostPassword !== 'string' + ) { + res.status(400).json({ error: 'Missing password or host password' }); + return; + } + if (!roomName || typeof roomName !== 'string') { + res.status(400).json({ error: 'Missing room name' }); + return; + } + if (!profile || typeof profile !== 'string') { + res.status(400).json({ error: 'Missing profile' }); + return; + } + + // Check if the provided room name is unique + const instances = await getServerInstancesFromDB(); + const isRoomNameUnique = instances.every((instance: ServerInstanceData) => { + return instance.roomName !== roomName; + }); + + if (!isRoomNameUnique) { + res + .status(400) + .json({ error: 'A room with that name already exists, must be unique' }); + return; + } + + // Attempt to create a new server instance + try { + // Authorize potential user + const uid = await authorizeUser(tokenID); + + const instance = await this._wormhole.addSession( + roomName, + password, + hostPassword + ); + + // TODO: handle error messages if we fail to push the session info to database + const serverInstance = await postServerInstance( + instance, + profile, + isPrivateRoom, + uid + ); + + instance.setSessionID(serverInstance.id); + // Server was successfully created, send back information to the client so they + // can connect to it through OpenSpace + res.json(serverInstance); + } catch (e) { + LERROR('Error creating server instance:', e); + // Report an internal server error to the client + res.status(500).json({ error: (e as Error).message }); + return; + } + } + + /** + * Automatically removes sessions that have been inactive for more than 30 minutes. + * Runs every 5 minutes. + */ + public autoKillInactiveSessions(): void { + let instances: ServerInstanceData[] = []; + + function handleData(snapshot: DataSnapshot): any { + if (snapshot.exists()) { + const data = snapshot.val(); + instances = Object.values(data); + } else { + instances = []; + } + } + + function handleError(error: Error) { + LERROR('Error fetching instance data: ', error); + } + + subscribeToDatabase('InstanceData', 'value', handleData, handleError); + + setInterval( + async () => { + const now = Date.now(); + const thirtyMinutes = 30 * 60 * 1000; + // We use an extra array to store the instance we want to remove. + // Because the removeServerInstanceFromDb will alter the firebase and we are + // subscribed to the database, as such the `instances` array will update while + // we would be looping over it, unsure of the behaviour of that. + const instancesToRemove: ServerInstanceData[] = []; + for (const instance of instances) { + // If the server has been running for more than 30 inactive minutes we kill it + const inactiveUptime = now - instance.inactiveTimeStamp; + if (!instance.active && inactiveUptime > thirtyMinutes) { + instancesToRemove.push(instance); + } + } + + // Remove the instances that have been inactive for too long + for (const instance of instancesToRemove) { + try { + await this._wormhole.removeSession(instance.id); + await removeServerInstanceFromDb(instance.id); + } catch (error) { + LERROR('Error removing inactive server:', error); + } + } + }, + 5 * 60 * 1000 // Run every 5 minutes + ); + } +} diff --git a/src/config/env.ts b/src/config/env.ts new file mode 100644 index 0000000..aba7069 --- /dev/null +++ b/src/config/env.ts @@ -0,0 +1,23 @@ +import 'dotenv/config'; +import { z } from 'zod'; + +const envSchema = z.object({ + HTTP_PORT: z.coerce.number().default(25000), + WORMHOLE_PORT: z.coerce.number().default(25001), + SERVER_API_PATH: z.string(), + CORS_ORIGIN: z.string(), + DEBUG: z.coerce.boolean().default(false), + ADMIN_AUTH_SDK_FILEPATH: z.string(), + ADMIN_DB_SDK_FILEPATH: z.string(), + DATABASE_FIREBASE_DATABASE_URL: z.httpUrl() +}); + +const result = envSchema.safeParse(process.env); + +if (!result.success) { + console.error('Invalid environment configuration:'); + console.error(result.error); + process.exit(1); +} + +export const env = result.data; diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..02d1d53 --- /dev/null +++ b/src/index.ts @@ -0,0 +1,38 @@ +/***************************************************************************************** + * * + * Wormhole * + * * + * Copyright (c) 2026-2026 * + * * + * Permission is hereby granted, free of charge, to any person obtaining a copy of this * + * software and associated documentation files (the "Software"), to deal in the Software * + * without restriction, including without limitation the rights to use, copy, modify, * + * merge, publish, distribute, sublicense, and/or sell copies of the Software, and to * + * permit persons to whom the Software is furnished to do so, subject to the following * + * conditions: * + * * + * The above copyright notice and this permission notice shall be included in all copies * + * or substantial portions of the Software. * + * * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, * + * INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A * + * PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT * + * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF * + * CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE * + * OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. * + ****************************************************************************************/ + +import { env } from './config/env'; +import { App } from './app'; + +async function main() { + const application = new App(env.HTTP_PORT, env.WORMHOLE_PORT, env.SERVER_API_PATH); + await application.loadSessionsFromDB(); + application.autoKillInactiveSessions(); + application.startWormhole(); +} + +main().catch((err: Error) => { + console.error('Fatal startup error:', err.message); + process.exit(1); +}); diff --git a/src/session.ts b/src/session.ts new file mode 100644 index 0000000..8fedb7b --- /dev/null +++ b/src/session.ts @@ -0,0 +1,484 @@ +/***************************************************************************************** + * * + * Wormhole * + * * + * Copyright (c) 2026-2026 * + * * + * Permission is hereby granted, free of charge, to any person obtaining a copy of this * + * software and associated documentation files (the "Software"), to deal in the Software * + * without restriction, including without limitation the rights to use, copy, modify, * + * merge, publish, distribute, sublicense, and/or sell copies of the Software, and to * + * permit persons to whom the Software is furnished to do so, subject to the following * + * conditions: * + * * + * The above copyright notice and this permission notice shall be included in all copies * + * or substantial portions of the Software. * + * * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, * + * INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A * + * PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT * + * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF * + * CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE * + * OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. * + ****************************************************************************************/ + +import { + updateActiveSessionStatus, + updateCurrentActiveUsers, + updateCurrentHost, + updateUsage +} from './adminApi'; +import { + Peer, + ConnectionStatus, + MessageType, + CurrentProtocolVersion +} from './types/types'; +import { LDEBUG } from './utils'; + +/** + * A named room that manages a group of connected peers. One peer may act as the host, + * controlling what all participants see, which simulation time is used, and which Lua + * scripts execute on all connected OpenSpace instances. + */ +export class Session { + /** + * The unique name of this session. Peers must must provide this name during + * authentication to join. Maximum 255 ASCII characters. + */ + private name: string; + + /** + * The clear-text password that peers need to provide to connect. This password has a + * maximum length of 65535 characters and can consist only of ASCII characters + */ + private password: string; + + /** + * The clear-text password for assuming hostship. The host password can have a maximum + * length of 65535 characters and can consist only of ASCII characters + */ + private hostPassword: string; + + /** + * A unique ID for this session, generated by the authentication provider. + */ + private id: string | null = null; + + /** + * The list of all peers currently connected to this session. Peers are appended on + * successful authentication and removed when they disconnect or produce an error. + */ + private peers: Peer[] = []; + + /** + * The id of the current host. Note that this is **not** the location of this + * peer in the `peers` list, but its `id`, which might be different under some + * circumstances + */ + private currentHostId: number | null = null; + + /** + * Creates a new Session. + * + * @param name The unique name of this session + * @param password The password peers must provide to join. Maximum 65535 ASCII characters. + * @param hostPassword The password peers must provide to claim hostship. Maximum 65535 + * ASCII characters. + * @param id The unique ID assigned by the authentication provider, or null if not yet set + */ + constructor( + name: string, + password: string, + hostPassword: string, + id: string | null = null + ) { + this.name = name; + this.password = password; + this.hostPassword = hostPassword; + if (id) { + this.id = id; + } + } + + /** + * @return The metadata of this session + */ + public getSessionMetadata() { + return { + sessionName: this.name, + password: this.password, + hostPassword: this.hostPassword, + id: this.id + }; + } + + /** + * Sets the unique ID of this session. + * + * @param id The unique ID assigned to this session + */ + public setSessionID(id: string): void { + this.id = id; + } + + /** + * Forcibly closes the session, disconnecting all connected peers. + */ + public stop(): Promise { + return new Promise((resolve) => { + let disconnectCount = 0; + + if (this.peers.length === 0) { + resolve(); + } + + const checkAllDisconnected = () => { + disconnectCount += 1; + if (disconnectCount === this.peers.length) { + resolve(); + } + }; + + for (const peer of this.peers) { + // Handle if socket does not end properly + peer.socket.on('error', (err) => { + LDEBUG( + `Session ${this.name}: Error while disconnecting peer ${peer.id} ${peer.name}`, + err + ); + checkAllDisconnected(); + }); + + peer.socket.end(() => { + checkAllDisconnected(); + }); + } + }); + } + + /** + * Handles the disconnection of a peer. We remove the Peer from the list of connected + * peers and free up the ID slot. If the Peer that disconnected was the host, we + * inform all connected peers that they no longer have a host. + * + * TODO: Currently we assign the ID of a connected peer by the length of the array of + * connected peers. If there are 2 peers connected and the first one disconnects and + * then reconnects, both peers will end up with ID = 1. + * + * @param peer The Peer that just disconnected + */ + public onEnd(peer: Peer): void { + // Find the index in the peer list of the peer that disconnected + const idx = this.peers.findIndex((p: Peer) => p.id === peer.id); + console.assert( + idx !== -1, + `Session ${this.name}: Could not find peer with id ${peer.id} trying to ` + + 'disconnect' + ); + this.peers.splice(idx, 1); + + // Remove hostship if the host disconnected + if (peer.id === this.currentHostId) { + this.removeHostship(); + } + + // Update the number of connections + this.sendNumberOfConnections(); + + LDEBUG(`Session ${this.name}: Number of connections remaining: ${this.peers.length}`); + } + + /** + * Handles the incoming authentication method by the provided peer. If the + * authentication is valid and contains the correct password, the peer is added to the + * list in the group. If the host password is also correct and there is no currently + * assigned host, the peer is automatically promoted to hostship too. + * + * @param data The payload of the authentication message + * @param peer The Peer from which this message arrived + * @return True if the authentication was successful, false otherwise + */ + public handleAuthentication( + peer: Peer, + password: string, + hostPassword: string | null + ): boolean { + if (password !== this.password) { + LDEBUG(`Session ${this.name}: Invalid password`); + return false; + } + + // We can add this peer to the list of all peers in the group + this.peers.push(peer); + + // If there is currently no host and this peer provided the host password + // we promote them to hostship + if (this.currentHostId === null && hostPassword === this.hostPassword) { + LDEBUG(`Session ${this.name}: Connection ${peer.id} ${peer.name} promoted to host`); + this.assignHost(peer); + } else { + LDEBUG(`Session ${this.name}: Connection ${peer.id} ${peer.name} is client`); + peer.status = + this.currentHostId === null + ? ConnectionStatus.ClientWithoutHost + : ConnectionStatus.ClientWithHost; + + this.sendConnectionStatus(peer); + } + + this.sendNumberOfConnections(); + updateUsage(this.id!); + return true; + } + + /** + * Handle incoming data from the provided peer, we only forward the data along to + * other peers if it comes from the host. + * + * @param data The payload of the data message + * @param peer The Peer from which this message is coming + */ + public handleData(data: Buffer, peer: Peer): void { + // Check if this peer is the current host + if (peer.id === this.currentHostId) { + console.assert( + peer.status === ConnectionStatus.Host, + `Server ${this.name}: Peer '${peer.id}' is host but status is not set to host` + ); + + this.peers.forEach((peer: Peer) => { + if (peer.status === ConnectionStatus.ClientWithHost) { + this.sendMessage(peer, MessageType.Data, data); + } + }); + } + } + + /** + * Handles an incoming hostship request message by the peer. If the message contains + * the correct host password, the peer is promoted to hostship and the previous host + * is demoted (if there was one). + * + * @param data The payload of the hostship request message + * @param peer The Peer from which this message arrived + */ + public handleHostshipRequest(data: Buffer, peer: Peer): void { + LDEBUG(`Session ${this.name}: Peer ${peer.id} ${peer.name} requested hostship`); + + const dv = new DataView( + data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength) + ); + let offset = 0; + + const passwordLength = dv.getUint16(offset, true); + offset += Uint16Array.BYTES_PER_ELEMENT; + if (passwordLength === 0) { + // No password was given so we don't do anything + LDEBUG(`Session ${this.name}: Invalid password length`); + return; + } + + const password = data.subarray(offset, offset + passwordLength).toString('utf-8'); + if (password !== this.hostPassword) { + // Wrong password so we don't do anything + LDEBUG( + `Session ${this.name}: Peer ${peer.id} ${peer.name} provided incorrect host password` + ); + return; + } + + if (peer.id === this.currentHostId) { + LDEBUG(`Session ${this.name}: Peer ${peer.id} ${peer.name} is already host`); + return; + } + + this.assignHost(peer); + } + + /** + * Handles an incoming hostship resignation by the peer. If the peer is the current + * host, we remove the hostship and inform all connected peers that they lost their + * host. + * + * @param peer The Peer from which this message arrived + */ + public handleHostshipResignation(peer: Peer): void { + LDEBUG(`Session ${this.name}: Peer ${peer.id} ${peer.name} resigned hostship`); + + // Only remove the host if it was the host peer that requested the resignation + if (peer.status === ConnectionStatus.Host) { + this.removeHostship(); + } + } + + /** + * Send a message to the provided peer of the provided messageType with the specified + * payload data. + * + * @param peer The Peer that will recieve the message + * @param messageType The type of message that will be sent + * @param payload The payload of the data that is sent + */ + private sendMessage(peer: Peer, messageType: MessageType, payload: Buffer): void { + const buffer = Buffer.alloc( + 'OS'.length + // OS prefix + Uint8Array.BYTES_PER_ELEMENT + // protocol version number + Uint8Array.BYTES_PER_ELEMENT + // message type identifier + Uint32Array.BYTES_PER_ELEMENT + // payload size in bytes + payload.byteLength // payload + ); + + const dv = new DataView(buffer.buffer, buffer.byteOffset, buffer.byteLength); + let offset = 0; + + // Set OS header + dv.setUint8(offset, 'OS'.charCodeAt(0)); + offset += Uint8Array.BYTES_PER_ELEMENT; + dv.setUint8(offset, 'OS'.charCodeAt(1)); + offset += Uint8Array.BYTES_PER_ELEMENT; + + // Protocol version + dv.setUint8(offset, CurrentProtocolVersion); + offset += Uint8Array.BYTES_PER_ELEMENT; + + // Message type + dv.setUint8(offset, messageType); + offset += Uint8Array.BYTES_PER_ELEMENT; + + // Message size + dv.setUint32(offset, payload.byteLength, true); + offset += Uint32Array.BYTES_PER_ELEMENT; + + // Copy the payload into the message + payload.copy(buffer, offset); + + LDEBUG( + `Session ${this.name}: Sending to peer with id: '${peer.id}', name: '${peer.name}', data: '${buffer}` + ); + peer.socket.write(buffer); + } + + /** + * Send a connection status message to the provided peer. + * + * @param peer The Peer that will recieve the connection status message + */ + private sendConnectionStatus(peer: Peer): void { + LDEBUG( + `Session ${this.name}: Sending connection status to peer with id: '${peer.id}', name: '${peer.name}'` + ); + + const host = this.currentHost(); + const currentHostName = host === null ? '' : host.name; + + const buffer = Buffer.alloc( + Uint8Array.BYTES_PER_ELEMENT + // Status + Uint8Array.BYTES_PER_ELEMENT + // Length of host + currentHostName.length // Host name + ); + + const dv = new DataView(buffer.buffer, buffer.byteOffset, buffer.byteLength); + let offset = 0; + + // Set the connection status of the peer as known by the server + dv.setUint8(offset, peer.status); + offset += Uint8Array.BYTES_PER_ELEMENT; + + // The length of the host name + dv.setUint8(offset, currentHostName.length); + offset += Uint8Array.BYTES_PER_ELEMENT; + + // Insert the name of the current host + buffer.write(currentHostName, offset, 'ascii'); + + this.sendMessage(peer, MessageType.ConnectionStatus, buffer); + } + + /** + * Send the number of connections to all connected peers. + */ + private sendNumberOfConnections(): void { + LDEBUG(`Session ${this.name}: Sending number of connections to all peers`); + + const buffer = Buffer.alloc(Uint32Array.BYTES_PER_ELEMENT); // Number of connections + const dv = new DataView(buffer.buffer, buffer.byteOffset, buffer.byteLength); + dv.setUint32(0, this.peers.length, true); + this.peers.forEach((peer: Peer) => { + this.sendMessage(peer, MessageType.NConnections, buffer); + }); + // Update the server status depending on if there still are any users connected + const isServerActive = this.peers.length > 0; + updateActiveSessionStatus(this.id!, isServerActive); + updateCurrentActiveUsers(this.id!, this.peers.length); + } + + /** + * @return The current host of the group or null if there is no host assigned + */ + private currentHost(): Peer | null { + if (this.currentHostId === null) { + return null; + } + + const idx = this.peers.findIndex((peer: Peer) => peer.id === this.currentHostId); + if (idx === -1) { + LDEBUG(`Could not find host with id ${this.currentHostId}`); + return null; + } + + return this.peers[idx]; + } + + /** + * Mark the provided newHost Peer as the new host of the group and remove the hostship + * of the previous host. Inform all connected peers about the change in hostship. + * + * @param newHost The Peer to be promoted to the new host + */ + private assignHost(newHost: Peer): void { + // If we currently have a host, we need to demote them + if (this.currentHostId !== null) { + let oldHost = this.currentHost()!; // Host id cannot be null here + oldHost.status = ConnectionStatus.ClientWithHost; + } + + // Promote the provided peer to host + this.currentHostId = newHost.id; + newHost.status = ConnectionStatus.Host; + + // Update others about the new host + this.peers.forEach((peer: Peer) => { + // If there was no host before, let them know about the new host + if (peer.status === ConnectionStatus.ClientWithoutHost) { + peer.status = ConnectionStatus.ClientWithHost; + } + this.sendConnectionStatus(peer); + }); + + updateCurrentHost(this.id!, newHost.name); + } + + /** + * If the group has a host, we remove the hostship and inform all connected peers that + * there is no host anymore. + */ + private removeHostship(): void { + // If we don't have a host, we don't need to do anything + if (this.currentHostId === null) { + return; + } + + // Remove host information on server side + this.currentHostId = null; + + // Inform all peers that we no longer have a host, this includes the recently + // removed host aswell + this.peers.forEach((p: Peer) => { + p.status = ConnectionStatus.ClientWithoutHost; + this.sendConnectionStatus(p); + }); + + updateCurrentHost(this.id!, 'null'); + } +} diff --git a/src/types/env.d.ts b/src/types/env.d.ts new file mode 100644 index 0000000..2074acf --- /dev/null +++ b/src/types/env.d.ts @@ -0,0 +1,16 @@ +declare namespace NodeJS { + interface ProcessEnv { + /** Path to the Firebase Admin Auth service account key JSON file */ + ADMIN_AUTH_SDK_FILEPATH: string; + /** Path to the Firebase Admin Realtime Database service account key JSON file */ + ADMIN_DB_SDK_FILEPATH: string; + /** Firebase Realtime Database URL */ + DATABASE_FIREBASE_DATABASE_URL: string; + /** Port for the HTTP REST API server (default: 25000) */ + HTTP_PORT?: string; + /** Port for the Wormhole TCP server (default: 25001) */ + WORMHOLE_PORT?: string; + /** Base path prefix for all REST API routes */ + SERVER_API_PATH: string; + } +} diff --git a/src/types/types.ts b/src/types/types.ts new file mode 100644 index 0000000..27a4712 --- /dev/null +++ b/src/types/types.ts @@ -0,0 +1,80 @@ +import { Socket } from 'net'; + +export type ServerInstanceData = { + id: string; + active: boolean; + inactiveTimeStamp: number; + created: number; + usage: number; + password: string; + hostPassword: string; + nPeers: number; + currentHost: string; + roomName: string; + profile: string; + isPrivate: boolean; + owner: string | null; +}; + +export type InstanceHistoryData = { + id: string; + inactiveTimeStamp: number; + created: number; + uptime: number; + usage: number; + roomName: string; + owner: string | null; +}; + +export type StatisticData = { + nPeers: number; + timestamp: number; +}; + +export const CurrentProtocolVersion = 7; + +export enum MessageType { + Authentication = 0, + Data, + ConnectionStatus, + HostshipRequest, + HostshipResignation, + NConnections +} + +export enum ConnectionStatus { + Disconnected = 0, + Connecting, + ClientWithoutHost, + ClientWithHost, + Host +} + +export type Peer = { + /** + * A unique ID for this peer + */ + id: number; + + /** + * The user-provided name for this peer, or "Anonymous" if no name was specified. The + * name can have a maximum length of 255 characters and can only consist of ASCII + * characters + */ + name: string; + + /** + * The socket on which to contact the Peer and send messages to + */ + socket: Socket; + + /** + * The connection status of this peer + */ + status: ConnectionStatus; + + /** + * The name of the session this peer is connected to + */ + sessionName: string; +}; diff --git a/src/utils.ts b/src/utils.ts new file mode 100644 index 0000000..7849a19 --- /dev/null +++ b/src/utils.ts @@ -0,0 +1,64 @@ +/***************************************************************************************** + * * + * Wormhole * + * * + * Copyright (c) 2026-2026 * + * * + * Permission is hereby granted, free of charge, to any person obtaining a copy of this * + * software and associated documentation files (the "Software"), to deal in the Software * + * without restriction, including without limitation the rights to use, copy, modify, * + * merge, publish, distribute, sublicense, and/or sell copies of the Software, and to * + * permit persons to whom the Software is furnished to do so, subject to the following * + * conditions: * + * * + * The above copyright notice and this permission notice shall be included in all copies * + * or substantial portions of the Software. * + * * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, * + * INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A * + * PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT * + * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF * + * CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE * + * OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. * + ****************************************************************************************/ + +import { env } from './config/env'; + +// Color codes for console logs +const FgRed = '\x1b[31m'; +const FgGreen = '\x1b[32m'; +const FgWhite = '\x1b[37m'; + +/** + * Logs a debug info message if `ChattyDebug` flage is set to true. + * + * @param message The message to log + * @param params Any additional parameters to log + */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export function LDEBUG(message: string, ...params: any[]): void { + if (env.DEBUG) { + console.log(FgGreen, message, FgWhite, ...params); + } +} +/** + * Logs an error message. + * + * @param message The message to log + * @param params Any additional parameters to log + */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export function LERROR(message: string, ...params: any[]): void { + console.error(FgRed, message, FgWhite, ...params); +} + +/** + * Logs an info message. + * + * @param message The message to log + * @param params Any additional parameters to log + */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export function LINFO(message: string, ...params: any[]): void { + console.log(FgWhite, message, ...params); +} diff --git a/src/wormhole.ts b/src/wormhole.ts new file mode 100644 index 0000000..d4c2963 --- /dev/null +++ b/src/wormhole.ts @@ -0,0 +1,397 @@ +/***************************************************************************************** + * * + * Wormhole * + * * + * Copyright (c) 2026-2026 * + * * + * Permission is hereby granted, free of charge, to any person obtaining a copy of this * + * software and associated documentation files (the "Software"), to deal in the Software * + * without restriction, including without limitation the rights to use, copy, modify, * + * merge, publish, distribute, sublicense, and/or sell copies of the Software, and to * + * permit persons to whom the Software is furnished to do so, subject to the following * + * conditions: * + * * + * The above copyright notice and this permission notice shall be included in all copies * + * or substantial portions of the Software. * + * * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, * + * INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A * + * PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT * + * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF * + * CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE * + * OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. * + ****************************************************************************************/ + +import { Session } from './session'; +import { + ConnectionStatus, + CurrentProtocolVersion, + MessageType, + Peer +} from './types/types'; +import { LDEBUG, LERROR, LINFO } from './utils'; +import * as net from 'net'; + +/** + * A TCP server that listens on a single port and routes incoming OpenSpace connections + * into their respective named sessions. Manages the full lifecycle of all active sessions. + */ +export class Wormhole { + /** + * The port on which this TCP server is listening + */ + private port: number; + + private peerIdCounter: number; + + /** + * All sessions currently managed by this Wormhole, keyed by session name. + */ + private instances: { [name: string]: Session } = {}; + + /** + * The local server that is listening to incoming connections that will result in Peers + */ + private server: net.Server; + + /** + * @param port The port on which this TCP server should listen for incoming connections. + * This port must not already be in use on this computer. + */ + constructor(port: number) { + this.port = port; + this.peerIdCounter = 0; + // Setup the TCP server for handling incoming OpenSpace connections + this.server = net.createServer(); + + this.server.on('connection', (socket: net.Socket) => { + LDEBUG('New connection', socket.remoteAddress); + + // The Peer is only added to the list if the authentication is succesful. + // eslint-disable-next-line prefer-const + let peer: Peer = { + id: this.peerIdCounter, // TODO: assign a unique ID, see comment in ´onEnd´ + name: '', + socket: socket, + status: ConnectionStatus.Connecting, + sessionName: '' + }; + + socket.on('data', (data: Buffer) => { + this.onData(data, peer); + }); + socket.on('end', () => { + this.onEnd(peer); + }); + socket.on('error', (error) => { + LERROR(`Error: ${error} from peer ${peer.id}`); + this.onEnd(peer); + }); + }); + } + + /** + * Starts the TCP server and begins accepting incoming connections. + * + * @return A promise that resolves to true once the server is listening, + * or rejects with an error if the port is unavailable. + */ + public start(): Promise { + return new Promise((resolve, reject) => { + this.server.on('listening', () => { + LINFO(`Wormhole listening on port ${this.port}`); + resolve(true); + }); + + this.server.on('error', (error: Error) => { + this.server.close(); + LERROR(`Error starting server: ${error.message}`); + reject(error); + }); + + this.server.listen(this.port); + }); + } + + /** + * Creates and registers a new session. If a session with the same name already + * exists it is removed first (this should not normally happen). + * + * @return The newly created Session + */ + public async addSession( + name: string, + password: string, + hostPassword: string, + id: string | null = null + ): Promise { + // If the session already exists, remove it (this *should* not happen) + if (name in this.instances) { + const oldSession = this.instances[name]; + await this.removeSession(oldSession.getSessionMetadata().id!); + } + + const session = new Session(name, password, hostPassword, id); + this.instances[name] = session; + return session; + } + + /** + * @return The number of active sessions currently running + */ + public activeSessions(): number { + return Object.keys(this.instances).length; + } + + /** + * Removes a session by ID. Disconnects all its peers, then deletes it from the + * internal registry. Resolves once all peers have been disconnected. + * + * @return A promise that resolves with a success message, or rejects on error + */ + public async removeSession(sessionID: string): Promise { + const session = Object.values(this.instances).find((session: Session) => { + return session.getSessionMetadata().id === sessionID; + }); + + if (!session) { + const errorMsg = `Session '${sessionID}' not found`; + LDEBUG(errorMsg); + throw new Error(errorMsg); + } + + try { + // Notify all peers that the session is closing + await session.stop(); + + // Remove session from internal registry + delete this.instances[session.getSessionMetadata().sessionName]; + + const successMsg = `Session "${sessionID}" successfully removed`; + LDEBUG(successMsg); + return successMsg; + } catch (error) { + const errorMsg = `Error stopping session "${sessionID}": ${(error as Error).message}`; + LERROR(errorMsg); + throw error; + } + } + + /** + * Handles incoming data packages on the socket. We unpack the message and bail early + * if there are any errors in the provided message. A valid message is then forwarded + * to the appropriate handlers. + * + * @param data The data package that was received on the socket + * @param peer The Peer from which this message arrived + */ + private onData(data: Buffer, peer: Peer): void { + const HeaderSize = + 'OS'.length + // OS prefix + Uint8Array.BYTES_PER_ELEMENT + // protocol version number + Uint8Array.BYTES_PER_ELEMENT + // message type identifier + Uint32Array.BYTES_PER_ELEMENT; // payload size in bytes + + // Exit early if we don't have a valid header information + if (data.length < HeaderSize) { + LDEBUG('Invalid header information'); + return; + } + + const prefix = data.subarray(0, 2).toString('utf-8'); + if (prefix !== 'OS') { + LDEBUG(`The message did not start with OS prefix, invalid message: '${prefix}'`); + return; + } + + const dv = new DataView( + data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength) + ); + + let offset = 'OS'.length; + const protocolVersion = dv.getUint8(offset); + offset += Uint8Array.BYTES_PER_ELEMENT; + if (protocolVersion !== CurrentProtocolVersion) { + LDEBUG( + `Invalid protocol version: '${protocolVersion}', expected: '${CurrentProtocolVersion}'` + ); + return; + } + + const messageType = dv.getUint8(offset); + offset += Uint8Array.BYTES_PER_ELEMENT; + if (!(messageType in MessageType)) { + LDEBUG(`Invalid message type: '${messageType}'`); + return; + } + + const messageSize = dv.getUint32(offset, true); + offset += Uint32Array.BYTES_PER_ELEMENT; + if (data.byteLength !== offset + messageSize) { + LDEBUG('The provided message size was not the same as the actual message length'); + LDEBUG(`Received message type: ${messageType}`); + LDEBUG(`Header size: ${HeaderSize}`); + LDEBUG(`Data size: ${data.byteLength}`); + LDEBUG(`Data byteoffset: ${data.byteOffset}`); + LDEBUG(`Message size: ${messageSize}`); + LDEBUG(`Offset: ${offset}`); + return; + } + + // Slice the header data from the message + const messagePayload = data.subarray(offset); + + switch (messageType) { + case MessageType.Authentication: + this.handleAuthentication(messagePayload, peer); + break; + case MessageType.Data: + this.handleData(messagePayload, peer); + break; + case MessageType.HostshipRequest: + this.handleHostshipRequest(messagePayload, peer); + break; + case MessageType.HostshipResignation: + this.handleHostshipResignation(messagePayload, peer); + break; + } + } + + /** + * Handles the disconnection of a peer. We remove the Peer from the list of connected + * peers and free up the ID slot. If the Peer that disconnected was the host, we + * inform all connected peers that they no longer have a host. + * + * TODO: Currently we assign the ID of a connected peer by the length of the array of + * connected peers. If there are 2 peers connected and the first one disconnects and + * then reconnects, both peers will end up with ID = 1. + * + * @param peer The Peer that just disconnected + */ + private onEnd(peer: Peer): void { + LDEBUG(`Peer ${peer.id} disconnected, closing connection`); + // Get the session that the peer is connected to + const session = this.instances[peer.sessionName]; + + if (!session) { + LDEBUG(`Session "${peer.sessionName}" not found`); + return; + } + + session.onEnd(peer); + } + + /** + * Handles the incoming authentication method by the provided peer. If the + * authentication is valid and contains the correct password, the peer is added to the + * list in the group. If the host password is also correct and there is no currently + * assigned host, the peer is automatically promoted to hostship too. + * + * @param data The payload of the authentication message + * @param peer The Peer from which this message arrived + */ + private handleAuthentication(data: Buffer, peer: Peer): void { + LDEBUG(`Handling authentication for peer ${peer.id}`); + + const dv = new DataView( + data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength) + ); + + let offset = 0; + const passwordLength = dv.getUint16(offset, true); + offset += Uint16Array.BYTES_PER_ELEMENT; + if (passwordLength === 0) { + // The password length cannot be zero + LDEBUG('Invalid password length'); + return; + } + const password = data.subarray(offset, offset + passwordLength).toString('utf-8'); + offset += passwordLength; + + const hostPasswordLength = dv.getUint16(offset, true); + offset += Uint16Array.BYTES_PER_ELEMENT; + const hostPassword = + hostPasswordLength === 0 + ? '' + : data.subarray(offset, offset + hostPasswordLength).toString('utf-8'); + offset += hostPasswordLength; + + const sessionNameLength = dv.getUint8(offset); + offset += Uint8Array.BYTES_PER_ELEMENT; + const sessionName = data + .subarray(offset, offset + sessionNameLength) + .toString('utf-8'); + offset += sessionNameLength; + peer.sessionName = sessionName; + + const nameLength = dv.getUint8(offset); + offset += Uint8Array.BYTES_PER_ELEMENT; + peer.name = + nameLength === 0 + ? 'Anonymous' + : data.subarray(offset, offset + nameLength).toString('utf-8'); + + // Get the session that this peer is trying to connect to + const session = this.instances[sessionName]; + if (!session) { + LDEBUG(`No session with name ${sessionName} found`); + return; + } + + const authenticated = session.handleAuthentication(peer, password, hostPassword); + if (authenticated) { + this.peerIdCounter++; + } + } + + /** + * Handle incoming data from the provided peer, we only forward the data along to + * other peers if it comes from the host. + * + * @param data The payload of the data message + * @param peer The Peer from which this message is coming + */ + private handleData(data: Buffer, peer: Peer): void { + const session = this.instances[peer.sessionName]; + if (!session) { + LDEBUG(`No session with name '${peer.sessionName}' found`); + return; + } + session.handleData(data, peer); + } + + /** + * Handles an incoming hostship request message by the peer. If the message contains + * the correct host password, the peer is promoted to hostship and the previous host + * is demoted (if there was one). + * + * @param data The payload of the hostship request message + * @param peer The Peer from which this message arrived + */ + private handleHostshipRequest(data: Buffer, peer: Peer): void { + const session = this.instances[peer.sessionName]; + if (!session) { + LDEBUG(`No session with name ${peer.sessionName} found`); + return; + } + + session.handleHostshipRequest(data, peer); + } + + /** + * Handles an incoming hostship resignation by the peer. If the peer is the current + * host, we remove the hostship and inform all connected peers that they lost their + * host. + * + * @param _ Unused parameter + * @param peer The Peer from which this message arrived + */ + private handleHostshipResignation(_: Buffer, peer: Peer): void { + const session = this.instances[peer.sessionName]; + if (!session) { + LDEBUG(`No session with name ${peer.sessionName} found`); + return; + } + session.handleHostshipResignation(peer); + } +} From ee04efaef0ba30ee72688a0a5feda5ead7c82e51 Mon Sep 17 00:00:00 2001 From: Andreas Engberg Date: Tue, 30 Jun 2026 10:44:57 +0200 Subject: [PATCH 04/17] rename serverinstance -> session, update endpoints --- src/adminApi.ts | 260 +++++++++++++++++++++++---------------------- src/app.ts | 197 ++++++++++++++++++++++++---------- src/session.ts | 9 +- src/types/types.ts | 7 +- src/utils.ts | 20 ++++ src/wormhole.ts | 102 +++++++++--------- 6 files changed, 353 insertions(+), 242 deletions(-) diff --git a/src/adminApi.ts b/src/adminApi.ts index e627407..4a5226f 100644 --- a/src/adminApi.ts +++ b/src/adminApi.ts @@ -24,7 +24,7 @@ import { adminAuthApp, adminDbApp } from './adminFirebaseConfig'; import { Session } from './session'; -import { InstanceHistoryData, ServerInstanceData, StatisticData } from './types/types'; +import { SessionHistoryData, SessionData, StatisticData } from './types/types'; import { LDEBUG, LERROR, LINFO } from './utils'; import { getAuth, UserRecord } from 'firebase-admin/auth'; import { DataSnapshot, EventType, getDatabase, Reference } from 'firebase-admin/database'; @@ -84,15 +84,15 @@ export async function getUserByID(token: string): Promise { /** * @return A promise that resolves with an array of server instances or empty array */ -export async function getServerInstancesFromDB(): Promise { +export async function getSessionsFromDb(): Promise { const db = getDatabase(adminDbApp); - const snapshot = await db.ref('InstanceData').once('value'); + const snapshot = await db.ref('SessionData').once('value'); if (!snapshot.exists()) { return []; } const data = snapshot.val(); - const instances = Object.values(data); + const instances = Object.values(data); return instances; } @@ -118,76 +118,119 @@ export async function subscribeToDatabase( /** * Adds a new server instance to the database. * - * @param server The server instance to add to the database + * @param session The server instance to add to the database * @param profile The OpenSpace profile this instance is running * @param isPrivate True if instance is private, false otherwise * @param uid The uid of the user who created this instance */ -export async function postServerInstance( - server: Session, +export async function postSession( + session: Session, profile: string, isPrivate: boolean, - uid: string | null -): Promise { + uid: string +): Promise { const db = getDatabase(adminDbApp); - const instanceRef = db.ref('InstanceData'); - const newPostRef = instanceRef.push(); - const postID = newPostRef.key!; + const sessionsRef = db.ref('SessionData'); + const newPostRef = sessionsRef.push(); + const postId = newPostRef.key!; - const metadata = server.getSessionMetadata(); - const data: ServerInstanceData = { + const metadata = session.getSessionMetadata(); + + await db.ref(`SessionSecrets/${postId}`).set({ hostPassword: metadata.hostPassword }); + LDEBUG(`Posted host password to database for session: ${postId}`); + + const data: SessionData = { active: false, inactiveTimeStamp: Date.now(), created: Date.now(), usage: 0, - password: metadata.password, - hostPassword: metadata.hostPassword, + password: metadata.password ?? null, nPeers: 0, currentHost: 'null', roomName: metadata.sessionName, profile: profile, - id: postID, + id: postId, isPrivate: isPrivate, owner: uid }; - await newPostRef.set(data, (error) => { - if (error) { - LERROR('Error posting instance to database:', error); - } else { - LDEBUG('Posted a new instance to database:', data); - } - }); + await newPostRef.set(data); + LDEBUG('Posted a new instance to database:', data); return data; } +/*** + * Fetch the host password for a given session ID and user ID. + * + * @param sessionId The ID of the session to fetch the host password for + * @param uid The user ID of the requester + */ +export async function getHostPassword(sessionId: string, uid: string): Promise { + const db = getDatabase(adminDbApp); + + const sessionSnapshot = await db.ref(`SessionData/${sessionId}`).get(); + if (!sessionSnapshot.exists()) { + throw new Error(`Session with id "${sessionId}" does not exist`); + } + + const session = sessionSnapshot.val() as SessionData; + if (session.owner !== uid) { + throw new Error('Only the session owner can retrieve the host password'); + } + + const secretSnapshot = await db.ref(`SessionSecrets/${sessionId}/hostPassword`).get(); + if (!secretSnapshot.exists()) { + throw new Error(`Host password for session with id "${sessionId}" does not exist`); + } + + return secretSnapshot.val(); +} + +export async function verifyHostPassword( + sessionId: string, + password: string +): Promise { + const db = getDatabase(adminDbApp); + const snapshot = await db.ref(`SessionSecrets/${sessionId}/hostPassword`).get(); + if (!snapshot.exists()) { + throw new Error(`Host password for session with id "${sessionId}" does not exist`); + } + return snapshot.val() === password; +} + +/** + * Fetch the host password for a session for server-internal use only. + * Does not perform any ownership or authentication check. + * + * @param sessionId The ID of the session + * @return The host password, or null if not found + */ +export async function getHostPasswordInternal(sessionId: string): Promise { + const db = getDatabase(adminDbApp); + const snapshot = await db.ref(`SessionSecrets/${sessionId}/hostPassword`).get(); + return snapshot.exists() ? snapshot.val() : null; +} + /** * Add the `instance` to the history database. * - * @param instance The instance data to add to the history database + * @param session The instance data to add to the history database */ -export async function postInstanceHistoryData( - instance: ServerInstanceData -): Promise { +export async function postSessionHistoryData(session: SessionData): Promise { try { const db = getDatabase(adminDbApp); - const uptime = Date.now() - instance.created; - const historyRef = db.ref(`InstanceHistory/${instance.id}`); - const history: InstanceHistoryData = { - id: instance.id, - inactiveTimeStamp: instance.inactiveTimeStamp, - created: instance.created, + const uptime = Date.now() - session.created; + const historyRef = db.ref(`SessionHistory/${session.id}`); + const history: SessionHistoryData = { + id: session.id, + inactiveTimeStamp: session.inactiveTimeStamp, + created: session.created, uptime: uptime, - usage: instance.usage, - roomName: instance.roomName, - owner: instance.owner + usage: session.usage, + roomName: session.roomName, + owner: session.owner }; - await historyRef.set(history, (error) => { - if (error) { - throw error; - } else { - LDEBUG('Posted history data to database:', history); - } - }); + await historyRef.set(history); + LDEBUG('Posted history data to database:', history); } catch (error) { LERROR('Error posting history data:', error); } @@ -196,32 +239,28 @@ export async function postInstanceHistoryData( /** * Attempts to remove the server instance with the given ID from the database. * - * @param instanceID The ID of the server instance to remove + * @param sessionId The ID of the server instance to remove * @return A promise once the operation is complete or an exception if an error occurs */ -export async function removeServerInstanceFromDb(instanceID: string): Promise { +export async function removeSessionFromDb(sessionId: string): Promise { try { const db = getDatabase(adminDbApp); - const instanceRef = db.ref(`InstanceData/${instanceID}`); + const instanceRef = db.ref(`SessionData/${sessionId}`); const snapshot = await instanceRef.get(); if (!snapshot.exists()) { - const errorMessage = `Error removing instance from database: "${instanceID}" does not exist`; + const errorMessage = `Error removing instance from database: "${sessionId}" does not exist`; LDEBUG(errorMessage); throw new Error(errorMessage); } - await postInstanceHistoryData(snapshot.val()); - await instanceRef.remove((error) => { - if (error) { - LERROR('Error removing server instance from database:', error); - } else { - LINFO(`Instance with id ${instanceID} was removed from the database`); - } - }); + await postSessionHistoryData(snapshot.val()); + await instanceRef.remove(); + await db.ref(`SessionSecrets/${sessionId}`).remove(); + LINFO(`Instance with id ${sessionId} was removed from the database`); } catch (error) { - const errorMessage = `Error removing instance "${instanceID}" from DB: ${(error as Error).message}`; + const errorMessage = `Error removing instance "${sessionId}" from DB: ${(error as Error).message}`; LERROR(errorMessage); throw new Error(errorMessage); } @@ -230,152 +269,119 @@ export async function removeServerInstanceFromDb(instanceID: string): Promise { - const onUpdate = (error: Error | null) => { - if (error) { - throw error; - } else { - LDEBUG(`Updated session status for "${instanceID}" to: ${online}`); - } - }; - try { const db = getDatabase(adminDbApp); - const instanceRef = db.ref(`InstanceData/${instanceID}`); + const instanceRef = db.ref(`SessionData/${sessionId}`); const snapshot = await instanceRef.get(); if (!snapshot.exists()) { - throw new Error(`Could not find instance with id "${instanceID}"`); + throw new Error(`Could not find instance with id "${sessionId}"`); } // if instance is offline we also need to update the inactive timestamp if (!online) { - await instanceRef.update( - { active: online, inactiveTimeStamp: Date.now() }, - onUpdate - ); + await instanceRef.update({ active: online, inactiveTimeStamp: Date.now() }); } else { - await instanceRef.update({ active: online }, onUpdate); + await instanceRef.update({ active: online }); } + LDEBUG(`Updated session status for "${sessionId}" to: ${online}`); } catch (error) { - LERROR(`Error updating session status for "${instanceID}:"`, error); + LERROR(`Error updating session status for "${sessionId}:"`, error); } } /** * Updates the current number of active users in a server instance. * - * @param instanceID The ID of the server instance to update + * @param sessionId The ID of the server instance to update * @param nPeers The new number of active users */ export async function updateCurrentActiveUsers( - instanceID: string, + sessionId: string, nPeers: number ): Promise { try { const db = getDatabase(adminDbApp); - const instanceRef = db.ref(`InstanceData/${instanceID}`); + const instanceRef = db.ref(`SessionData/${sessionId}`); const snapshot = await instanceRef.get(); if (!snapshot) { - throw new Error(`Could not find instance with id "${instanceID}"`); + throw new Error(`Could not find instance with id "${sessionId}"`); } - await instanceRef.update({ nPeers: nPeers }, (error) => { - if (error) { - throw error; - } else { - LDEBUG(`Updated # active users for "${instanceID}" to: ${nPeers}`); - } - }); + await instanceRef.update({ nPeers: nPeers }); + LDEBUG(`Updated # active users for "${sessionId}" to: ${nPeers}`); } catch (error) { - LERROR(`Error updating # active users for "${instanceID}:"`, error); + LERROR(`Error updating # active users for "${sessionId}:"`, error); } - await updateStatistics(instanceID, nPeers); + await updateStatistics(sessionId, nPeers); } /** * Add a new statistics entry for the given `instanceID` instance. * - * @param instanceID The ID of the server instance to update + * @param sessionId The ID of the server instance to update * @param nPeers The new number of active users */ -export async function updateStatistics( - instanceID: string, - nPeers: number -): Promise { +export async function updateStatistics(sessionId: string, nPeers: number): Promise { try { const db = getDatabase(adminDbApp); - const statisticsRef = db.ref(`Statistics/${instanceID}`); + const statisticsRef = db.ref(`Statistics/${sessionId}`); const stats: StatisticData = { nPeers: nPeers, timestamp: Date.now() }; - await statisticsRef.push(stats, (error) => { - if (error) { - throw error; - } else { - LDEBUG('Pushed new statistics to database:', stats); - } - }); + await statisticsRef.push(stats); + LDEBUG('Pushed new statistics to database:', stats); } catch (error) { - LERROR(`Error updating statistics for "${instanceID}:"`, error); + LERROR(`Error updating statistics for "${sessionId}:"`, error); } } /** * Updates the current host of a server instance. * - * @param instanceID The ID of the server instance to update + * @param sessionId The ID of the server instance to update * @param host The new host of the server instance */ -export async function updateCurrentHost(instanceID: string, host: string) { +export async function updateCurrentHost(sessionId: string, host: string) { try { const db = getDatabase(adminDbApp); - const instanceRef = db.ref(`InstanceData/${instanceID}`); + const instanceRef = db.ref(`SessionData/${sessionId}`); const snapshot = await instanceRef.get(); if (!snapshot.exists()) { - throw new Error(`Could not find instance with id "${instanceID}"`); + throw new Error(`Could not find instance with id "${sessionId}"`); } - await instanceRef.update({ currentHost: host }, (error) => { - if (error) { - throw error; - } else { - LDEBUG(`Updated current host for "${instanceID}" to: ${host}`); - } - }); + await instanceRef.update({ currentHost: host }); + LDEBUG(`Updated current host for "${sessionId}" to: ${host}`); } catch (error) { - LERROR(`Error updating current host for ${instanceID}:`, error); + LERROR(`Error updating current host for ${sessionId}:`, error); } } /** * Updates the total number of users connected to a server instance. * - * @param instanceID The ID of the server instance to update + * @param sessionId The ID of the server instance to update */ -export async function updateUsage(instanceID: string) { +export async function updateUsage(sessionId: string) { try { const db = getDatabase(adminDbApp); - const instanceRef = db.ref(`InstanceData/${instanceID}`); + const instanceRef = db.ref(`SessionData/${sessionId}`); const snapshot = await instanceRef.get(); if (!snapshot.exists()) { - throw new Error(`Could not find instance with id "${instanceID}"`); + throw new Error(`Could not find instance with id "${sessionId}"`); } - const data = snapshot.val() as ServerInstanceData; - await instanceRef.update({ usage: data.usage + 1 }, (error) => { - if (error) { - throw error; - } else { - LDEBUG(`Updated usage for "${instanceID}" to: ${data.usage + 1}`); - } - }); + const data = snapshot.val() as SessionData; + await instanceRef.update({ usage: data.usage + 1 }); + LDEBUG(`Updated usage for "${sessionId}" to: ${data.usage + 1}`); } catch (error) { - LERROR(`Error updating instance ${instanceID} usage:`, error); + LERROR(`Error updating instance ${sessionId} usage:`, error); } } diff --git a/src/app.ts b/src/app.ts index a250a5a..c648c52 100644 --- a/src/app.ts +++ b/src/app.ts @@ -24,12 +24,15 @@ import { authorizeUser, - getServerInstancesFromDB, + getSessionsFromDb, getUserByID, - postServerInstance, - removeServerInstanceFromDb, + getHostPassword, + getHostPasswordInternal, + postSession, + removeSessionFromDb, setAdminRights, - subscribeToDatabase + subscribeToDatabase, + verifyHostPassword } from './adminApi'; import { Wormhole } from './wormhole'; import { LERROR, LINFO } from './utils'; @@ -37,7 +40,7 @@ import cors from 'cors'; import express, { Response, Request } from 'express'; import { DataSnapshot } from 'firebase-admin/database'; import { env } from './config/env'; -import { ServerInstanceData } from './types/types'; +import { SessionData } from './types/types'; /** * Top-level application coordinator. Owns the HTTP REST API and the Wormhole TCP server. @@ -67,22 +70,25 @@ export class App { this._app.use(cors({ origin: env.CORS_ORIGIN })); this._app.use(express.json()); - this._app.post( - `${apiPath}/request-server-instance`, - this.handleRequestSession.bind(this) - ); this._app.post( `${apiPath}/request-admin-rights`, this.handleRequestSetAdminRights.bind(this) ); - this._app.delete( - `${apiPath}/remove-server-instance/:id`, - this.handleRemoveSession.bind(this) - ); this._app.get( `${apiPath}/fetch-user-name/:token`, this.handleRequestgetUserNameByID.bind(this) ); + this._app.post(`${apiPath}/request-session`, this.handleRequestSession.bind(this)); + this._app.get( + `${apiPath}/session/:id/host-password`, + this.handleGetHostPassword.bind(this) + ); + this._app.post(`${apiPath}/session/:id/claim-host`, this.handleClaimHost.bind(this)); + this._app.delete( + `${apiPath}/session/:id/remove`, + this.handleRemoveSession.bind(this) + ); + this._app.listen(httpPort, () => { LINFO(`Server listening on port ${httpPort}`); }); @@ -121,7 +127,7 @@ export class App { * Loads existing sessions from the database and restores them in the Wormhole. */ public async loadSessionsFromDB(): Promise { - const instances = await getServerInstancesFromDB(); + const instances = await getSessionsFromDb(); if (!instances || !instances.length) { LINFO('No existing sessions found in database'); @@ -129,10 +135,11 @@ export class App { } // Add sessions to internal registry for (const instance of instances) { + const hostPassword = (await getHostPasswordInternal(instance.id)) ?? ''; await this._wormhole.addSession( instance.roomName, instance.password, - instance.hostPassword, + hostPassword, instance.id ); } @@ -191,7 +198,7 @@ export class App { */ private async removeSession(instanceID: string): Promise { const msg = await this._wormhole.removeSession(instanceID); - await removeServerInstanceFromDb(instanceID); + await removeSessionFromDb(instanceID); return msg; } @@ -202,25 +209,25 @@ export class App { * @return A promise that resolves with the session metadata or rejects with an error */ private async handleRequestSession(req: Request, res: Response): Promise { + const authHeader = req.headers.authorization; + if (!authHeader?.startsWith('Bearer ')) { + res.status(401).json({ error: 'Missing or invalid authorization header' }); + return; + } + const token = authHeader.slice(7); + const password = req.body.password; - const hostPassword = req.body.hostpassword; - const roomName = req.body.roomname; + const hostPassword = req.body.hostPassword; + const roomName = req.body.roomName; const profile = req.body.profile; - const isPrivateRoom = req.body.roomaccess ? true : false; - const tokenID: string | null = req.body.token ?? null; - - if (!tokenID) { - res.status(401).json({ error: 'Unauthorized: Could not verify user' }); + const isPrivateRoom = req.body.isPrivate ?? false; + // A server without passwords are not allowed + if (!hostPassword || typeof hostPassword !== 'string') { + res.status(400).json({ error: 'Missing host password' }); return; } - // A server without passwords are not allowed - if ( - !password || - !hostPassword || - typeof password !== 'string' || - typeof hostPassword !== 'string' - ) { - res.status(400).json({ error: 'Missing password or host password' }); + if (password !== undefined && typeof password !== 'string') { + res.status(400).json({ error: 'Invalid password' }); return; } if (!roomName || typeof roomName !== 'string') { @@ -233,8 +240,8 @@ export class App { } // Check if the provided room name is unique - const instances = await getServerInstancesFromDB(); - const isRoomNameUnique = instances.every((instance: ServerInstanceData) => { + const sessions = await getSessionsFromDb(); + const isRoomNameUnique = sessions.every((instance: SessionData) => { return instance.roomName !== roomName; }); @@ -248,26 +255,22 @@ export class App { // Attempt to create a new server instance try { // Authorize potential user - const uid = await authorizeUser(tokenID); - - const instance = await this._wormhole.addSession( - roomName, - password, - hostPassword - ); - - // TODO: handle error messages if we fail to push the session info to database - const serverInstance = await postServerInstance( - instance, - profile, - isPrivateRoom, - uid - ); - - instance.setSessionID(serverInstance.id); + const uid = await authorizeUser(token); + const session = await this._wormhole.addSession(roomName, password, hostPassword); + + let sessionData: SessionData; + try { + sessionData = await postSession(session, profile, isPrivateRoom, uid); + } catch (e) { + await this._wormhole.removeSession(session); + LERROR('Failed to post session to database, rolling back wormhole session:', e); + res.status(500).json({ error: 'Failed to create session, please try again' }); + return; + } + session.setSessionID(sessionData.id); // Server was successfully created, send back information to the client so they // can connect to it through OpenSpace - res.json(serverInstance); + res.json(sessionData); } catch (e) { LERROR('Error creating server instance:', e); // Report an internal server error to the client @@ -276,17 +279,99 @@ export class App { } } + /** + * Handle the request to retrieve the host password for a session. + * Only the session owner may access it. + * + * @param req Request params must contain the session id as `/:id` + */ + private async handleGetHostPassword(req: Request, res: Response): Promise { + const authHeader = req.headers.authorization; + if (!authHeader?.startsWith('Bearer ')) { + res.status(401).json({ error: 'Missing or invalid authorization header' }); + return; + } + const token = authHeader.slice(7); + const sessionId = req.params.id; + + LERROR('handleGetHostPassword called with sessionId:', sessionId); + + if (!sessionId || typeof sessionId !== 'string') { + res.status(400).json({ error: 'Session ID is required' }); + return; + } + + try { + const uid = await authorizeUser(token); + const hostPassword = await getHostPassword(sessionId, uid); + res.status(200).json({ hostPassword }); + } catch (error) { + const msg = (error as Error).message; + if (msg.includes('Only the session owner')) { + res.status(403).json({ error: msg }); + } else if (msg.includes('does not exist')) { + res.status(404).json({ error: msg }); + } else { + res.status(500).json({ error: msg }); + } + } + } + + /** + * Handle a request to claim host for a session. + * Verifies the provided host password; does not check whether a host is currently active. + * + * @param req Request params must contain the session id as `/:id`; body must contain `password` + */ + private async handleClaimHost(req: Request, res: Response): Promise { + const authHeader = req.headers.authorization; + if (!authHeader?.startsWith('Bearer ')) { + res.status(401).json({ error: 'Missing or invalid authorization header' }); + return; + } + const token = authHeader.slice(7); + const sessionId = req.params.id; + const password = req.body.password; + + if (!sessionId || typeof sessionId !== 'string') { + res.status(400).json({ error: 'Session ID is required' }); + return; + } + + if (!password || typeof password !== 'string') { + res.status(400).json({ error: 'Missing password' }); + return; + } + + try { + await authorizeUser(token); + const isValid = await verifyHostPassword(sessionId, password); + if (isValid) { + res.status(200).json({ message: 'Host password verified' }); + } else { + res.status(403).json({ error: 'Invalid host password' }); + } + } catch (error) { + const msg = (error as Error).message; + if (msg.includes('does not exist')) { + res.status(404).json({ error: msg }); + } else { + res.status(500).json({ error: msg }); + } + } + } + /** * Automatically removes sessions that have been inactive for more than 30 minutes. * Runs every 5 minutes. */ public autoKillInactiveSessions(): void { - let instances: ServerInstanceData[] = []; + let instances: SessionData[] = []; function handleData(snapshot: DataSnapshot): any { if (snapshot.exists()) { const data = snapshot.val(); - instances = Object.values(data); + instances = Object.values(data); } else { instances = []; } @@ -296,7 +381,7 @@ export class App { LERROR('Error fetching instance data: ', error); } - subscribeToDatabase('InstanceData', 'value', handleData, handleError); + subscribeToDatabase('SessionData', 'value', handleData, handleError); setInterval( async () => { @@ -306,7 +391,7 @@ export class App { // Because the removeServerInstanceFromDb will alter the firebase and we are // subscribed to the database, as such the `instances` array will update while // we would be looping over it, unsure of the behaviour of that. - const instancesToRemove: ServerInstanceData[] = []; + const instancesToRemove: SessionData[] = []; for (const instance of instances) { // If the server has been running for more than 30 inactive minutes we kill it const inactiveUptime = now - instance.inactiveTimeStamp; @@ -319,7 +404,7 @@ export class App { for (const instance of instancesToRemove) { try { await this._wormhole.removeSession(instance.id); - await removeServerInstanceFromDb(instance.id); + await removeSessionFromDb(instance.id); } catch (error) { LERROR('Error removing inactive server:', error); } diff --git a/src/session.ts b/src/session.ts index 8fedb7b..f83fe7d 100644 --- a/src/session.ts +++ b/src/session.ts @@ -50,7 +50,8 @@ export class Session { /** * The clear-text password that peers need to provide to connect. This password has a - * maximum length of 65535 characters and can consist only of ASCII characters + * maximum length of 65535 characters and can consist only of ASCII characters. + * An empty string means the room is public and any password is accepted. */ private password: string; @@ -89,12 +90,12 @@ export class Session { */ constructor( name: string, - password: string, + password: string | null, hostPassword: string, id: string | null = null ) { this.name = name; - this.password = password; + this.password = password ?? ''; this.hostPassword = hostPassword; if (id) { this.id = id; @@ -204,7 +205,7 @@ export class Session { password: string, hostPassword: string | null ): boolean { - if (password !== this.password) { + if (this.password !== '' && password !== this.password) { LDEBUG(`Session ${this.name}: Invalid password`); return false; } diff --git a/src/types/types.ts b/src/types/types.ts index 27a4712..2fadce4 100644 --- a/src/types/types.ts +++ b/src/types/types.ts @@ -1,13 +1,12 @@ import { Socket } from 'net'; -export type ServerInstanceData = { +export type SessionData = { id: string; active: boolean; inactiveTimeStamp: number; created: number; usage: number; - password: string; - hostPassword: string; + password: string | null; nPeers: number; currentHost: string; roomName: string; @@ -16,7 +15,7 @@ export type ServerInstanceData = { owner: string | null; }; -export type InstanceHistoryData = { +export type SessionHistoryData = { id: string; inactiveTimeStamp: number; created: number; diff --git a/src/utils.ts b/src/utils.ts index 7849a19..1e5f884 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -23,6 +23,7 @@ ****************************************************************************************/ import { env } from './config/env'; +import { MessageType } from './types/types'; // Color codes for console logs const FgRed = '\x1b[31m'; @@ -62,3 +63,22 @@ export function LERROR(message: string, ...params: any[]): void { export function LINFO(message: string, ...params: any[]): void { console.log(FgWhite, message, ...params); } + +export function toString(messageType: MessageType) { + switch (messageType) { + case MessageType.Authentication: + return 'Authentication'; + case MessageType.ConnectionStatus: + return 'ConnectionStatus'; + case MessageType.Data: + return 'Data'; + case MessageType.HostshipRequest: + return 'HostshipRequest'; + case MessageType.HostshipResignation: + return 'HostshipResignation'; + case MessageType.NConnections: + return 'NConnections'; + default: + throw new Error(`Missing implementation case for '${messageType}'`); + } +} diff --git a/src/wormhole.ts b/src/wormhole.ts index d4c2963..0ddaa13 100644 --- a/src/wormhole.ts +++ b/src/wormhole.ts @@ -29,7 +29,7 @@ import { MessageType, Peer } from './types/types'; -import { LDEBUG, LERROR, LINFO } from './utils'; +import { LDEBUG, LERROR, LINFO, toString } from './utils'; import * as net from 'net'; /** @@ -40,37 +40,37 @@ export class Wormhole { /** * The port on which this TCP server is listening */ - private port: number; + private _port: number; - private peerIdCounter: number; + private _peerIdCounter: number; /** * All sessions currently managed by this Wormhole, keyed by session name. */ - private instances: { [name: string]: Session } = {}; + private _sessions: { [name: string]: Session } = {}; /** * The local server that is listening to incoming connections that will result in Peers */ - private server: net.Server; + private _server: net.Server; /** * @param port The port on which this TCP server should listen for incoming connections. * This port must not already be in use on this computer. */ constructor(port: number) { - this.port = port; - this.peerIdCounter = 0; + this._port = port; + this._peerIdCounter = 0; // Setup the TCP server for handling incoming OpenSpace connections - this.server = net.createServer(); + this._server = net.createServer(); - this.server.on('connection', (socket: net.Socket) => { + this._server.on('connection', (socket: net.Socket) => { LDEBUG('New connection', socket.remoteAddress); // The Peer is only added to the list if the authentication is succesful. // eslint-disable-next-line prefer-const let peer: Peer = { - id: this.peerIdCounter, // TODO: assign a unique ID, see comment in ´onEnd´ + id: this._peerIdCounter, // TODO: assign a unique ID, see comment in ´onEnd´ name: '', socket: socket, status: ConnectionStatus.Connecting, @@ -98,18 +98,18 @@ export class Wormhole { */ public start(): Promise { return new Promise((resolve, reject) => { - this.server.on('listening', () => { - LINFO(`Wormhole listening on port ${this.port}`); + this._server.on('listening', () => { + LINFO(`Wormhole listening on port ${this._port}`); resolve(true); }); - this.server.on('error', (error: Error) => { - this.server.close(); + this._server.on('error', (error: Error) => { + this._server.close(); LERROR(`Error starting server: ${error.message}`); reject(error); }); - this.server.listen(this.port); + this._server.listen(this._port); }); } @@ -121,18 +121,18 @@ export class Wormhole { */ public async addSession( name: string, - password: string, + password: string | null, hostPassword: string, id: string | null = null ): Promise { // If the session already exists, remove it (this *should* not happen) - if (name in this.instances) { - const oldSession = this.instances[name]; + if (name in this._sessions) { + const oldSession = this._sessions[name]; await this.removeSession(oldSession.getSessionMetadata().id!); } const session = new Session(name, password, hostPassword, id); - this.instances[name] = session; + this._sessions[name] = session; return session; } @@ -140,38 +140,40 @@ export class Wormhole { * @return The number of active sessions currently running */ public activeSessions(): number { - return Object.keys(this.instances).length; + return Object.keys(this._sessions).length; } /** - * Removes a session by ID. Disconnects all its peers, then deletes it from the - * internal registry. Resolves once all peers have been disconnected. + * Removes a session by ID or by Session object reference. Disconnects all peers before + * removing from the internal registry. Resolves once all peers have been disconnected. * + * @param sessionIdOrSession The session ID string, or the Session object directly * @return A promise that resolves with a success message, or rejects on error */ - public async removeSession(sessionID: string): Promise { - const session = Object.values(this.instances).find((session: Session) => { - return session.getSessionMetadata().id === sessionID; - }); + public async removeSession(sessionIdOrSession: string | Session): Promise { + const session = + typeof sessionIdOrSession === 'string' + ? Object.values(this._sessions).find((session: Session) => { + return session.getSessionMetadata().id === sessionIdOrSession; + }) + : sessionIdOrSession; if (!session) { - const errorMsg = `Session '${sessionID}' not found`; - LDEBUG(errorMsg); - throw new Error(errorMsg); + throw new Error('Session not found'); } + const { id, sessionName } = session.getSessionMetadata(); try { // Notify all peers that the session is closing await session.stop(); - // Remove session from internal registry - delete this.instances[session.getSessionMetadata().sessionName]; + delete this._sessions[sessionName]; - const successMsg = `Session "${sessionID}" successfully removed`; - LDEBUG(successMsg); - return successMsg; + const msg = `Session '${id ?? sessionName}' successfully removed`; + LDEBUG(msg); + return msg; } catch (error) { - const errorMsg = `Error stopping session "${sessionID}": ${(error as Error).message}`; + const errorMsg = `Error stopping session "${sessionIdOrSession}": ${(error as Error).message}`; LERROR(errorMsg); throw error; } @@ -229,7 +231,7 @@ export class Wormhole { offset += Uint32Array.BYTES_PER_ELEMENT; if (data.byteLength !== offset + messageSize) { LDEBUG('The provided message size was not the same as the actual message length'); - LDEBUG(`Received message type: ${messageType}`); + LDEBUG(`Received message type: ${toString(messageType)}`); LDEBUG(`Header size: ${HeaderSize}`); LDEBUG(`Data size: ${data.byteLength}`); LDEBUG(`Data byteoffset: ${data.byteOffset}`); @@ -271,7 +273,7 @@ export class Wormhole { private onEnd(peer: Peer): void { LDEBUG(`Peer ${peer.id} disconnected, closing connection`); // Get the session that the peer is connected to - const session = this.instances[peer.sessionName]; + const session = this._sessions[peer.sessionName]; if (!session) { LDEBUG(`Session "${peer.sessionName}" not found`); @@ -300,12 +302,10 @@ export class Wormhole { let offset = 0; const passwordLength = dv.getUint16(offset, true); offset += Uint16Array.BYTES_PER_ELEMENT; - if (passwordLength === 0) { - // The password length cannot be zero - LDEBUG('Invalid password length'); - return; - } - const password = data.subarray(offset, offset + passwordLength).toString('utf-8'); + const password = + passwordLength === 0 + ? '' + : data.subarray(offset, offset + passwordLength).toString('utf-8'); offset += passwordLength; const hostPasswordLength = dv.getUint16(offset, true); @@ -332,15 +332,15 @@ export class Wormhole { : data.subarray(offset, offset + nameLength).toString('utf-8'); // Get the session that this peer is trying to connect to - const session = this.instances[sessionName]; + const session = this._sessions[sessionName]; if (!session) { - LDEBUG(`No session with name ${sessionName} found`); + LDEBUG(`No session with name '${sessionName}' found`); return; } const authenticated = session.handleAuthentication(peer, password, hostPassword); if (authenticated) { - this.peerIdCounter++; + this._peerIdCounter++; } } @@ -352,7 +352,7 @@ export class Wormhole { * @param peer The Peer from which this message is coming */ private handleData(data: Buffer, peer: Peer): void { - const session = this.instances[peer.sessionName]; + const session = this._sessions[peer.sessionName]; if (!session) { LDEBUG(`No session with name '${peer.sessionName}' found`); return; @@ -369,9 +369,9 @@ export class Wormhole { * @param peer The Peer from which this message arrived */ private handleHostshipRequest(data: Buffer, peer: Peer): void { - const session = this.instances[peer.sessionName]; + const session = this._sessions[peer.sessionName]; if (!session) { - LDEBUG(`No session with name ${peer.sessionName} found`); + LDEBUG(`No session with name '${peer.sessionName}' found`); return; } @@ -387,9 +387,9 @@ export class Wormhole { * @param peer The Peer from which this message arrived */ private handleHostshipResignation(_: Buffer, peer: Peer): void { - const session = this.instances[peer.sessionName]; + const session = this._sessions[peer.sessionName]; if (!session) { - LDEBUG(`No session with name ${peer.sessionName} found`); + LDEBUG(`No session with name '${peer.sessionName}' found`); return; } session.handleHostshipResignation(peer); From 828764570101c708857e55f3f783e10de0b4878c Mon Sep 17 00:00:00 2001 From: Andreas Engberg Date: Tue, 30 Jun 2026 13:25:40 +0200 Subject: [PATCH 05/17] Fix issue where data messages were split and not correctly consumed Uses an internal reciever buffer for each client connected that parses and consume messages in flight, even if they are split by the TCP socket --- src/app.ts | 2 - src/session.ts | 63 +++++++++++++++++--------- src/types/types.ts | 7 +++ src/utils.ts | 20 --------- src/wormhole.ts | 109 +++++++++++++++++++++++++++++---------------- 5 files changed, 120 insertions(+), 81 deletions(-) diff --git a/src/app.ts b/src/app.ts index c648c52..8d55d8b 100644 --- a/src/app.ts +++ b/src/app.ts @@ -294,8 +294,6 @@ export class App { const token = authHeader.slice(7); const sessionId = req.params.id; - LERROR('handleGetHostPassword called with sessionId:', sessionId); - if (!sessionId || typeof sessionId !== 'string') { res.status(400).json({ error: 'Session ID is required' }); return; diff --git a/src/session.ts b/src/session.ts index f83fe7d..5275f5d 100644 --- a/src/session.ts +++ b/src/session.ts @@ -216,10 +216,10 @@ export class Session { // If there is currently no host and this peer provided the host password // we promote them to hostship if (this.currentHostId === null && hostPassword === this.hostPassword) { - LDEBUG(`Session ${this.name}: Connection ${peer.id} ${peer.name} promoted to host`); + LDEBUG(`Session ${this.name}: Peer #${peer.id} ${peer.name} promoted to host`); this.assignHost(peer); } else { - LDEBUG(`Session ${this.name}: Connection ${peer.id} ${peer.name} is client`); + LDEBUG(`Session ${this.name}: Peer #${peer.id} ${peer.name} is client`); peer.status = this.currentHostId === null ? ConnectionStatus.ClientWithoutHost @@ -245,12 +245,13 @@ export class Session { if (peer.id === this.currentHostId) { console.assert( peer.status === ConnectionStatus.Host, - `Server ${this.name}: Peer '${peer.id}' is host but status is not set to host` + `Session ${this.name}: Peer #${peer.id} is host but status is not set to host` ); + const message = this.buildMessage(MessageType.Data, data); this.peers.forEach((peer: Peer) => { if (peer.status === ConnectionStatus.ClientWithHost) { - this.sendMessage(peer, MessageType.Data, data); + this.writeMessage(peer, message); } }); } @@ -265,7 +266,7 @@ export class Session { * @param peer The Peer from which this message arrived */ public handleHostshipRequest(data: Buffer, peer: Peer): void { - LDEBUG(`Session ${this.name}: Peer ${peer.id} ${peer.name} requested hostship`); + LDEBUG(`Session ${this.name}: Peer #${peer.id} ${peer.name} requested hostship`); const dv = new DataView( data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength) @@ -284,13 +285,13 @@ export class Session { if (password !== this.hostPassword) { // Wrong password so we don't do anything LDEBUG( - `Session ${this.name}: Peer ${peer.id} ${peer.name} provided incorrect host password` + `Session ${this.name}: Peer #${peer.id} ${peer.name} provided incorrect host password` ); return; } if (peer.id === this.currentHostId) { - LDEBUG(`Session ${this.name}: Peer ${peer.id} ${peer.name} is already host`); + LDEBUG(`Session ${this.name}: Peer #${peer.id} ${peer.name} is already host`); return; } @@ -305,7 +306,7 @@ export class Session { * @param peer The Peer from which this message arrived */ public handleHostshipResignation(peer: Peer): void { - LDEBUG(`Session ${this.name}: Peer ${peer.id} ${peer.name} resigned hostship`); + LDEBUG(`Session ${this.name}: Peer #${peer.id} ${peer.name} resigned hostship`); // Only remove the host if it was the host peer that requested the resignation if (peer.status === ConnectionStatus.Host) { @@ -314,14 +315,13 @@ export class Session { } /** - * Send a message to the provided peer of the provided messageType with the specified - * payload data. + * Construct a complete message buffer with the provided payload data * - * @param peer The Peer that will recieve the message - * @param messageType The type of message that will be sent - * @param payload The payload of the data that is sent + * @param messageType The type of message to construct + * @param payload The payload of the message + * @returns A buffer object holding the constructed message */ - private sendMessage(peer: Peer, messageType: MessageType, payload: Buffer): void { + private buildMessage(messageType: MessageType, payload: Buffer): Buffer { const buffer = Buffer.alloc( 'OS'.length + // OS prefix Uint8Array.BYTES_PER_ELEMENT + // protocol version number @@ -353,11 +353,31 @@ export class Session { // Copy the payload into the message payload.copy(buffer, offset); + return buffer; + } + /** + * Write the `message` to the provided `peer` + * @param peer The peer that will recieve the message + * @param message The message payload data that is sent + */ + private writeMessage(peer: Peer, message: Buffer): void { LDEBUG( - `Session ${this.name}: Sending to peer with id: '${peer.id}', name: '${peer.name}', data: '${buffer}` + `Session ${this.name}: Sending to peer with id: #${peer.id}, name: '${peer.name}'` ); - peer.socket.write(buffer); + peer.socket.write(message); + } + + /** + * Send a message to the provided peer of the provided messageType with the specified + * payload data. + * + * @param peer The Peer that will recieve the message + * @param messageType The type of message that will be sent + * @param payload The payload of the data that is sent + */ + private sendMessage(peer: Peer, messageType: MessageType, payload: Buffer): void { + this.writeMessage(peer, this.buildMessage(messageType, payload)); } /** @@ -367,7 +387,7 @@ export class Session { */ private sendConnectionStatus(peer: Peer): void { LDEBUG( - `Session ${this.name}: Sending connection status to peer with id: '${peer.id}', name: '${peer.name}'` + `Session ${this.name}: Sending connection status to peer with id: #${peer.id}, name: '${peer.name}'` ); const host = this.currentHost(); @@ -405,9 +425,10 @@ export class Session { const buffer = Buffer.alloc(Uint32Array.BYTES_PER_ELEMENT); // Number of connections const dv = new DataView(buffer.buffer, buffer.byteOffset, buffer.byteLength); dv.setUint32(0, this.peers.length, true); - this.peers.forEach((peer: Peer) => { - this.sendMessage(peer, MessageType.NConnections, buffer); - }); + + const message = this.buildMessage(MessageType.NConnections, buffer); + + this.peers.forEach((peer: Peer) => this.writeMessage(peer, message)); // Update the server status depending on if there still are any users connected const isServerActive = this.peers.length > 0; updateActiveSessionStatus(this.id!, isServerActive); @@ -424,7 +445,7 @@ export class Session { const idx = this.peers.findIndex((peer: Peer) => peer.id === this.currentHostId); if (idx === -1) { - LDEBUG(`Could not find host with id ${this.currentHostId}`); + LDEBUG(`Could not find host with id '${this.currentHostId}'`); return null; } diff --git a/src/types/types.ts b/src/types/types.ts index 2fadce4..e781e6f 100644 --- a/src/types/types.ts +++ b/src/types/types.ts @@ -76,4 +76,11 @@ export type Peer = { * The name of the session this peer is connected to */ sessionName: string; + + /** + * Accumulates bytes received on the socket that have not yet formed a complete message. + * TCP Gives no guarantee that a single 'data' event aligns with a single + * application-level message. As such, we must parse the messages. + */ + recvBuffer: Buffer; }; diff --git a/src/utils.ts b/src/utils.ts index 1e5f884..7849a19 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -23,7 +23,6 @@ ****************************************************************************************/ import { env } from './config/env'; -import { MessageType } from './types/types'; // Color codes for console logs const FgRed = '\x1b[31m'; @@ -63,22 +62,3 @@ export function LERROR(message: string, ...params: any[]): void { export function LINFO(message: string, ...params: any[]): void { console.log(FgWhite, message, ...params); } - -export function toString(messageType: MessageType) { - switch (messageType) { - case MessageType.Authentication: - return 'Authentication'; - case MessageType.ConnectionStatus: - return 'ConnectionStatus'; - case MessageType.Data: - return 'Data'; - case MessageType.HostshipRequest: - return 'HostshipRequest'; - case MessageType.HostshipResignation: - return 'HostshipResignation'; - case MessageType.NConnections: - return 'NConnections'; - default: - throw new Error(`Missing implementation case for '${messageType}'`); - } -} diff --git a/src/wormhole.ts b/src/wormhole.ts index 0ddaa13..b44bdc9 100644 --- a/src/wormhole.ts +++ b/src/wormhole.ts @@ -29,9 +29,12 @@ import { MessageType, Peer } from './types/types'; -import { LDEBUG, LERROR, LINFO, toString } from './utils'; +import { LDEBUG, LERROR, LINFO } from './utils'; import * as net from 'net'; +const NeedMoreData = 0; +const ProtocolError = -1; + /** * A TCP server that listens on a single port and routes incoming OpenSpace connections * into their respective named sessions. Manages the full lifecycle of all active sessions. @@ -66,6 +69,7 @@ export class Wormhole { this._server.on('connection', (socket: net.Socket) => { LDEBUG('New connection', socket.remoteAddress); + socket.setNoDelay(true); // The Peer is only added to the list if the authentication is succesful. // eslint-disable-next-line prefer-const @@ -74,7 +78,8 @@ export class Wormhole { name: '', socket: socket, status: ConnectionStatus.Connecting, - sessionName: '' + sessionName: '', + recvBuffer: Buffer.alloc(0) }; socket.on('data', (data: Buffer) => { @@ -84,7 +89,7 @@ export class Wormhole { this.onEnd(peer); }); socket.on('error', (error) => { - LERROR(`Error: ${error} from peer ${peer.id}`); + LERROR(`Error peer #${peer.id}: ${error}`); this.onEnd(peer); }); }); @@ -180,69 +185,95 @@ export class Wormhole { } /** - * Handles incoming data packages on the socket. We unpack the message and bail early - * if there are any errors in the provided message. A valid message is then forwarded - * to the appropriate handlers. + * Handles incoming raw bytes from the peer's socket. TCP gives no guarantee that a + * single 'data' event aligns with a single application-level message, i.e., a message + * can arrive split across several events, or several messages can arrived coalesced + * into one event. * * @param data The data package that was received on the socket * @param peer The Peer from which this message arrived */ private onData(data: Buffer, peer: Peer): void { + peer.recvBuffer = + peer.recvBuffer.length === 0 ? data : Buffer.concat([peer.recvBuffer, data]); + + // Keep extracting messages for as long as the buffer holds a complete one + let consumed = this.processMessage(peer); + while (consumed > NeedMoreData) { + peer.recvBuffer = peer.recvBuffer.subarray(consumed); + consumed = this.processMessage(peer); + } + + if (consumed === ProtocolError) { + LDEBUG(`Peer #${peer.id}: connection terminated after protocol error`); + } + } + + /** + * Attempts to parse and sipatch a single complete message from the front of + * `peer.recvBuffer`. If the buffer does not yet hold a full header, or a full header + * plus its playload, no action is taken. If the header is malformed, the stream is + * considered unrecoverable. + * @param peer The Peer whose buffer should be parsed + * @returns The nubmer of bytes (header + payload) consumed from the front of the + * buffer. Returns 0 if no complete message could be extracted, which happens either + * becasue more data is still needed, or because the connection was just terminated due + * to a protocol error + */ + private processMessage(peer: Peer): number { const HeaderSize = 'OS'.length + // OS prefix - Uint8Array.BYTES_PER_ELEMENT + // protocol version number - Uint8Array.BYTES_PER_ELEMENT + // message type identifier - Uint32Array.BYTES_PER_ELEMENT; // payload size in bytes + Uint8Array.BYTES_PER_ELEMENT + // Protocol version number + Uint8Array.BYTES_PER_ELEMENT + // Message type identifier + Uint32Array.BYTES_PER_ELEMENT; // Payload size in bytes - // Exit early if we don't have a valid header information - if (data.length < HeaderSize) { - LDEBUG('Invalid header information'); - return; - } + const buffer = peer.recvBuffer; - const prefix = data.subarray(0, 2).toString('utf-8'); - if (prefix !== 'OS') { - LDEBUG(`The message did not start with OS prefix, invalid message: '${prefix}'`); - return; + if (buffer.length < HeaderSize) { + return NeedMoreData; // Header hasn't fully arrived yet } - const dv = new DataView( - data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength) - ); + // Check the prefix matches 'OS' + if (buffer[0] !== 0x4f || buffer[1] !== 0x53) { + LERROR( + `Peer #${peer.id}: stream desynced, expected 'OS' prefix, got ` + + `'${buffer.subarray(0, 2).toString('utf-8')}` + ); + peer.socket.destroy(new Error('Protocol desync')); + return ProtocolError; + } + const dv = new DataView(buffer.buffer, buffer.byteOffset, buffer.byteLength); let offset = 'OS'.length; + const protocolVersion = dv.getUint8(offset); offset += Uint8Array.BYTES_PER_ELEMENT; if (protocolVersion !== CurrentProtocolVersion) { - LDEBUG( - `Invalid protocol version: '${protocolVersion}', expected: '${CurrentProtocolVersion}'` - ); - return; + LERROR(`Peer ${peer.id}: invalid protocol version '${protocolVersion}'`); + peer.socket.destroy(new Error('Protocol version mismatch')); + return ProtocolError; } const messageType = dv.getUint8(offset); offset += Uint8Array.BYTES_PER_ELEMENT; if (!(messageType in MessageType)) { - LDEBUG(`Invalid message type: '${messageType}'`); - return; + LERROR(`Peer ${peer.id}: invalid message type '${messageType}'`); + peer.socket.destroy(new Error('Invalid message type')); + return ProtocolError; } - const messageSize = dv.getUint32(offset, true); + const payloadSize = dv.getUint32(offset, true); offset += Uint32Array.BYTES_PER_ELEMENT; - if (data.byteLength !== offset + messageSize) { - LDEBUG('The provided message size was not the same as the actual message length'); - LDEBUG(`Received message type: ${toString(messageType)}`); - LDEBUG(`Header size: ${HeaderSize}`); - LDEBUG(`Data size: ${data.byteLength}`); - LDEBUG(`Data byteoffset: ${data.byteOffset}`); - LDEBUG(`Message size: ${messageSize}`); - LDEBUG(`Offset: ${offset}`); - return; + const messageSize = offset + payloadSize; + + if (buffer.byteLength < messageSize) { + return NeedMoreData; // Payload hasn't fully arrived yet } // Slice the header data from the message - const messagePayload = data.subarray(offset); + const messagePayload = buffer.subarray(offset, messageSize); + // Dispatch message switch (messageType) { case MessageType.Authentication: this.handleAuthentication(messagePayload, peer); @@ -257,6 +288,8 @@ export class Wormhole { this.handleHostshipResignation(messagePayload, peer); break; } + + return messageSize; } /** From 3dd6df676f8350c26d87d2416bea3b010313ce02 Mon Sep 17 00:00:00 2001 From: Andreas Engberg Date: Tue, 30 Jun 2026 13:32:27 +0200 Subject: [PATCH 06/17] Assign a peerId only if the peer authenticated with the session --- src/session.ts | 90 +++++++++++++++++++++++-------------------------- src/wormhole.ts | 21 +++++------- 2 files changed, 52 insertions(+), 59 deletions(-) diff --git a/src/session.ts b/src/session.ts index 5275f5d..177567b 100644 --- a/src/session.ts +++ b/src/session.ts @@ -163,10 +163,6 @@ export class Session { * peers and free up the ID slot. If the Peer that disconnected was the host, we * inform all connected peers that they no longer have a host. * - * TODO: Currently we assign the ID of a connected peer by the length of the array of - * connected peers. If there are 2 peers connected and the first one disconnects and - * then reconnects, both peers will end up with ID = 1. - * * @param peer The Peer that just disconnected */ public onEnd(peer: Peer): void { @@ -190,49 +186,6 @@ export class Session { LDEBUG(`Session ${this.name}: Number of connections remaining: ${this.peers.length}`); } - /** - * Handles the incoming authentication method by the provided peer. If the - * authentication is valid and contains the correct password, the peer is added to the - * list in the group. If the host password is also correct and there is no currently - * assigned host, the peer is automatically promoted to hostship too. - * - * @param data The payload of the authentication message - * @param peer The Peer from which this message arrived - * @return True if the authentication was successful, false otherwise - */ - public handleAuthentication( - peer: Peer, - password: string, - hostPassword: string | null - ): boolean { - if (this.password !== '' && password !== this.password) { - LDEBUG(`Session ${this.name}: Invalid password`); - return false; - } - - // We can add this peer to the list of all peers in the group - this.peers.push(peer); - - // If there is currently no host and this peer provided the host password - // we promote them to hostship - if (this.currentHostId === null && hostPassword === this.hostPassword) { - LDEBUG(`Session ${this.name}: Peer #${peer.id} ${peer.name} promoted to host`); - this.assignHost(peer); - } else { - LDEBUG(`Session ${this.name}: Peer #${peer.id} ${peer.name} is client`); - peer.status = - this.currentHostId === null - ? ConnectionStatus.ClientWithoutHost - : ConnectionStatus.ClientWithHost; - - this.sendConnectionStatus(peer); - } - - this.sendNumberOfConnections(); - updateUsage(this.id!); - return true; - } - /** * Handle incoming data from the provided peer, we only forward the data along to * other peers if it comes from the host. @@ -314,6 +267,49 @@ export class Session { } } + /** + * Checks whether the provided password grants access to this session. Any empty room + * password means the room is public and any password is accepted. + * + * @param password The room password provided by the peer + * @returns True if the password is correct, false otherwise + */ + public isPasswordValid(password: string): boolean { + return this.password === '' || password === this.password; + } + + /** + * Registers an already-validated peer into the session: adds it to the list of + * connected peers, and pormotes it to host if it provided the correct host password + * and no host is currently assigned. The caller is responsible for having confirmed + * the room password, and an existing `peer.id` already being set + * + * @param peer The Peer to register + * @param hostPassword The host password provided by the peer, or null if none was given + */ + public registerPeer(peer: Peer, hostPassword: string | null): void { + // We can add this peer to the list of all peers in the group + this.peers.push(peer); + + // If there is currently no host and this peer provided the host password + // we promote them to hostship + if (this.currentHostId === null && hostPassword === this.hostPassword) { + LDEBUG(`Session ${this.name}: Peer #${peer.id} ${peer.name} promoted to host`); + this.assignHost(peer); + } else { + LDEBUG(`Session ${this.name}: Peer #${peer.id} ${peer.name} is client`); + peer.status = + this.currentHostId === null + ? ConnectionStatus.ClientWithoutHost + : ConnectionStatus.ClientWithHost; + + this.sendConnectionStatus(peer); + } + + this.sendNumberOfConnections(); + updateUsage(this.id!); + } + /** * Construct a complete message buffer with the provided payload data * diff --git a/src/wormhole.ts b/src/wormhole.ts index b44bdc9..3d25bc8 100644 --- a/src/wormhole.ts +++ b/src/wormhole.ts @@ -74,7 +74,7 @@ export class Wormhole { // The Peer is only added to the list if the authentication is succesful. // eslint-disable-next-line prefer-const let peer: Peer = { - id: this._peerIdCounter, // TODO: assign a unique ID, see comment in ´onEnd´ + id: -1, // Id is assigned once authentication is attempted, see `handleAuthentication` name: '', socket: socket, status: ConnectionStatus.Connecting, @@ -293,13 +293,7 @@ export class Wormhole { } /** - * Handles the disconnection of a peer. We remove the Peer from the list of connected - * peers and free up the ID slot. If the Peer that disconnected was the host, we - * inform all connected peers that they no longer have a host. - * - * TODO: Currently we assign the ID of a connected peer by the length of the array of - * connected peers. If there are 2 peers connected and the first one disconnects and - * then reconnects, both peers will end up with ID = 1. + * Handles the disconnection of a peer. * * @param peer The Peer that just disconnected */ @@ -345,7 +339,7 @@ export class Wormhole { offset += Uint16Array.BYTES_PER_ELEMENT; const hostPassword = hostPasswordLength === 0 - ? '' + ? null : data.subarray(offset, offset + hostPasswordLength).toString('utf-8'); offset += hostPasswordLength; @@ -371,10 +365,13 @@ export class Wormhole { return; } - const authenticated = session.handleAuthentication(peer, password, hostPassword); - if (authenticated) { - this._peerIdCounter++; + if (!session.isPasswordValid(password)) { + LDEBUG(`Peer provided invalid password for session '${sessionName}'`); + return; } + + peer.id = this._peerIdCounter++; + session.registerPeer(peer, hostPassword); } /** From 548f8323aa1ba701e2fc66ce51c190304a60d94e Mon Sep 17 00:00:00 2001 From: Andreas Engberg Date: Wed, 1 Jul 2026 14:45:22 +0200 Subject: [PATCH 07/17] Unify and fix comments, and log messages. Minor code cleanup --- src/adminApi.ts | 96 ++++++++++++++++------------- src/adminFirebaseConfig.ts | 3 +- src/app.ts | 68 ++++++++++++--------- src/index.ts | 3 +- src/session.ts | 121 ++++++++++++++++++++----------------- src/types/types.ts | 10 +-- src/wormhole.ts | 92 ++++++++++++++-------------- 7 files changed, 213 insertions(+), 180 deletions(-) diff --git a/src/adminApi.ts b/src/adminApi.ts index 4a5226f..42a6962 100644 --- a/src/adminApi.ts +++ b/src/adminApi.ts @@ -30,7 +30,7 @@ import { getAuth, UserRecord } from 'firebase-admin/auth'; import { DataSnapshot, EventType, getDatabase, Reference } from 'firebase-admin/database'; /** - * Set admin rights for a user in the firebase auth database. + * Set admin rights for a user in the Firebase auth database * * @param uid The user uid to set admin rights for * @param secret The secret to verify the request @@ -51,15 +51,15 @@ export async function setAdminRights(uid: string, secret: string) { const auth = getAuth(adminAuthApp); try { await auth.setCustomUserClaims(uid, { admin: true }); - LINFO(`Successfully set admin rights for user: ${uid}`); + LINFO(`User '${uid}': admin rights granted`); } catch (error) { - LERROR('Internal error', (error as Error).message, `uid: '${uid}'`); + LERROR(`User '${uid}': failed to set admin rights`, error); throw error; } } /** - * Authorize a user to the firebase auth database. + * Authorize a user to the Firebase auth database * * @param token The client user id token to authorize * @return A promise that resolves with the user firebase uid @@ -71,7 +71,7 @@ export async function authorizeUser(token: string): Promise { } /** - * Fetch the display name of a user given their uid. + * Fetch the display name of a user given their uid * * @param token Token to fetch user information * @return A promise that resolves with the user information @@ -82,7 +82,9 @@ export async function getUserByID(token: string): Promise { } /** - * @return A promise that resolves with an array of server instances or empty array + * Fetch all live sessions from Firebase database + * + * @return A promise that resolves with an array of sessions */ export async function getSessionsFromDb(): Promise { const db = getDatabase(adminDbApp); @@ -91,9 +93,8 @@ export async function getSessionsFromDb(): Promise { return []; } - const data = snapshot.val(); - const instances = Object.values(data); - return instances; + const sessions = Object.values(snapshot.val()); + return sessions; } /** @@ -130,14 +131,16 @@ export async function postSession( uid: string ): Promise { const db = getDatabase(adminDbApp); - const sessionsRef = db.ref('SessionData'); - const newPostRef = sessionsRef.push(); - const postId = newPostRef.key!; + const newPostRef = await db.ref('SessionData').push(); + const postId = newPostRef.key; + if (!postId) { + throw new Error('Failed to push SessionData to database'); + } const metadata = session.getSessionMetadata(); await db.ref(`SessionSecrets/${postId}`).set({ hostPassword: metadata.hostPassword }); - LDEBUG(`Posted host password to database for session: ${postId}`); + LDEBUG(`Session '${postId}': host password posted to database`); const data: SessionData = { active: false, @@ -154,12 +157,13 @@ export async function postSession( owner: uid }; await newPostRef.set(data); - LDEBUG('Posted a new instance to database:', data); + LDEBUG(`Session '${postId}': new instance posted to databse`, data); return data; } -/*** - * Fetch the host password for a given session ID and user ID. +/** + * Fetch the host password for a given session ID and user ID. Only sessions that are + * owned by the uid can be fetched * * @param sessionId The ID of the session to fetch the host password for * @param uid The user ID of the requester @@ -169,7 +173,7 @@ export async function getHostPassword(sessionId: string, uid: string): Promise { const snapshot = await instanceRef.get(); if (!snapshot.exists()) { - const errorMessage = `Error removing instance from database: "${sessionId}" does not exist`; + const errorMessage = `Session '${sessionId}': does not exists, cannot remove`; LDEBUG(errorMessage); throw new Error(errorMessage); } @@ -258,11 +269,11 @@ export async function removeSessionFromDb(sessionId: string): Promise { await postSessionHistoryData(snapshot.val()); await instanceRef.remove(); await db.ref(`SessionSecrets/${sessionId}`).remove(); - LINFO(`Instance with id ${sessionId} was removed from the database`); + LINFO(`Session '${sessionId}': removed from database`); } catch (error) { - const errorMessage = `Error removing instance "${sessionId}" from DB: ${(error as Error).message}`; - LERROR(errorMessage); - throw new Error(errorMessage); + const errorMessage = `Session '${sessionId}': failed to remove from database`; + LERROR(errorMessage, error); + throw new Error(`${errorMessage}: ${(error as Error).message}`); } } @@ -282,7 +293,7 @@ export async function updateActiveSessionStatus( const snapshot = await instanceRef.get(); if (!snapshot.exists()) { - throw new Error(`Could not find instance with id "${sessionId}"`); + throw new Error(`Could not find instance with id '${sessionId}'`); } // if instance is offline we also need to update the inactive timestamp if (!online) { @@ -290,9 +301,9 @@ export async function updateActiveSessionStatus( } else { await instanceRef.update({ active: online }); } - LDEBUG(`Updated session status for "${sessionId}" to: ${online}`); + LDEBUG(`Session '${sessionId}': active status updated to ${online}`); } catch (error) { - LERROR(`Error updating session status for "${sessionId}:"`, error); + LERROR(`Session '${sessionId}': failed to update active status`, error); } } @@ -312,13 +323,12 @@ export async function updateCurrentActiveUsers( const snapshot = await instanceRef.get(); if (!snapshot) { - throw new Error(`Could not find instance with id "${sessionId}"`); + throw new Error(`Could not find session with id '${sessionId}'`); } - await instanceRef.update({ nPeers: nPeers }); - LDEBUG(`Updated # active users for "${sessionId}" to: ${nPeers}`); + LDEBUG(`Session '${sessionId}': active user count updated to ${nPeers}`); } catch (error) { - LERROR(`Error updating # active users for "${sessionId}:"`, error); + LERROR(`Session '${sessionId}': failed to update active userc count`, error); } await updateStatistics(sessionId, nPeers); } @@ -339,9 +349,9 @@ export async function updateStatistics(sessionId: string, nPeers: number): Promi }; await statisticsRef.push(stats); - LDEBUG('Pushed new statistics to database:', stats); + LDEBUG(`Session '${sessionId}': statistics entry pushed, stats`); } catch (error) { - LERROR(`Error updating statistics for "${sessionId}:"`, error); + LERROR(`Session '${sessionId}': failed to push statistics entry`, error); } } /** @@ -356,12 +366,12 @@ export async function updateCurrentHost(sessionId: string, host: string) { const instanceRef = db.ref(`SessionData/${sessionId}`); const snapshot = await instanceRef.get(); if (!snapshot.exists()) { - throw new Error(`Could not find instance with id "${sessionId}"`); + throw new Error(`Could not find instance with id '${sessionId}'`); } await instanceRef.update({ currentHost: host }); - LDEBUG(`Updated current host for "${sessionId}" to: ${host}`); + LDEBUG(`Session '${sessionId}': current host updated to '${host}'`); } catch (error) { - LERROR(`Error updating current host for ${sessionId}:`, error); + LERROR(`Session '${sessionId}': failed to update current host`, error); } } @@ -376,12 +386,12 @@ export async function updateUsage(sessionId: string) { const instanceRef = db.ref(`SessionData/${sessionId}`); const snapshot = await instanceRef.get(); if (!snapshot.exists()) { - throw new Error(`Could not find instance with id "${sessionId}"`); + throw new Error(`Could not find instance with id '${sessionId}'`); } const data = snapshot.val() as SessionData; await instanceRef.update({ usage: data.usage + 1 }); - LDEBUG(`Updated usage for "${sessionId}" to: ${data.usage + 1}`); + LDEBUG(`Session '${sessionId}': usage updated to ${data.usage + 1}`); } catch (error) { - LERROR(`Error updating instance ${sessionId} usage:`, error); + LERROR(`Session '${sessionId}': failed to update usage`, error); } } diff --git a/src/adminFirebaseConfig.ts b/src/adminFirebaseConfig.ts index 1d43f8b..1a23832 100644 --- a/src/adminFirebaseConfig.ts +++ b/src/adminFirebaseConfig.ts @@ -31,7 +31,8 @@ function loadServiceAccount(filePath: string, envVar: string): object { return JSON.parse(readFileSync(filePath, 'utf-8')); } catch (err) { throw new Error( - `Failed to load Firebase service account from ${envVar}="${filePath}": ${(err as Error).message}` + `Failed to load Firebase service account from ${envVar}="${filePath}": ` + + `${(err as Error).message}` ); } } diff --git a/src/app.ts b/src/app.ts index 8d55d8b..79335ec 100644 --- a/src/app.ts +++ b/src/app.ts @@ -59,7 +59,8 @@ export class App { /** * @param httpPort The port on which the HTTP server will listen for incoming requests - * @param wormholePort The port on which the Wormhole will listen for incoming peer connections + * @param wormholePort The port on which the Wormhole will listen for incoming peer + * connections * @param apiPath The path to the API endpoint for the server, e.g. `/api` */ constructor(httpPort: number, wormholePort: number, apiPath: string) { @@ -90,7 +91,7 @@ export class App { ); this._app.listen(httpPort, () => { - LINFO(`Server listening on port ${httpPort}`); + LINFO(`HTTP server: listening on port ${httpPort}`); }); } @@ -130,7 +131,7 @@ export class App { const instances = await getSessionsFromDb(); if (!instances || !instances.length) { - LINFO('No existing sessions found in database'); + LINFO('Database: no existing sessions found in database'); return; } // Add sessions to internal registry @@ -144,7 +145,10 @@ export class App { ); } - LINFO(`Loaded ${this._wormhole.activeSessions()} existing sessions from database`); + LINFO( + `Database: loaded ${this._wormhole.activeSessions()} existing session(s) from ` + + `the database` + ); } /** @@ -205,7 +209,8 @@ export class App { /** * Handle the request to create a new session. * - * @param req Request body must contain password, hostpassword, roomname, profile, and token + * @param req Request body must contain password, hostpassword, roomname, profile, and + * token * @return A promise that resolves with the session metadata or rejects with an error */ private async handleRequestSession(req: Request, res: Response): Promise { @@ -261,9 +266,13 @@ export class App { let sessionData: SessionData; try { sessionData = await postSession(session, profile, isPrivateRoom, uid); - } catch (e) { + } catch (error) { await this._wormhole.removeSession(session); - LERROR('Failed to post session to database, rolling back wormhole session:', e); + LERROR( + `Session '${roomName}': failed to post session to database, rolling back ` + + `Wormhole session`, + error + ); res.status(500).json({ error: 'Failed to create session, please try again' }); return; } @@ -271,17 +280,17 @@ export class App { // Server was successfully created, send back information to the client so they // can connect to it through OpenSpace res.json(sessionData); - } catch (e) { - LERROR('Error creating server instance:', e); + } catch (error) { + LERROR(`Session '${roomName}': failed to create server instance`, error); // Report an internal server error to the client - res.status(500).json({ error: (e as Error).message }); + res.status(500).json({ error: (error as Error).message }); return; } } /** - * Handle the request to retrieve the host password for a session. - * Only the session owner may access it. + * Handle the request to retrieve the host password for a session. Only the session + * owner may access it. * * @param req Request params must contain the session id as `/:id` */ @@ -316,10 +325,11 @@ export class App { } /** - * Handle a request to claim host for a session. - * Verifies the provided host password; does not check whether a host is currently active. + * Handle a request to claim host for a session. Verifies the provided host password, + * does not check whether a host is currently active * - * @param req Request params must contain the session id as `/:id`; body must contain `password` + * @param req Request params must contain the session id as `/:id`; body must contain + * `password` */ private async handleClaimHost(req: Request, res: Response): Promise { const authHeader = req.headers.authorization; @@ -364,19 +374,19 @@ export class App { * Runs every 5 minutes. */ public autoKillInactiveSessions(): void { - let instances: SessionData[] = []; + let sessions: SessionData[] = []; function handleData(snapshot: DataSnapshot): any { if (snapshot.exists()) { const data = snapshot.val(); - instances = Object.values(data); + sessions = Object.values(data); } else { - instances = []; + sessions = []; } } function handleError(error: Error) { - LERROR('Error fetching instance data: ', error); + LERROR(`Database: failed to fetch session data`, error); } subscribeToDatabase('SessionData', 'value', handleData, handleError); @@ -389,22 +399,22 @@ export class App { // Because the removeServerInstanceFromDb will alter the firebase and we are // subscribed to the database, as such the `instances` array will update while // we would be looping over it, unsure of the behaviour of that. - const instancesToRemove: SessionData[] = []; - for (const instance of instances) { + const sessionsToRemove: SessionData[] = []; + for (const session of sessions) { // If the server has been running for more than 30 inactive minutes we kill it - const inactiveUptime = now - instance.inactiveTimeStamp; - if (!instance.active && inactiveUptime > thirtyMinutes) { - instancesToRemove.push(instance); + const inactiveUptime = now - session.inactiveTimeStamp; + if (!session.active && inactiveUptime > thirtyMinutes) { + sessionsToRemove.push(session); } } - // Remove the instances that have been inactive for too long - for (const instance of instancesToRemove) { + // Remove the sessions that have been inactive for too long + for (const session of sessionsToRemove) { try { - await this._wormhole.removeSession(instance.id); - await removeSessionFromDb(instance.id); + await this._wormhole.removeSession(session.id); + await removeSessionFromDb(session.id); } catch (error) { - LERROR('Error removing inactive server:', error); + LERROR(`Session '${session.id}': failed to remove inactive session`, error); } } }, diff --git a/src/index.ts b/src/index.ts index 02d1d53..04e3b2b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -24,6 +24,7 @@ import { env } from './config/env'; import { App } from './app'; +import { LERROR } from './utils'; async function main() { const application = new App(env.HTTP_PORT, env.WORMHOLE_PORT, env.SERVER_API_PATH); @@ -33,6 +34,6 @@ async function main() { } main().catch((err: Error) => { - console.error('Fatal startup error:', err.message); + LERROR('Wormhole: fatal startup error:', err); process.exit(1); }); diff --git a/src/session.ts b/src/session.ts index 177567b..e903490 100644 --- a/src/session.ts +++ b/src/session.ts @@ -34,7 +34,7 @@ import { MessageType, CurrentProtocolVersion } from './types/types'; -import { LDEBUG } from './utils'; +import { LDEBUG, LERROR } from './utils'; /** * A named room that manages a group of connected peers. One peer may act as the host, @@ -57,12 +57,12 @@ export class Session { /** * The clear-text password for assuming hostship. The host password can have a maximum - * length of 65535 characters and can consist only of ASCII characters + * length of 65535 characters and can consist only of ASCII characters. */ private hostPassword: string; /** - * A unique ID for this session, generated by the authentication provider. + * A unique ID for this session, generated by the Firebase database provider. */ private id: string | null = null; @@ -75,7 +75,7 @@ export class Session { /** * The id of the current host. Note that this is **not** the location of this * peer in the `peers` list, but its `id`, which might be different under some - * circumstances + * circumstances. */ private currentHostId: number | null = null; @@ -83,10 +83,12 @@ export class Session { * Creates a new Session. * * @param name The unique name of this session - * @param password The password peers must provide to join. Maximum 65535 ASCII characters. + * @param password The password peers must provide to join. Maximum 65535 ASCII + * characters. * @param hostPassword The password peers must provide to claim hostship. Maximum 65535 * ASCII characters. - * @param id The unique ID assigned by the authentication provider, or null if not yet set + * @param id The unique ID assigned by the Firebase database provider, or null if not + * yet set */ constructor( name: string, @@ -145,7 +147,7 @@ export class Session { // Handle if socket does not end properly peer.socket.on('error', (err) => { LDEBUG( - `Session ${this.name}: Error while disconnecting peer ${peer.id} ${peer.name}`, + `Session ${this.id}: error disconnecting peer #${peer.id} ('${peer.name}')`, err ); checkAllDisconnected(); @@ -159,20 +161,22 @@ export class Session { } /** - * Handles the disconnection of a peer. We remove the Peer from the list of connected - * peers and free up the ID slot. If the Peer that disconnected was the host, we + * Handles the disconnection of a peer. We remove the peer from the list of connected + * peers and free up the ID slot. If the peer that disconnected was the host, we * inform all connected peers that they no longer have a host. * - * @param peer The Peer that just disconnected + * @param peer The peer that just disconnected */ public onEnd(peer: Peer): void { // Find the index in the peer list of the peer that disconnected const idx = this.peers.findIndex((p: Peer) => p.id === peer.id); - console.assert( - idx !== -1, - `Session ${this.name}: Could not find peer with id ${peer.id} trying to ` + - 'disconnect' - ); + if (idx === -1) { + LERROR( + `Session '${this.id}': could not find peer with id #${peer.id} ` + + `('${peer.name}') on disconnect` + ); + return; + } this.peers.splice(idx, 1); // Remove hostship if the host disconnected @@ -183,7 +187,7 @@ export class Session { // Update the number of connections this.sendNumberOfConnections(); - LDEBUG(`Session ${this.name}: Number of connections remaining: ${this.peers.length}`); + LDEBUG(`Session '${this.id}': ${this.peers.length} connection(s) remaining`); } /** @@ -191,15 +195,18 @@ export class Session { * other peers if it comes from the host. * * @param data The payload of the data message - * @param peer The Peer from which this message is coming + * @param peer The peer from which this message is coming */ public handleData(data: Buffer, peer: Peer): void { // Check if this peer is the current host if (peer.id === this.currentHostId) { - console.assert( - peer.status === ConnectionStatus.Host, - `Session ${this.name}: Peer #${peer.id} is host but status is not set to host` - ); + if (peer.id !== ConnectionStatus.Host) { + LERROR( + `Session '${this.id}': peer #${peer.id} ('${peer.name}') matches ` + + `currentHostId but has status ${peer.status}, expected Host ` + + `(${ConnectionStatus.Host})` + ); + } const message = this.buildMessage(MessageType.Data, data); this.peers.forEach((peer: Peer) => { @@ -213,13 +220,13 @@ export class Session { /** * Handles an incoming hostship request message by the peer. If the message contains * the correct host password, the peer is promoted to hostship and the previous host - * is demoted (if there was one). + * is demoted. * * @param data The payload of the hostship request message - * @param peer The Peer from which this message arrived + * @param peer The peer from which this message arrived */ public handleHostshipRequest(data: Buffer, peer: Peer): void { - LDEBUG(`Session ${this.name}: Peer #${peer.id} ${peer.name} requested hostship`); + LDEBUG(`Session '${this.id}': peer #${peer.id} ('${peer.name}') requested hostship`); const dv = new DataView( data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength) @@ -230,7 +237,7 @@ export class Session { offset += Uint16Array.BYTES_PER_ELEMENT; if (passwordLength === 0) { // No password was given so we don't do anything - LDEBUG(`Session ${this.name}: Invalid password length`); + LDEBUG(`Session '${this.id}': invalid host password length`); return; } @@ -238,13 +245,14 @@ export class Session { if (password !== this.hostPassword) { // Wrong password so we don't do anything LDEBUG( - `Session ${this.name}: Peer #${peer.id} ${peer.name} provided incorrect host password` + `Session '${this.id}': peer #${peer.id} ('${peer.name}') provided incorrect ` + + `host password` ); return; } if (peer.id === this.currentHostId) { - LDEBUG(`Session ${this.name}: Peer #${peer.id} ${peer.name} is already host`); + LDEBUG(`Session '${this.id}': peer #${peer.id} ('${peer.name}') is already host`); return; } @@ -256,10 +264,10 @@ export class Session { * host, we remove the hostship and inform all connected peers that they lost their * host. * - * @param peer The Peer from which this message arrived + * @param peer The peer from which this message arrived */ public handleHostshipResignation(peer: Peer): void { - LDEBUG(`Session ${this.name}: Peer #${peer.id} ${peer.name} resigned hostship`); + LDEBUG(`Session '${this.name}': peer #${peer.id} ('${peer.name}') resigned hostship`); // Only remove the host if it was the host peer that requested the resignation if (peer.status === ConnectionStatus.Host) { @@ -272,7 +280,7 @@ export class Session { * password means the room is public and any password is accepted. * * @param password The room password provided by the peer - * @returns True if the password is correct, false otherwise + * s True if the password is correct, false otherwise */ public isPasswordValid(password: string): boolean { return this.password === '' || password === this.password; @@ -280,11 +288,11 @@ export class Session { /** * Registers an already-validated peer into the session: adds it to the list of - * connected peers, and pormotes it to host if it provided the correct host password + * connected peers, and promotes it to host if it provided the correct host password * and no host is currently assigned. The caller is responsible for having confirmed - * the room password, and an existing `peer.id` already being set + * the room password, and an existing `peer.id` already being set. * - * @param peer The Peer to register + * @param peer The peer to register * @param hostPassword The host password provided by the peer, or null if none was given */ public registerPeer(peer: Peer, hostPassword: string | null): void { @@ -294,10 +302,14 @@ export class Session { // If there is currently no host and this peer provided the host password // we promote them to hostship if (this.currentHostId === null && hostPassword === this.hostPassword) { - LDEBUG(`Session ${this.name}: Peer #${peer.id} ${peer.name} promoted to host`); + LDEBUG( + `Session '${this.name}': peer #${peer.id} ('${peer.name}') promoted to host` + ); this.assignHost(peer); } else { - LDEBUG(`Session ${this.name}: Peer #${peer.id} ${peer.name} is client`); + LDEBUG( + `Session '${this.name}': peer #${peer.id} ('${peer.name}') registered as client` + ); peer.status = this.currentHostId === null ? ConnectionStatus.ClientWithoutHost @@ -311,11 +323,12 @@ export class Session { } /** - * Construct a complete message buffer with the provided payload data + * Construct a complete message buffer including the message header and provided payload + * data. * * @param messageType The type of message to construct * @param payload The payload of the message - * @returns A buffer object holding the constructed message + * @return A buffer object holding the constructed message */ private buildMessage(messageType: MessageType, payload: Buffer): Buffer { const buffer = Buffer.alloc( @@ -353,22 +366,21 @@ export class Session { } /** - * Write the `message` to the provided `peer` + * Write the `message` to the provided `peer`. + * * @param peer The peer that will recieve the message * @param message The message payload data that is sent */ private writeMessage(peer: Peer, message: Buffer): void { - LDEBUG( - `Session ${this.name}: Sending to peer with id: #${peer.id}, name: '${peer.name}'` - ); + LDEBUG(`Session '${this.id}': sending message to peer #${peer.id} ('${peer.name}')`); peer.socket.write(message); } /** - * Send a message to the provided peer of the provided messageType with the specified - * payload data. + * Send a message to the provided peer of the provided `messageType` with the specified + * `payload` data. * - * @param peer The Peer that will recieve the message + * @param peer The peer that will recieve the message * @param messageType The type of message that will be sent * @param payload The payload of the data that is sent */ @@ -379,13 +391,9 @@ export class Session { /** * Send a connection status message to the provided peer. * - * @param peer The Peer that will recieve the connection status message + * @param peer The peer that will recieve the connection status message */ private sendConnectionStatus(peer: Peer): void { - LDEBUG( - `Session ${this.name}: Sending connection status to peer with id: #${peer.id}, name: '${peer.name}'` - ); - const host = this.currentHost(); const currentHostName = host === null ? '' : host.name; @@ -416,14 +424,13 @@ export class Session { * Send the number of connections to all connected peers. */ private sendNumberOfConnections(): void { - LDEBUG(`Session ${this.name}: Sending number of connections to all peers`); + LDEBUG(`Session '${this.id}': sending number of connections to all peers`); const buffer = Buffer.alloc(Uint32Array.BYTES_PER_ELEMENT); // Number of connections const dv = new DataView(buffer.buffer, buffer.byteOffset, buffer.byteLength); dv.setUint32(0, this.peers.length, true); const message = this.buildMessage(MessageType.NConnections, buffer); - this.peers.forEach((peer: Peer) => this.writeMessage(peer, message)); // Update the server status depending on if there still are any users connected const isServerActive = this.peers.length > 0; @@ -441,7 +448,7 @@ export class Session { const idx = this.peers.findIndex((peer: Peer) => peer.id === this.currentHostId); if (idx === -1) { - LDEBUG(`Could not find host with id '${this.currentHostId}'`); + LDEBUG(`Session '${this.id}': could not find host with id ${this.currentHostId}`); return null; } @@ -449,10 +456,10 @@ export class Session { } /** - * Mark the provided newHost Peer as the new host of the group and remove the hostship + * Mark the provided newHost peer as the new host of the group and remove the hostship * of the previous host. Inform all connected peers about the change in hostship. * - * @param newHost The Peer to be promoted to the new host + * @param newHost The peer to be promoted to the new host */ private assignHost(newHost: Peer): void { // If we currently have a host, we need to demote them @@ -492,9 +499,9 @@ export class Session { // Inform all peers that we no longer have a host, this includes the recently // removed host aswell - this.peers.forEach((p: Peer) => { - p.status = ConnectionStatus.ClientWithoutHost; - this.sendConnectionStatus(p); + this.peers.forEach((peer: Peer) => { + peer.status = ConnectionStatus.ClientWithoutHost; + this.sendConnectionStatus(peer); }); updateCurrentHost(this.id!, 'null'); diff --git a/src/types/types.ts b/src/types/types.ts index e781e6f..ac808d7 100644 --- a/src/types/types.ts +++ b/src/types/types.ts @@ -51,19 +51,19 @@ export enum ConnectionStatus { export type Peer = { /** - * A unique ID for this peer + * A unique ID for this peer. */ id: number; /** * The user-provided name for this peer, or "Anonymous" if no name was specified. The * name can have a maximum length of 255 characters and can only consist of ASCII - * characters + * characters. */ name: string; /** - * The socket on which to contact the Peer and send messages to + * The socket on which to contact the Peer and send messages to. */ socket: Socket; @@ -73,7 +73,7 @@ export type Peer = { status: ConnectionStatus; /** - * The name of the session this peer is connected to + * The name of the session this peer is connected to. */ sessionName: string; @@ -82,5 +82,5 @@ export type Peer = { * TCP Gives no guarantee that a single 'data' event aligns with a single * application-level message. As such, we must parse the messages. */ - recvBuffer: Buffer; + buffer: Buffer; }; diff --git a/src/wormhole.ts b/src/wormhole.ts index 3d25bc8..529fc20 100644 --- a/src/wormhole.ts +++ b/src/wormhole.ts @@ -37,14 +37,19 @@ const ProtocolError = -1; /** * A TCP server that listens on a single port and routes incoming OpenSpace connections - * into their respective named sessions. Manages the full lifecycle of all active sessions. + * into their respective named sessions. Manages the full lifecycle of all active + * sessions. */ export class Wormhole { /** - * The port on which this TCP server is listening + * The port on which this TCP server is listening. */ private _port: number; + /** + * A global id that is assigned to connected peers, regardless of session they belong + * to. + */ private _peerIdCounter: number; /** @@ -53,7 +58,8 @@ export class Wormhole { private _sessions: { [name: string]: Session } = {}; /** - * The local server that is listening to incoming connections that will result in Peers + * The local TCP server that is listening for incoming OpenSpace connections to the + * different sessions. */ private _server: net.Server; @@ -68,18 +74,19 @@ export class Wormhole { this._server = net.createServer(); this._server.on('connection', (socket: net.Socket) => { - LDEBUG('New connection', socket.remoteAddress); + LDEBUG(`Wormhole: new connection from ${socket.remoteAddress}`); socket.setNoDelay(true); - // The Peer is only added to the list if the authentication is succesful. + // The peer is only added to the list if the authentication is succesful. Note: the + // `Id` is assigned once authentication is attempted, see `handleAuthentication` // eslint-disable-next-line prefer-const let peer: Peer = { - id: -1, // Id is assigned once authentication is attempted, see `handleAuthentication` + id: -1, name: '', socket: socket, status: ConnectionStatus.Connecting, sessionName: '', - recvBuffer: Buffer.alloc(0) + buffer: Buffer.alloc(0) }; socket.on('data', (data: Buffer) => { @@ -89,28 +96,28 @@ export class Wormhole { this.onEnd(peer); }); socket.on('error', (error) => { - LERROR(`Error peer #${peer.id}: ${error}`); + LERROR(`Peer #${peer.id}: socket error`, error); this.onEnd(peer); }); }); } /** - * Starts the TCP server and begins accepting incoming connections. + * Starts the TCP server and begin accepting incoming connections. * - * @return A promise that resolves to true once the server is listening, - * or rejects with an error if the port is unavailable. + * @return A promise that resolves to true once the server is listening, or rejects with + * an error if the port is unavailable. */ public start(): Promise { return new Promise((resolve, reject) => { this._server.on('listening', () => { - LINFO(`Wormhole listening on port ${this._port}`); + LINFO(`Wormhole: listening on port ${this._port}`); resolve(true); }); this._server.on('error', (error: Error) => { this._server.close(); - LERROR(`Error starting server: ${error.message}`); + LERROR(`Wormhole: failed to start server `, error); reject(error); }); @@ -119,8 +126,8 @@ export class Wormhole { } /** - * Creates and registers a new session. If a session with the same name already - * exists it is removed first (this should not normally happen). + * Creates and registers a new session. If a session with the same name already exists + * it is removed first (this should not normally happen). * * @return The newly created Session */ @@ -164,7 +171,7 @@ export class Wormhole { : sessionIdOrSession; if (!session) { - throw new Error('Session not found'); + throw new Error(`Wormhole: session '${sessionIdOrSession}' not found`); } const { id, sessionName } = session.getSessionMetadata(); @@ -173,13 +180,10 @@ export class Wormhole { await session.stop(); // Remove session from internal registry delete this._sessions[sessionName]; - - const msg = `Session '${id ?? sessionName}' successfully removed`; - LDEBUG(msg); - return msg; + LDEBUG(`Wormhole: session '${id ?? sessionName}' removed`); + return `Session '${id ?? sessionName}' successfully removed`; } catch (error) { - const errorMsg = `Error stopping session "${sessionIdOrSession}": ${(error as Error).message}`; - LERROR(errorMsg); + LERROR(`Wormhole: session '${id ?? sessionName}' failed to stop`, error); throw error; } } @@ -194,13 +198,12 @@ export class Wormhole { * @param peer The Peer from which this message arrived */ private onData(data: Buffer, peer: Peer): void { - peer.recvBuffer = - peer.recvBuffer.length === 0 ? data : Buffer.concat([peer.recvBuffer, data]); + peer.buffer = peer.buffer.length === 0 ? data : Buffer.concat([peer.buffer, data]); // Keep extracting messages for as long as the buffer holds a complete one let consumed = this.processMessage(peer); while (consumed > NeedMoreData) { - peer.recvBuffer = peer.recvBuffer.subarray(consumed); + peer.buffer = peer.buffer.subarray(consumed); consumed = this.processMessage(peer); } @@ -210,15 +213,16 @@ export class Wormhole { } /** - * Attempts to parse and sipatch a single complete message from the front of - * `peer.recvBuffer`. If the buffer does not yet hold a full header, or a full header + * Attempts to parse and dispatch a single complete message from the front of + * `peer.buffer`. If the buffer does not yet hold a full header, or a full header * plus its playload, no action is taken. If the header is malformed, the stream is * considered unrecoverable. + * * @param peer The Peer whose buffer should be parsed - * @returns The nubmer of bytes (header + payload) consumed from the front of the - * buffer. Returns 0 if no complete message could be extracted, which happens either - * becasue more data is still needed, or because the connection was just terminated due - * to a protocol error + * @return The nubmer of bytes (header + payload) consumed from the front of the + * buffer. Return `NeedMoreData` if no complete message could be extracted, which + * happens becasue more data is still needed or `ProtocolError` if the connection was + * just terminated due to a protocol error */ private processMessage(peer: Peer): number { const HeaderSize = @@ -227,7 +231,7 @@ export class Wormhole { Uint8Array.BYTES_PER_ELEMENT + // Message type identifier Uint32Array.BYTES_PER_ELEMENT; // Payload size in bytes - const buffer = peer.recvBuffer; + const buffer = peer.buffer; if (buffer.length < HeaderSize) { return NeedMoreData; // Header hasn't fully arrived yet @@ -237,7 +241,7 @@ export class Wormhole { if (buffer[0] !== 0x4f || buffer[1] !== 0x53) { LERROR( `Peer #${peer.id}: stream desynced, expected 'OS' prefix, got ` + - `'${buffer.subarray(0, 2).toString('utf-8')}` + `'${buffer.subarray(0, 2).toString('utf-8')}'` ); peer.socket.destroy(new Error('Protocol desync')); return ProtocolError; @@ -249,7 +253,7 @@ export class Wormhole { const protocolVersion = dv.getUint8(offset); offset += Uint8Array.BYTES_PER_ELEMENT; if (protocolVersion !== CurrentProtocolVersion) { - LERROR(`Peer ${peer.id}: invalid protocol version '${protocolVersion}'`); + LERROR(`Peer #${peer.id}: invalid protocol version '${protocolVersion}'`); peer.socket.destroy(new Error('Protocol version mismatch')); return ProtocolError; } @@ -257,7 +261,7 @@ export class Wormhole { const messageType = dv.getUint8(offset); offset += Uint8Array.BYTES_PER_ELEMENT; if (!(messageType in MessageType)) { - LERROR(`Peer ${peer.id}: invalid message type '${messageType}'`); + LERROR(`Peer #${peer.id}: invalid message type '${messageType}'`); peer.socket.destroy(new Error('Invalid message type')); return ProtocolError; } @@ -298,12 +302,12 @@ export class Wormhole { * @param peer The Peer that just disconnected */ private onEnd(peer: Peer): void { - LDEBUG(`Peer ${peer.id} disconnected, closing connection`); + LDEBUG(`Peer #${peer.id}: disconnected, closing connection`); // Get the session that the peer is connected to const session = this._sessions[peer.sessionName]; if (!session) { - LDEBUG(`Session "${peer.sessionName}" not found`); + LDEBUG(`Wormhole: session '${peer.sessionName}' not found`); return; } @@ -313,14 +317,14 @@ export class Wormhole { /** * Handles the incoming authentication method by the provided peer. If the * authentication is valid and contains the correct password, the peer is added to the - * list in the group. If the host password is also correct and there is no currently + * list in the session. If the host password is also correct and there is currently no * assigned host, the peer is automatically promoted to hostship too. * * @param data The payload of the authentication message * @param peer The Peer from which this message arrived */ private handleAuthentication(data: Buffer, peer: Peer): void { - LDEBUG(`Handling authentication for peer ${peer.id}`); + LDEBUG(`Wormhole: handling authentication attempt from ${peer.socket.remoteAddress}`); const dv = new DataView( data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength) @@ -361,12 +365,12 @@ export class Wormhole { // Get the session that this peer is trying to connect to const session = this._sessions[sessionName]; if (!session) { - LDEBUG(`No session with name '${sessionName}' found`); + LDEBUG(`Wormhole: session '${sessionName}' not found`); return; } if (!session.isPasswordValid(password)) { - LDEBUG(`Peer provided invalid password for session '${sessionName}'`); + LDEBUG(`Wormhole: session '${sessionName}' invalid password provided`); return; } @@ -384,7 +388,7 @@ export class Wormhole { private handleData(data: Buffer, peer: Peer): void { const session = this._sessions[peer.sessionName]; if (!session) { - LDEBUG(`No session with name '${peer.sessionName}' found`); + LDEBUG(`Wormhole: session '${peer.sessionName}' not found`); return; } session.handleData(data, peer); @@ -401,7 +405,7 @@ export class Wormhole { private handleHostshipRequest(data: Buffer, peer: Peer): void { const session = this._sessions[peer.sessionName]; if (!session) { - LDEBUG(`No session with name '${peer.sessionName}' found`); + LDEBUG(`Wormhole: session '${peer.sessionName}' not found`); return; } @@ -419,7 +423,7 @@ export class Wormhole { private handleHostshipResignation(_: Buffer, peer: Peer): void { const session = this._sessions[peer.sessionName]; if (!session) { - LDEBUG(`No session with name '${peer.sessionName}' found`); + LDEBUG(`Wormhole: session '${peer.sessionName}' not found`); return; } session.handleHostshipResignation(peer); From aa2871e84018476e406cf20e0ab367ed840ff91a Mon Sep 17 00:00:00 2001 From: Andreas Engberg Date: Wed, 1 Jul 2026 15:21:14 +0200 Subject: [PATCH 08/17] refactor --- src/session.ts | 2 +- src/wormhole.ts | 95 +++++++++++++++++++------------------------------ 2 files changed, 37 insertions(+), 60 deletions(-) diff --git a/src/session.ts b/src/session.ts index e903490..58c0ddb 100644 --- a/src/session.ts +++ b/src/session.ts @@ -200,7 +200,7 @@ export class Session { public handleData(data: Buffer, peer: Peer): void { // Check if this peer is the current host if (peer.id === this.currentHostId) { - if (peer.id !== ConnectionStatus.Host) { + if (peer.status !== ConnectionStatus.Host) { LERROR( `Session '${this.id}': peer #${peer.id} ('${peer.name}') matches ` + `currentHostId but has status ${peer.status}, expected Host ` + diff --git a/src/wormhole.ts b/src/wormhole.ts index 529fc20..94c948a 100644 --- a/src/wormhole.ts +++ b/src/wormhole.ts @@ -279,17 +279,36 @@ export class Wormhole { // Dispatch message switch (messageType) { + // Session name is parsed from the payload during authentication case MessageType.Authentication: - this.handleAuthentication(messagePayload, peer); - break; + const status = this.handleAuthentication(messagePayload, peer); + if (status.authenticated) { + return messageSize; + } else { + peer.socket.destroy(new Error(status.message)); + return ProtocolError; + } + } + + // All other message types require the peer to already be registered in a session + const session = this._sessions[peer.sessionName]; + if (!session) { + LERROR(`Wormhole: session '${peer.sessionName}' not found`); + peer.socket.destroy( + new Error(`Could not find session with name '${peer.sessionName}'`) + ); + return ProtocolError; + } + + switch (messageType) { case MessageType.Data: - this.handleData(messagePayload, peer); + session.handleData(messagePayload, peer); break; case MessageType.HostshipRequest: - this.handleHostshipRequest(messagePayload, peer); + session.handleHostshipRequest(messagePayload, peer); break; case MessageType.HostshipResignation: - this.handleHostshipResignation(messagePayload, peer); + session.handleHostshipResignation(peer); break; } @@ -322,8 +341,13 @@ export class Wormhole { * * @param data The payload of the authentication message * @param peer The Peer from which this message arrived + * @return An object containing the authentication status (true if authentication + * succeedes, false otherwise) and an optional message */ - private handleAuthentication(data: Buffer, peer: Peer): void { + private handleAuthentication( + data: Buffer, + peer: Peer + ): { authenticated: boolean; message?: string } { LDEBUG(`Wormhole: handling authentication attempt from ${peer.socket.remoteAddress}`); const dv = new DataView( @@ -366,66 +390,19 @@ export class Wormhole { const session = this._sessions[sessionName]; if (!session) { LDEBUG(`Wormhole: session '${sessionName}' not found`); - return; + return { authenticated: false, message: `Session '${sessionName}': not found` }; } if (!session.isPasswordValid(password)) { LDEBUG(`Wormhole: session '${sessionName}' invalid password provided`); - return; + return { + authenticated: false, + message: `Invalid password` + }; } peer.id = this._peerIdCounter++; session.registerPeer(peer, hostPassword); - } - - /** - * Handle incoming data from the provided peer, we only forward the data along to - * other peers if it comes from the host. - * - * @param data The payload of the data message - * @param peer The Peer from which this message is coming - */ - private handleData(data: Buffer, peer: Peer): void { - const session = this._sessions[peer.sessionName]; - if (!session) { - LDEBUG(`Wormhole: session '${peer.sessionName}' not found`); - return; - } - session.handleData(data, peer); - } - - /** - * Handles an incoming hostship request message by the peer. If the message contains - * the correct host password, the peer is promoted to hostship and the previous host - * is demoted (if there was one). - * - * @param data The payload of the hostship request message - * @param peer The Peer from which this message arrived - */ - private handleHostshipRequest(data: Buffer, peer: Peer): void { - const session = this._sessions[peer.sessionName]; - if (!session) { - LDEBUG(`Wormhole: session '${peer.sessionName}' not found`); - return; - } - - session.handleHostshipRequest(data, peer); - } - - /** - * Handles an incoming hostship resignation by the peer. If the peer is the current - * host, we remove the hostship and inform all connected peers that they lost their - * host. - * - * @param _ Unused parameter - * @param peer The Peer from which this message arrived - */ - private handleHostshipResignation(_: Buffer, peer: Peer): void { - const session = this._sessions[peer.sessionName]; - if (!session) { - LDEBUG(`Wormhole: session '${peer.sessionName}' not found`); - return; - } - session.handleHostshipResignation(peer); + return { authenticated: true }; } } From 7a4002f3f1dc779746da21d2da10e14c27c81d92 Mon Sep 17 00:00:00 2001 From: Andreas Engberg Date: Thu, 2 Jul 2026 10:42:18 +0200 Subject: [PATCH 09/17] cleanup code, add admin check for removing a server --- src/adminApi.ts | 64 +++++++++++++++++++++++++++++----------------- src/app.ts | 39 +++++++++++++++++++--------- src/session.ts | 19 +++++++------- src/types/env.d.ts | 4 +++ src/utils.ts | 2 +- src/wormhole.ts | 10 +++----- 6 files changed, 86 insertions(+), 52 deletions(-) diff --git a/src/adminApi.ts b/src/adminApi.ts index 42a6962..2720755 100644 --- a/src/adminApi.ts +++ b/src/adminApi.ts @@ -38,13 +38,13 @@ import { DataSnapshot, EventType, getDatabase, Reference } from 'firebase-admin/ export async function setAdminRights(uid: string, secret: string) { // Verify the provided secret against the database secret const db = getDatabase(adminDbApp); - const dbSecret = await db.ref('Admin/secret').get(); + const secretSnapshot = await db.ref('Admin/secret').once('value'); - if (!dbSecret.exists()) { + if (!secretSnapshot.exists()) { throw new Error('Could not find admin secret in database'); } - if (secret !== dbSecret.val()) { + if (secret !== secretSnapshot.val()) { throw new Error('Invalid secret provided'); } @@ -59,7 +59,7 @@ export async function setAdminRights(uid: string, secret: string) { } /** - * Authorize a user to the Firebase auth database + * Authorize a user to the Firebase auth database. * * @param token The client user id token to authorize * @return A promise that resolves with the user firebase uid @@ -70,15 +70,31 @@ export async function authorizeUser(token: string): Promise { return decodedToken.uid; } +/** + * Authorize a user and verify they have admin rights. + * + * @param token The client user id token to authorize + * @return A promise that resolves with the user firebase uid, or rejects if the user + * is not authenticated or does not have admin rights + */ +export async function authorizeAdmin(token: string): Promise { + const auth = getAuth(adminAuthApp); + const decodedToken = await auth.verifyIdToken(token); + if (!decodedToken['admin']) { + throw new Error('Insufficient permissions: admin rights required'); + } + return decodedToken.uid; +} + /** * Fetch the display name of a user given their uid * - * @param token Token to fetch user information + * @param uid The uid of the user to fetch * @return A promise that resolves with the user information */ -export async function getUserByID(token: string): Promise { +export async function getUserByID(uid: string): Promise { const auth = getAuth(adminAuthApp); - return await auth.getUser(token); + return await auth.getUser(uid); } /** @@ -105,7 +121,7 @@ export async function getSessionsFromDb(): Promise { * @param callback The callback function when an event is triggered * @param onError The callback function when an error occurs */ -export async function subscribeToDatabase( +export function subscribeToDatabase( path: string | Reference, eventType: EventType, callback: (snapshot: DataSnapshot, b?: string | null | undefined) => any, @@ -157,7 +173,7 @@ export async function postSession( owner: uid }; await newPostRef.set(data); - LDEBUG(`Session '${postId}': new instance posted to databse`, data); + LDEBUG(`Session '${postId}': new instance posted to database`, data); return data; } @@ -171,7 +187,7 @@ export async function postSession( export async function getHostPassword(sessionId: string, uid: string): Promise { const db = getDatabase(adminDbApp); - const sessionSnapshot = await db.ref(`SessionData/${sessionId}`).get(); + const sessionSnapshot = await db.ref(`SessionData/${sessionId}`).once('value'); if (!sessionSnapshot.exists()) { throw new Error(`Session with id '${sessionId}' does not exist`); } @@ -181,7 +197,9 @@ export async function getHostPassword(sessionId: string, uid: string): Promise { const db = getDatabase(adminDbApp); - const snapshot = await db.ref(`SessionSecrets/${sessionId}/hostPassword`).get(); + const snapshot = await db.ref(`SessionSecrets/${sessionId}/hostPassword`).once('value'); if (!snapshot.exists()) { throw new Error(`Could not find host password for session '${sessionId}'`); } @@ -217,7 +235,7 @@ export async function verifyHostPassword( */ export async function getHostPasswordInternal(sessionId: string): Promise { const db = getDatabase(adminDbApp); - const snapshot = await db.ref(`SessionSecrets/${sessionId}/hostPassword`).get(); + const snapshot = await db.ref(`SessionSecrets/${sessionId}/hostPassword`).once('value'); return snapshot.exists() ? snapshot.val() : null; } @@ -258,7 +276,7 @@ export async function removeSessionFromDb(sessionId: string): Promise { const db = getDatabase(adminDbApp); const instanceRef = db.ref(`SessionData/${sessionId}`); - const snapshot = await instanceRef.get(); + const snapshot = await instanceRef.once('value'); if (!snapshot.exists()) { const errorMessage = `Session '${sessionId}': does not exists, cannot remove`; @@ -290,7 +308,7 @@ export async function updateActiveSessionStatus( try { const db = getDatabase(adminDbApp); const instanceRef = db.ref(`SessionData/${sessionId}`); - const snapshot = await instanceRef.get(); + const snapshot = await instanceRef.once('value'); if (!snapshot.exists()) { throw new Error(`Could not find instance with id '${sessionId}'`); @@ -320,17 +338,17 @@ export async function updateCurrentActiveUsers( try { const db = getDatabase(adminDbApp); const instanceRef = db.ref(`SessionData/${sessionId}`); - const snapshot = await instanceRef.get(); + const snapshot = await instanceRef.once('value'); - if (!snapshot) { + if (!snapshot.exists()) { throw new Error(`Could not find session with id '${sessionId}'`); } await instanceRef.update({ nPeers: nPeers }); LDEBUG(`Session '${sessionId}': active user count updated to ${nPeers}`); + await updateStatistics(sessionId, nPeers); } catch (error) { - LERROR(`Session '${sessionId}': failed to update active userc count`, error); + LERROR(`Session '${sessionId}': failed to update active user count`, error); } - await updateStatistics(sessionId, nPeers); } /** @@ -349,9 +367,9 @@ export async function updateStatistics(sessionId: string, nPeers: number): Promi }; await statisticsRef.push(stats); - LDEBUG(`Session '${sessionId}': statistics entry pushed, stats`); + LDEBUG(`Session '${sessionId}': statistics entry pushed`, stats); } catch (error) { - LERROR(`Session '${sessionId}': failed to push statistics entry`, error); + LERROR(`Session '${sessionId}': failed to push statistics entry`, error); } } /** @@ -364,7 +382,7 @@ export async function updateCurrentHost(sessionId: string, host: string) { try { const db = getDatabase(adminDbApp); const instanceRef = db.ref(`SessionData/${sessionId}`); - const snapshot = await instanceRef.get(); + const snapshot = await instanceRef.once('value'); if (!snapshot.exists()) { throw new Error(`Could not find instance with id '${sessionId}'`); } @@ -384,7 +402,7 @@ export async function updateUsage(sessionId: string) { try { const db = getDatabase(adminDbApp); const instanceRef = db.ref(`SessionData/${sessionId}`); - const snapshot = await instanceRef.get(); + const snapshot = await instanceRef.once('value'); if (!snapshot.exists()) { throw new Error(`Could not find instance with id '${sessionId}'`); } diff --git a/src/app.ts b/src/app.ts index 79335ec..9d593ff 100644 --- a/src/app.ts +++ b/src/app.ts @@ -23,6 +23,7 @@ ****************************************************************************************/ import { + authorizeAdmin, authorizeUser, getSessionsFromDb, getUserByID, @@ -76,8 +77,8 @@ export class App { this.handleRequestSetAdminRights.bind(this) ); this._app.get( - `${apiPath}/fetch-user-name/:token`, - this.handleRequestgetUserNameByID.bind(this) + `${apiPath}/fetch-user-name/:uid`, + this.handleRequestGetUserNameByID.bind(this) ); this._app.post(`${apiPath}/request-session`, this.handleRequestSession.bind(this)); this._app.get( @@ -105,19 +106,19 @@ export class App { /** * Fetch the display name of a user given their uid. * - * @param req Request object containing the user token + * @param req Request params must contain the user uid as `/:uid` * @param res Response object containing user name */ - private async handleRequestgetUserNameByID(req: Request, res: Response): Promise { - const token = req.params.token; + private async handleRequestGetUserNameByID(req: Request, res: Response): Promise { + const uid = req.params.uid; - if (!token || typeof token !== 'string') { - res.status(400).json({ error: 'Invalid token' }); + if (!uid || typeof uid !== 'string') { + res.status(400).json({ error: 'Invalid uid' }); return; } try { - const user = await getUserByID(token); + const user = await getUserByID(uid); res.json({ name: user.displayName }); } catch (error) { res.status(400).json({ error: (error as Error).message }); @@ -174,11 +175,19 @@ export class App { } /** - * Handle the request to remove a session. + * Handle the request to remove a session. Only users with admin rights may remove a + * session. * - * @param req Request object must contain the session id as a params `/:id` + * @param req Request params must contain the session id as `/:id`; must include a + * valid admin Bearer token in the authorization header */ private async handleRemoveSession(req: Request, res: Response): Promise { + const authHeader = req.headers.authorization; + if (!authHeader?.startsWith('Bearer ')) { + res.status(401).json({ error: 'Missing or invalid authorization header' }); + return; + } + const token = authHeader.slice(7); const instanceID = req.params.id; if (!instanceID || typeof instanceID !== 'string') { @@ -187,10 +196,16 @@ export class App { } try { + await authorizeAdmin(token); const msg = await this.removeSession(instanceID); res.status(200).json({ message: msg }); } catch (e) { - res.status(500).json({ error: (e as Error).message }); + const msg = (e as Error).message; + if (msg.includes('Insufficient permissions')) { + res.status(403).json({ error: msg }); + } else { + res.status(500).json({ error: msg }); + } } } @@ -211,7 +226,7 @@ export class App { * * @param req Request body must contain password, hostpassword, roomname, profile, and * token - * @return A promise that resolves with the session metadata or rejects with an error + * @param res Response object that will receive the session metadata on success */ private async handleRequestSession(req: Request, res: Response): Promise { const authHeader = req.headers.authorization; diff --git a/src/session.ts b/src/session.ts index 58c0ddb..08f8da9 100644 --- a/src/session.ts +++ b/src/session.ts @@ -136,9 +136,10 @@ export class Session { resolve(); } + const totalPeers = this.peers.length; const checkAllDisconnected = () => { disconnectCount += 1; - if (disconnectCount === this.peers.length) { + if (disconnectCount === totalPeers) { resolve(); } }; @@ -228,9 +229,7 @@ export class Session { public handleHostshipRequest(data: Buffer, peer: Peer): void { LDEBUG(`Session '${this.id}': peer #${peer.id} ('${peer.name}') requested hostship`); - const dv = new DataView( - data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength) - ); + const dv = new DataView(data.buffer, data.byteOffset, data.byteLength); let offset = 0; const passwordLength = dv.getUint16(offset, true); @@ -280,7 +279,7 @@ export class Session { * password means the room is public and any password is accepted. * * @param password The room password provided by the peer - * s True if the password is correct, false otherwise + * @return True if the password is correct, false otherwise */ public isPasswordValid(password: string): boolean { return this.password === '' || password === this.password; @@ -368,7 +367,7 @@ export class Session { /** * Write the `message` to the provided `peer`. * - * @param peer The peer that will recieve the message + * @param peer The peer that will receive the message * @param message The message payload data that is sent */ private writeMessage(peer: Peer, message: Buffer): void { @@ -380,7 +379,7 @@ export class Session { * Send a message to the provided peer of the provided `messageType` with the specified * `payload` data. * - * @param peer The peer that will recieve the message + * @param peer The peer that will receive the message * @param messageType The type of message that will be sent * @param payload The payload of the data that is sent */ @@ -391,7 +390,7 @@ export class Session { /** * Send a connection status message to the provided peer. * - * @param peer The peer that will recieve the connection status message + * @param peer The peer that will receive the connection status message */ private sendConnectionStatus(peer: Peer): void { const host = this.currentHost(); @@ -464,7 +463,7 @@ export class Session { private assignHost(newHost: Peer): void { // If we currently have a host, we need to demote them if (this.currentHostId !== null) { - let oldHost = this.currentHost()!; // Host id cannot be null here + const oldHost = this.currentHost()!; // Host id cannot be null here oldHost.status = ConnectionStatus.ClientWithHost; } @@ -498,7 +497,7 @@ export class Session { this.currentHostId = null; // Inform all peers that we no longer have a host, this includes the recently - // removed host aswell + // removed host as well this.peers.forEach((peer: Peer) => { peer.status = ConnectionStatus.ClientWithoutHost; this.sendConnectionStatus(peer); diff --git a/src/types/env.d.ts b/src/types/env.d.ts index 2074acf..9df6b64 100644 --- a/src/types/env.d.ts +++ b/src/types/env.d.ts @@ -12,5 +12,9 @@ declare namespace NodeJS { WORMHOLE_PORT?: string; /** Base path prefix for all REST API routes */ SERVER_API_PATH: string; + /** Allowed CORS origin for the HTTP server */ + CORS_ORIGIN: string; + /** Enable verbose debug logging (default: false) */ + DEBUG?: string; } } diff --git a/src/utils.ts b/src/utils.ts index 7849a19..1b19bd0 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -30,7 +30,7 @@ const FgGreen = '\x1b[32m'; const FgWhite = '\x1b[37m'; /** - * Logs a debug info message if `ChattyDebug` flage is set to true. + * Logs a debug info message if `DEBUG` flag is set to true. * * @param message The message to log * @param params Any additional parameters to log diff --git a/src/wormhole.ts b/src/wormhole.ts index 94c948a..f0ca142 100644 --- a/src/wormhole.ts +++ b/src/wormhole.ts @@ -215,13 +215,13 @@ export class Wormhole { /** * Attempts to parse and dispatch a single complete message from the front of * `peer.buffer`. If the buffer does not yet hold a full header, or a full header - * plus its playload, no action is taken. If the header is malformed, the stream is + * plus its payload, no action is taken. If the header is malformed, the stream is * considered unrecoverable. * * @param peer The Peer whose buffer should be parsed - * @return The nubmer of bytes (header + payload) consumed from the front of the + * @return The number of bytes (header + payload) consumed from the front of the * buffer. Return `NeedMoreData` if no complete message could be extracted, which - * happens becasue more data is still needed or `ProtocolError` if the connection was + * happens because more data is still needed or `ProtocolError` if the connection was * just terminated due to a protocol error */ private processMessage(peer: Peer): number { @@ -350,9 +350,7 @@ export class Wormhole { ): { authenticated: boolean; message?: string } { LDEBUG(`Wormhole: handling authentication attempt from ${peer.socket.remoteAddress}`); - const dv = new DataView( - data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength) - ); + const dv = new DataView(data.buffer, data.byteOffset, data.byteLength); let offset = 0; const passwordLength = dv.getUint16(offset, true); From 79598327892d4fd1b9f112803fc2fa8bbe97f42d Mon Sep 17 00:00:00 2001 From: Andreas Engberg Date: Thu, 2 Jul 2026 11:43:14 +0200 Subject: [PATCH 10/17] further code improvements --- src/adminApi.ts | 4 ++-- src/session.ts | 31 ++++++++++++++++++++----------- src/types/types.ts | 6 +++--- src/wormhole.ts | 3 +-- 4 files changed, 26 insertions(+), 18 deletions(-) diff --git a/src/adminApi.ts b/src/adminApi.ts index 2720755..0404a87 100644 --- a/src/adminApi.ts +++ b/src/adminApi.ts @@ -163,9 +163,9 @@ export async function postSession( inactiveTimeStamp: Date.now(), created: Date.now(), usage: 0, - password: metadata.password ?? null, + password: metadata.password, nPeers: 0, - currentHost: 'null', + currentHost: '', roomName: metadata.sessionName, profile: profile, id: postId, diff --git a/src/session.ts b/src/session.ts index 08f8da9..2ce5816 100644 --- a/src/session.ts +++ b/src/session.ts @@ -130,32 +130,41 @@ export class Session { */ public stop(): Promise { return new Promise((resolve) => { - let disconnectCount = 0; - if (this.peers.length === 0) { resolve(); + return; } - const totalPeers = this.peers.length; - const checkAllDisconnected = () => { - disconnectCount += 1; - if (disconnectCount === totalPeers) { + // Create a copy of the peers since `this.peers` can be mutaded concurrently by the + // Worhmhole socket 'error' callback while we're still iterating here. + const peersToDisconnect = [...this.peers]; + const settled = new Set(); + + const checkAllDisconnected = (peer: Peer) => { + // A peer's disconnect may be reported twice, once here and via the socket-level + // 'error' handler set up in the Wormhole + if (settled.has(peer.id)) { + return; + } + + settled.add(peer.id); + if (settled.size === peersToDisconnect.length) { resolve(); } }; - for (const peer of this.peers) { + for (const peer of peersToDisconnect) { // Handle if socket does not end properly - peer.socket.on('error', (err) => { + peer.socket.once('error', (err) => { LDEBUG( `Session ${this.id}: error disconnecting peer #${peer.id} ('${peer.name}')`, err ); - checkAllDisconnected(); + checkAllDisconnected(peer); }); peer.socket.end(() => { - checkAllDisconnected(); + checkAllDisconnected(peer); }); } }); @@ -503,6 +512,6 @@ export class Session { this.sendConnectionStatus(peer); }); - updateCurrentHost(this.id!, 'null'); + updateCurrentHost(this.id!, ''); } } diff --git a/src/types/types.ts b/src/types/types.ts index ac808d7..1c421fc 100644 --- a/src/types/types.ts +++ b/src/types/types.ts @@ -6,13 +6,13 @@ export type SessionData = { inactiveTimeStamp: number; created: number; usage: number; - password: string | null; + password: string; nPeers: number; currentHost: string; roomName: string; profile: string; isPrivate: boolean; - owner: string | null; + owner: string; }; export type SessionHistoryData = { @@ -22,7 +22,7 @@ export type SessionHistoryData = { uptime: number; usage: number; roomName: string; - owner: string | null; + owner: string; }; export type StatisticData = { diff --git a/src/wormhole.ts b/src/wormhole.ts index f0ca142..6127cdd 100644 --- a/src/wormhole.ts +++ b/src/wormhole.ts @@ -79,8 +79,7 @@ export class Wormhole { // The peer is only added to the list if the authentication is succesful. Note: the // `Id` is assigned once authentication is attempted, see `handleAuthentication` - // eslint-disable-next-line prefer-const - let peer: Peer = { + const peer: Peer = { id: -1, name: '', socket: socket, From 0e8b99b0381f99e685e333aa58ebbe004dc34362 Mon Sep 17 00:00:00 2001 From: Andreas Engberg Date: Thu, 2 Jul 2026 13:14:57 +0200 Subject: [PATCH 11/17] lint --- eslint.config.mjs | 62 ++ package-lock.json | 1217 +++++++++++++++++++++++++++++++++++- package.json | 13 +- src/adminApi.ts | 11 +- src/adminFirebaseConfig.ts | 8 +- src/app.ts | 34 +- src/config/env.ts | 8 +- src/session.ts | 12 +- src/utils.ts | 3 + src/wormhole.ts | 13 +- 10 files changed, 1336 insertions(+), 45 deletions(-) create mode 100644 eslint.config.mjs diff --git a/eslint.config.mjs b/eslint.config.mjs new file mode 100644 index 0000000..2a1e85f --- /dev/null +++ b/eslint.config.mjs @@ -0,0 +1,62 @@ +import globals from 'globals'; +import eslintConfigPrettier from 'eslint-config-prettier'; +import simpleImportSort from 'eslint-plugin-simple-import-sort'; +import tseslint from 'typescript-eslint'; +import js from '@eslint/js'; + +export default tseslint.config( + { ignores: ['dist'] }, + { + extends: [ + js.configs.recommended, + ...tseslint.configs.recommended, + eslintConfigPrettier + ], + files: ['**/*.{ts,tsx}'], + ignores: ['**/*.d.ts'], + languageOptions: { + ecmaVersion: 2020, + globals: globals.node + }, + plugins: { + 'simple-import-sort': simpleImportSort + }, + linterOptions: { + reportUnusedDisableDirectives: 'error' + }, + rules: { + '@typescript-eslint/no-explicit-any': 'warn', // Disallow usage of any + 'no-duplicate-imports': 'error', // Imports should be on one line + 'no-console': 'warn', + 'prefer-destructuring': ['error', { object: true, array: true }], + 'simple-import-sort/imports': 'error', + 'simple-import-sort/exports': 'error', + 'simple-import-sort/imports': [ + 'error', + { + groups: [ + // Packages `react` related packages come first. + ['^react', '^@?\\w'], + // Internal packages. + ['^(@|components)(/.*|$)'], + // Side effect imports. + ['^\\u0000'], + // Parent imports. Put `..` last. + ['^\\.\\.(?!/?$)', '^\\.\\./?$'], + // Other relative imports. Put same-folder imports and `.` last. + ['^\\./(?=.*/)(?!/?$)', '^\\.(?!/?$)', '^\\./?$'], + // Style imports. + ['^.+\\.?(css)$'] + ] + } + ], + + // Guards against stupidity + 'no-self-compare': 'error', + 'no-unreachable-loop': 'error', + 'no-template-curly-in-string': 'error', // Catches "${}" template strings + 'default-case': ['error', { commentPattern: '^skip\\sdefault' }], // require default switch case + 'default-case-last': 'error' // enforce default switch case last + } + } +); diff --git a/package-lock.json b/package-lock.json index d335fd9..61f3ebd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,11 +1,11 @@ { - "name": "wormhole-server", + "name": "wormhole", "version": "1.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "wormhole-server", + "name": "wormhole", "version": "1.0.0", "dependencies": { "cors": "^2.8.6", @@ -16,12 +16,19 @@ "zod": "^4.4.3" }, "devDependencies": { + "@eslint/js": "^10.0.1", "@types/cors": "^2.8.19", "@types/express": "^5.0.6", "@types/node": "^25.9.2", + "eslint": "^10.6.0", + "eslint-config-prettier": "^10.1.8", + "eslint-plugin-simple-import-sort": "^13.0.0", + "globals": "^17.7.0", "pino-pretty": "^13.1.3", + "prettier": "^3.9.4", "tsx": "^4.19.4", - "typescript": "^6.0.3" + "typescript": "^6.0.3", + "typescript-eslint": "^8.62.1" } }, "node_modules/@esbuild/aix-ppc64": { @@ -466,6 +473,134 @@ "node": ">=18" } }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", + "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.23.5", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.23.5.tgz", + "integrity": "sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^3.0.5", + "debug": "^4.3.1", + "minimatch": "^10.2.4" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.6.0.tgz", + "integrity": "sha512-ii6Bw9jJ2zi2cWA2Z+9/QZ/+3DX6kwaV5Q986D/CdP3Lap3w/pgQZ373FV7byY/i7L4IRH/G43I5dz1ClsCbpA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^1.2.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/core": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-1.2.1.tgz", + "integrity": "sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/js": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-10.0.1.tgz", + "integrity": "sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "eslint": "^10.0.0" + }, + "peerDependenciesMeta": { + "eslint": { + "optional": true + } + } + }, + "node_modules/@eslint/object-schema": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-3.0.5.tgz", + "integrity": "sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.7.2.tgz", + "integrity": "sha512-+CNAzxglkrpNf/kKywqQfk74QjtceuOE7Qm+AF8miRvPF/wmmK5+OJOgVh3AVTT3RP2mH3+FOaxlE5v72owk0A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^1.2.1", + "levn": "^0.4.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, "node_modules/@fastify/busboy": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/@fastify/busboy/-/busboy-3.2.0.tgz", @@ -749,6 +884,72 @@ "node": ">=6" } }, + "node_modules/@humanfs/core": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", + "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/types": "^0.15.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz", + "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.2", + "@humanfs/types": "^0.15.0", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/types": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz", + "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, "node_modules/@js-sdsl/ordered-map": { "version": "4.4.2", "resolved": "https://registry.npmjs.org/@js-sdsl/ordered-map/-/ordered-map-4.4.2.tgz", @@ -910,6 +1111,20 @@ "@types/node": "*" } }, + "node_modules/@types/esrecurse": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz", + "integrity": "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/express": { "version": "5.0.6", "resolved": "https://registry.npmjs.org/@types/express/-/express-5.0.6.tgz", @@ -942,6 +1157,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/jsonwebtoken": { "version": "9.0.10", "resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-9.0.10.tgz", @@ -1029,6 +1251,236 @@ "license": "MIT", "optional": true }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.62.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.62.1.tgz", + "integrity": "sha512-4EQM77WgVNxj7OkL/5b/D/xZsw00G577+UriYTC7JF5opcF3T2AuoeY7ueLaZgSVjSgCS6yOAJB5bRGLPSJUzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.62.1", + "@typescript-eslint/type-utils": "8.62.1", + "@typescript-eslint/utils": "8.62.1", + "@typescript-eslint/visitor-keys": "8.62.1", + "ignore": "^7.0.5", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.62.1", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.62.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.62.1.tgz", + "integrity": "sha512-sPhE4iHuJDSvoAiec+Ro8JyXw8f0ql13HFR82P99nCm9GwTEKG0KYLvDe6REk8BCXuit6vJAv/Yxg5ABaNS2rA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.62.1", + "@typescript-eslint/types": "8.62.1", + "@typescript-eslint/typescript-estree": "8.62.1", + "@typescript-eslint/visitor-keys": "8.62.1", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.62.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.62.1.tgz", + "integrity": "sha512-yQ3RgY5RkSBpsNS1Bx/JQEcA24FOSdfGktoyprAr5u18390UQdtVcfnEv4nIrIshNnavlVyZBKxQwT1fIAE6cg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.62.1", + "@typescript-eslint/types": "^8.62.1", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.62.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.62.1.tgz", + "integrity": "sha512-r4d249KbQ1SFdpeStvob8Ih6aPPIzfqllPVOtvhve6ZcpuVcYo5/7zUWckKpHE7StASX4kTKZTLf0WQm/wPkcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.62.1", + "@typescript-eslint/visitor-keys": "8.62.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.62.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.62.1.tgz", + "integrity": "sha512-xadytJqX9vJVQ2fdQjkcIVigwaOJNWkpjdLt6cEQ+xPnrI1fkp+/jZE/I97k9KUjqtpd25i0HeyZf3T6dutv2g==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.62.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.62.1.tgz", + "integrity": "sha512-aXM5xlqXiTxPibXB93cLAURfT3rlizf7uMXISCXy66Isr/9hISJx3yDsKl0L7lKa51b8JpFuNKby0/O0pEm9jg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.62.1", + "@typescript-eslint/typescript-estree": "8.62.1", + "@typescript-eslint/utils": "8.62.1", + "debug": "^4.4.3", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.62.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.62.1.tgz", + "integrity": "sha512-ooCzJFaf+Hg+uG6fA3NRFGuFjlfNlDhBthbv4ZPU/0elCAFUfnyXUvf/WOpHz/jYwSmvU2GkR2LtyUfy1AxZ1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.62.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.62.1.tgz", + "integrity": "sha512-xMcW9oP9u7fAMXYs9A65CVmtLQe2r//oXINHfi8HV+oiqhih17sbLdhXr4540YWlgpDKQdY854OL5ZrdCiQsAA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.62.1", + "@typescript-eslint/tsconfig-utils": "8.62.1", + "@typescript-eslint/types": "8.62.1", + "@typescript-eslint/visitor-keys": "8.62.1", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.62.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.62.1.tgz", + "integrity": "sha512-sHtbPfuKNZCG+ih8SyjjucqRntSVmp8XgL5u6o9mAhiSn8ds5o/M/XdM0abweme2Tln3szOstOrZ9OXitvPh0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.62.1", + "@typescript-eslint/types": "8.62.1", + "@typescript-eslint/typescript-estree": "8.62.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.62.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.62.1.tgz", + "integrity": "sha512-4g3BLxfdTMy8iZG0MaBkadnlRrCJ74cQiFbyEVMrkwIoqdyaXXQM22cotDvrl4x28wgIZ9rEJRoM+mmhSJpJ1g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.62.1", + "eslint-visitor-keys": "^5.0.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, "node_modules/abort-controller": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", @@ -1055,6 +1507,29 @@ "node": ">= 0.6" } }, + "node_modules/acorn": { + "version": "8.17.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.17.0.tgz", + "integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, "node_modules/agent-base": { "version": "7.1.4", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", @@ -1064,6 +1539,23 @@ "node": ">= 14" } }, + "node_modules/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, "node_modules/ansi-regex": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", @@ -1126,6 +1618,16 @@ "node": ">=8.0.0" } }, + "node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, "node_modules/base64-js": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", @@ -1179,6 +1681,19 @@ "url": "https://opencollective.com/express" } }, + "node_modules/brace-expansion": { + "version": "5.0.7", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz", + "integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, "node_modules/buffer-equal-constant-time": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", @@ -1335,6 +1850,21 @@ "url": "https://opencollective.com/express" } }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, "node_modules/data-uri-to-buffer": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz", @@ -1371,6 +1901,13 @@ } } }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, "node_modules/delayed-stream": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", @@ -1574,6 +2111,200 @@ "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", "license": "MIT" }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.6.0.tgz", + "integrity": "sha512-6lVbcqSodALYo+4ELD0heG6lFiFxnLMuLkiMi2qV8LMp54N8tE8FT1GMH+ev4Ti00nFjNze2+Su6DsV5OQW3Dg==", + "dev": true, + "license": "MIT", + "workspaces": [ + "packages/*" + ], + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.2", + "@eslint/config-array": "^0.23.5", + "@eslint/config-helpers": "^0.6.0", + "@eslint/core": "^1.2.1", + "@eslint/plugin-kit": "^0.7.2", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.14.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^9.1.2", + "eslint-visitor-keys": "^5.0.1", + "espree": "^11.2.0", + "esquery": "^1.7.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "minimatch": "^10.2.4", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-config-prettier": { + "version": "10.1.8", + "resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-10.1.8.tgz", + "integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==", + "dev": true, + "license": "MIT", + "bin": { + "eslint-config-prettier": "bin/cli.js" + }, + "funding": { + "url": "https://opencollective.com/eslint-config-prettier" + }, + "peerDependencies": { + "eslint": ">=7.0.0" + } + }, + "node_modules/eslint-plugin-simple-import-sort": { + "version": "13.0.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-simple-import-sort/-/eslint-plugin-simple-import-sort-13.0.0.tgz", + "integrity": "sha512-McAc+/Nlvcg4byY/CABGH8kqnefWBj8s3JA2okEtz8ixbECQgU46p0HkTUKa4YS7wvgGceimlc34p1nXqbWqtA==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "eslint": ">=5.0.0" + } + }, + "node_modules/eslint-scope": { + "version": "9.1.2", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-9.1.2.tgz", + "integrity": "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "@types/esrecurse": "^4.3.1", + "@types/estree": "^1.0.8", + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree": { + "version": "11.2.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-11.2.0.tgz", + "integrity": "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.16.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^5.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/etag": { "version": "1.8.1", "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", @@ -1664,6 +2395,20 @@ "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", "license": "MIT" }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, "node_modules/fast-safe-stringify": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz", @@ -1723,6 +2468,24 @@ "node": ">=0.8.0" } }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, "node_modules/fetch-blob": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz", @@ -1746,6 +2509,19 @@ "node": "^12.20 || >= 14.13" } }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, "node_modules/finalhandler": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", @@ -1763,8 +2539,25 @@ "node": ">= 18.0.0" }, "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/firebase-admin": { @@ -1790,6 +2583,27 @@ "@google-cloud/storage": "^7.19.0" } }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", + "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", + "dev": true, + "license": "ISC" + }, "node_modules/form-data": { "version": "2.5.5", "resolved": "https://registry.npmjs.org/form-data/-/form-data-2.5.5.tgz", @@ -2017,6 +2831,32 @@ "node": ">= 0.4" } }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/globals": { + "version": "17.7.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-17.7.0.tgz", + "integrity": "sha512-Czmyns5dUsq4seFBR/Kdydhmo8y9kC79hiSkPn0YcGtNnYWnrgt0vjrSjx9tspoDGWm2CMarffRuLjM4xUz8xg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/google-auth-library": { "version": "10.6.2", "resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-10.6.2.tgz", @@ -2330,6 +3170,26 @@ "url": "https://opencollective.com/express" } }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, "node_modules/inherits": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", @@ -2345,6 +3205,16 @@ "node": ">= 0.10" } }, + "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-fullwidth-code-point": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", @@ -2355,6 +3225,19 @@ "node": ">=8" } }, + "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-promise": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", @@ -2374,6 +3257,13 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, "node_modules/jose": { "version": "4.15.9", "resolved": "https://registry.npmjs.org/jose/-/jose-4.15.9.tgz", @@ -2402,6 +3292,27 @@ "bignumber.js": "^9.0.0" } }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, "node_modules/jsonwebtoken": { "version": "9.0.3", "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz", @@ -2461,11 +3372,51 @@ "safe-buffer": "^5.0.1" } }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, "node_modules/limiter": { "version": "1.1.5", "resolved": "https://registry.npmjs.org/limiter/-/limiter-1.1.5.tgz", "integrity": "sha512-FWWMIEOxz3GwUI4Ts/IvgVy6LPvoMPgjMdQ185nN6psJyBJ4yOpzqm695/h5umdLJg2vW3GR5iG11MAkR2AzJA==" }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/lodash.camelcase": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz", @@ -2618,6 +3569,22 @@ "url": "https://opencollective.com/express" } }, + "node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/minimist": { "version": "1.2.8", "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", @@ -2634,6 +3601,13 @@ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "license": "MIT" }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, "node_modules/negotiator": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", @@ -2745,12 +3719,30 @@ "wrappy": "1" } }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, "node_modules/p-limit": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "devOptional": true, "license": "MIT", - "optional": true, "dependencies": { "yocto-queue": "^0.1.0" }, @@ -2761,6 +3753,22 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/parseurl": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", @@ -2770,6 +3778,16 @@ "node": ">= 0.8" } }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/path-expression-matcher": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.5.0.tgz", @@ -2786,6 +3804,16 @@ "node": ">=14.0.0" } }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/path-to-regexp": { "version": "8.4.2", "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", @@ -2796,6 +3824,19 @@ "url": "https://opencollective.com/express" } }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/pino": { "version": "10.3.1", "resolved": "https://registry.npmjs.org/pino/-/pino-10.3.1.tgz", @@ -2858,6 +3899,32 @@ "integrity": "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==", "license": "MIT" }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/prettier": { + "version": "3.9.4", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.4.tgz", + "integrity": "sha512-yWG/o/4oJfo036EKAfK6ACAoDOfHeRHx4tuxkfBZiauURiaSmYwlpOr5LQqKtIkRD2z1PLteme2WoxEnj4tHTg==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, "node_modules/process-warning": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.0.0.tgz", @@ -2936,6 +4003,16 @@ "once": "^1.3.1" } }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/qs": { "version": "6.15.2", "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz", @@ -3171,6 +4248,29 @@ "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", "license": "ISC" }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/side-channel": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", @@ -3435,6 +4535,23 @@ "integrity": "sha512-P4nbQYQfePJxRSmY+v/KINxVucm4NF3p3s7pJveMTtom52FR4YGltUQLB8idDXwDDWW+eYrWDFbuzUnjoWHF7g==", "license": "MIT" }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, "node_modules/toidentifier": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", @@ -3451,6 +4568,19 @@ "license": "MIT", "optional": true }, + "node_modules/ts-api-utils": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", + "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, "node_modules/tslib": { "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", @@ -3476,6 +4606,19 @@ "fsevents": "~2.3.3" } }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, "node_modules/type-is": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz", @@ -3521,6 +4664,30 @@ "node": ">=14.17" } }, + "node_modules/typescript-eslint": { + "version": "8.62.1", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.62.1.tgz", + "integrity": "sha512-vymnnM5g0AKQDSAyfP12nMIBvgwgA42syg74kkuZ4x1VuTzwQKwc5h9rGxeShCjny5o+zWAb6OEoz7XLgrIkIw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/eslint-plugin": "8.62.1", + "@typescript-eslint/parser": "8.62.1", + "@typescript-eslint/typescript-estree": "8.62.1", + "@typescript-eslint/utils": "8.62.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, "node_modules/undici-types": { "version": "7.24.6", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz", @@ -3536,6 +4703,16 @@ "node": ">= 0.8" } }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", @@ -3613,6 +4790,32 @@ "webidl-conversions": "^3.0.0" } }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/wrap-ansi": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", @@ -3702,8 +4905,8 @@ "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "devOptional": true, "license": "MIT", - "optional": true, "engines": { "node": ">=10" }, diff --git a/package.json b/package.json index e7d5439..a9b367f 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,9 @@ "scripts": { "build": "tsc", "preview": "node dist/index.js", - "dev": "tsx watch src/index.ts" + "dev": "tsx watch src/index.ts", + "lint": "npx prettier . --check & eslint .", + "lint-fix": "npx prettier . --write & eslint . --fix" }, "dependencies": { "cors": "^2.8.6", @@ -18,11 +20,18 @@ "zod": "^4.4.3" }, "devDependencies": { + "@eslint/js": "^10.0.1", "@types/cors": "^2.8.19", "@types/express": "^5.0.6", "@types/node": "^25.9.2", + "eslint": "^10.6.0", + "eslint-config-prettier": "^10.1.8", + "eslint-plugin-simple-import-sort": "^13.0.0", + "globals": "^17.7.0", "pino-pretty": "^13.1.3", + "prettier": "^3.9.4", "tsx": "^4.19.4", - "typescript": "^6.0.3" + "typescript": "^6.0.3", + "typescript-eslint": "^8.62.1" } } diff --git a/src/adminApi.ts b/src/adminApi.ts index 0404a87..39021e8 100644 --- a/src/adminApi.ts +++ b/src/adminApi.ts @@ -22,12 +22,13 @@ * OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. * ****************************************************************************************/ +import { getAuth, UserRecord } from 'firebase-admin/auth'; +import { DataSnapshot, EventType, getDatabase, Reference } from 'firebase-admin/database'; + +import { SessionData, SessionHistoryData, StatisticData } from './types/types'; import { adminAuthApp, adminDbApp } from './adminFirebaseConfig'; import { Session } from './session'; -import { SessionHistoryData, SessionData, StatisticData } from './types/types'; import { LDEBUG, LERROR, LINFO } from './utils'; -import { getAuth, UserRecord } from 'firebase-admin/auth'; -import { DataSnapshot, EventType, getDatabase, Reference } from 'firebase-admin/database'; /** * Set admin rights for a user in the Firebase auth database @@ -124,7 +125,9 @@ export async function getSessionsFromDb(): Promise { export function subscribeToDatabase( path: string | Reference, eventType: EventType, + // eslint-disable-next-line @typescript-eslint/no-explicit-any callback: (snapshot: DataSnapshot, b?: string | null | undefined) => any, + // eslint-disable-next-line @typescript-eslint/no-explicit-any onError: (error: Error) => any ) { const db = getDatabase(adminDbApp); @@ -291,7 +294,7 @@ export async function removeSessionFromDb(sessionId: string): Promise { } catch (error) { const errorMessage = `Session '${sessionId}': failed to remove from database`; LERROR(errorMessage, error); - throw new Error(`${errorMessage}: ${(error as Error).message}`); + throw new Error(`${errorMessage}: ${(error as Error).message}`, { cause: error }); } } diff --git a/src/adminFirebaseConfig.ts b/src/adminFirebaseConfig.ts index 1a23832..7719235 100644 --- a/src/adminFirebaseConfig.ts +++ b/src/adminFirebaseConfig.ts @@ -22,17 +22,19 @@ * OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. * ****************************************************************************************/ -import { readFileSync } from 'fs'; import { cert, initializeApp } from 'firebase-admin/app'; +import { readFileSync } from 'fs'; + import { env } from './config/env'; function loadServiceAccount(filePath: string, envVar: string): object { try { return JSON.parse(readFileSync(filePath, 'utf-8')); - } catch (err) { + } catch (error) { throw new Error( `Failed to load Firebase service account from ${envVar}="${filePath}": ` + - `${(err as Error).message}` + `${(error as Error).message}`, + { cause: error } ); } } diff --git a/src/app.ts b/src/app.ts index 9d593ff..bc02869 100644 --- a/src/app.ts +++ b/src/app.ts @@ -22,26 +22,27 @@ * OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. * ****************************************************************************************/ +import cors from 'cors'; +import express, { Request, Response } from 'express'; +import { DataSnapshot } from 'firebase-admin/database'; + +import { env } from './config/env'; +import { SessionData } from './types/types'; import { authorizeAdmin, authorizeUser, - getSessionsFromDb, - getUserByID, getHostPassword, getHostPasswordInternal, + getSessionsFromDb, + getUserByID, postSession, removeSessionFromDb, setAdminRights, subscribeToDatabase, verifyHostPassword } from './adminApi'; -import { Wormhole } from './wormhole'; import { LERROR, LINFO } from './utils'; -import cors from 'cors'; -import express, { Response, Request } from 'express'; -import { DataSnapshot } from 'firebase-admin/database'; -import { env } from './config/env'; -import { SessionData } from './types/types'; +import { Wormhole } from './wormhole'; /** * Top-level application coordinator. Owns the HTTP REST API and the Wormhole TCP server. @@ -110,7 +111,7 @@ export class App { * @param res Response object containing user name */ private async handleRequestGetUserNameByID(req: Request, res: Response): Promise { - const uid = req.params.uid; + const { uid } = req.params; if (!uid || typeof uid !== 'string') { res.status(400).json({ error: 'Invalid uid' }); @@ -158,8 +159,8 @@ export class App { * @param req Request object must contain the user id token and secret in the body */ private async handleRequestSetAdminRights(req: Request, res: Response): Promise { - const uid = req.body.uid; - const secret = req.body.secret; + const { uid } = req.body; + const { secret } = req.body; if (!uid || !secret || typeof uid !== 'string' || typeof secret !== 'string') { res.status(400).json({ error: 'Missing uid or secret' }); @@ -236,10 +237,10 @@ export class App { } const token = authHeader.slice(7); - const password = req.body.password; - const hostPassword = req.body.hostPassword; - const roomName = req.body.roomName; - const profile = req.body.profile; + const { password } = req.body; + const { hostPassword } = req.body; + const { roomName } = req.body; + const { profile } = req.body; const isPrivateRoom = req.body.isPrivate ?? false; // A server without passwords are not allowed if (!hostPassword || typeof hostPassword !== 'string') { @@ -354,7 +355,7 @@ export class App { } const token = authHeader.slice(7); const sessionId = req.params.id; - const password = req.body.password; + const { password } = req.body; if (!sessionId || typeof sessionId !== 'string') { res.status(400).json({ error: 'Session ID is required' }); @@ -391,6 +392,7 @@ export class App { public autoKillInactiveSessions(): void { let sessions: SessionData[] = []; + // eslint-disable-next-line @typescript-eslint/no-explicit-any function handleData(snapshot: DataSnapshot): any { if (snapshot.exists()) { const data = snapshot.val(); diff --git a/src/config/env.ts b/src/config/env.ts index aba7069..1589ddf 100644 --- a/src/config/env.ts +++ b/src/config/env.ts @@ -1,6 +1,9 @@ -import 'dotenv/config'; import { z } from 'zod'; +import 'dotenv/config'; + +import { LERROR } from '../utils'; + const envSchema = z.object({ HTTP_PORT: z.coerce.number().default(25000), WORMHOLE_PORT: z.coerce.number().default(25001), @@ -15,8 +18,7 @@ const envSchema = z.object({ const result = envSchema.safeParse(process.env); if (!result.success) { - console.error('Invalid environment configuration:'); - console.error(result.error); + LERROR('Invalid environment configuration:', result.error); process.exit(1); } diff --git a/src/session.ts b/src/session.ts index 2ce5816..d146622 100644 --- a/src/session.ts +++ b/src/session.ts @@ -22,18 +22,18 @@ * OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. * ****************************************************************************************/ +import { + ConnectionStatus, + CurrentProtocolVersion, + MessageType, + Peer +} from './types/types'; import { updateActiveSessionStatus, updateCurrentActiveUsers, updateCurrentHost, updateUsage } from './adminApi'; -import { - Peer, - ConnectionStatus, - MessageType, - CurrentProtocolVersion -} from './types/types'; import { LDEBUG, LERROR } from './utils'; /** diff --git a/src/utils.ts b/src/utils.ts index 1b19bd0..5e01ca1 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -38,6 +38,7 @@ const FgWhite = '\x1b[37m'; // eslint-disable-next-line @typescript-eslint/no-explicit-any export function LDEBUG(message: string, ...params: any[]): void { if (env.DEBUG) { + // eslint-disable-next-line no-console console.log(FgGreen, message, FgWhite, ...params); } } @@ -49,6 +50,7 @@ export function LDEBUG(message: string, ...params: any[]): void { */ // eslint-disable-next-line @typescript-eslint/no-explicit-any export function LERROR(message: string, ...params: any[]): void { + // eslint-disable-next-line no-console console.error(FgRed, message, FgWhite, ...params); } @@ -60,5 +62,6 @@ export function LERROR(message: string, ...params: any[]): void { */ // eslint-disable-next-line @typescript-eslint/no-explicit-any export function LINFO(message: string, ...params: any[]): void { + // eslint-disable-next-line no-console console.log(FgWhite, message, ...params); } diff --git a/src/wormhole.ts b/src/wormhole.ts index 6127cdd..2ec4998 100644 --- a/src/wormhole.ts +++ b/src/wormhole.ts @@ -22,15 +22,16 @@ * OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. * ****************************************************************************************/ -import { Session } from './session'; +import * as net from 'net'; + import { ConnectionStatus, CurrentProtocolVersion, MessageType, Peer } from './types/types'; +import { Session } from './session'; import { LDEBUG, LERROR, LINFO } from './utils'; -import * as net from 'net'; const NeedMoreData = 0; const ProtocolError = -1; @@ -230,7 +231,7 @@ export class Wormhole { Uint8Array.BYTES_PER_ELEMENT + // Message type identifier Uint32Array.BYTES_PER_ELEMENT; // Payload size in bytes - const buffer = peer.buffer; + const { buffer } = peer; if (buffer.length < HeaderSize) { return NeedMoreData; // Header hasn't fully arrived yet @@ -279,7 +280,7 @@ export class Wormhole { // Dispatch message switch (messageType) { // Session name is parsed from the payload during authentication - case MessageType.Authentication: + case MessageType.Authentication: { const status = this.handleAuthentication(messagePayload, peer); if (status.authenticated) { return messageSize; @@ -287,6 +288,8 @@ export class Wormhole { peer.socket.destroy(new Error(status.message)); return ProtocolError; } + } + default: } // All other message types require the peer to already be registered in a session @@ -309,6 +312,8 @@ export class Wormhole { case MessageType.HostshipResignation: session.handleHostshipResignation(peer); break; + default: + throw new Error(`Unhandled messageType (${messageType})`); } return messageSize; From 30358f57db3a7705b1c099a5e573e1058beb06f9 Mon Sep 17 00:00:00 2001 From: Andreas Engberg Date: Thu, 2 Jul 2026 13:29:05 +0200 Subject: [PATCH 12/17] Update env sample --- .env-sample | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/.env-sample b/.env-sample index 3a8a47f..8c80627 100644 --- a/.env-sample +++ b/.env-sample @@ -1,11 +1,9 @@ SERVER_API_PATH=/api/v1 -# Address and port of the frontend http server listening on fetch requests -#ADDRESS = "http://localhost" +# The port on which the HTTP server will listen for incoming requests HTTP_PORT = 25000 -# Address and port of the backend wormhole server listening for parallel connections -#WORMHOLE_ADDRESS = "localhost" +# The port on which the Wormhole will listen for incoming peer connections from OpenSpace WORMHOLE_PORT = 25001 # Admin SDK file paths @@ -17,4 +15,5 @@ DATABASE_FIREBASE_DATABASE_URL = "" CORS_ORIGIN = "" +# Logs additional debug messages if set to true DEBUG = "true" \ No newline at end of file From f9a0ad6ed15b8635d0a49a97dcd2bd461ada6e93 Mon Sep 17 00:00:00 2001 From: Andreas Engberg Date: Thu, 2 Jul 2026 13:36:58 +0200 Subject: [PATCH 13/17] update readme --- .markdownlint.json | 51 ++++++++++++++++++++++++++++++ README.md | 79 +++++++++++++++++++++++++++------------------- 2 files changed, 98 insertions(+), 32 deletions(-) create mode 100644 .markdownlint.json diff --git a/.markdownlint.json b/.markdownlint.json new file mode 100644 index 0000000..a4cc790 --- /dev/null +++ b/.markdownlint.json @@ -0,0 +1,51 @@ +{ + "MD003": { + "style": "atx" + }, + "MD004": { + "style": "dash" + }, + "MD007": { + "indent": 2, + "start_indented": true + }, + "MD012": false, + "MD013": false, + "MD022": false, + "MD024": { + "siblings_only": true + }, + "MD029": { + "style": "one" + }, + "MD031": false, + "MD032": false, + "MD033": false, + "MD035": { + "style": "---" + }, + "MD041": { + "allow_preamble": true + }, + "MD046": { + "style": "fenced" + }, + "MD048": { + "style": "backtick" + }, + "MD049": { + "style": "asterisk" + }, + "MD050": { + "style": "asterisk" + }, + "MD051": false, + "MD055": { + "style": "leading_and_trailing" + }, + "MD058": false, + "MD059": false, + "MD060": { + "style": "compact" + } +} diff --git a/README.md b/README.md index 42e769c..164fb5e 100644 --- a/README.md +++ b/README.md @@ -1,39 +1,55 @@ -# Wormhole - Parallel Connection -# Overview -The web app enhances the usability of Parallel Connection in OpenSpace by enabling users to view available rooms (sessions), create and share rooms with others, and join sessions directly from the website if OpenSpace is running locally. +# Wormhole -# Functionality -### Session Information -View detailed information about each session, including the number of participants, the creator of the room, the current host, and other relevant information. +The Wormhole server is the backend component of [Astrocast](https://github.com/OpenSpace/astrocast.openspaceproject.com). It is responsible for relaying data between [OpenSpace](https://www.openspaceproject.com) instances in a session, and exposing an HTTP API that the Astrocast frontend uses to manage those sessions. -### Join Existing Rooms -Easily connect to an existing room session via the website if you're connected to OpenSpace. +For the frontend, see the [astrocast.openspaceproject.com](https://github.com/OpenSpace/astrocast.openspaceproject.com) repository. -### Share Room -Share room links with others for automatic joining and seamless streaming. +## Getting Started -### Create New Rooms -All sessions occupied? Log in with your Google, Facebook, Twitter, or GitHub account and create a new session. +### Prerequisites + - [Node.js and npm](https://nodejs.org) -# Wormhole -To start the Wormhole server locally, you will need [Node.JS and NPM](https://nodejs.org) installed on your system. With that taken care of - -1. Clone this repository and enter the `Wormhole` folder -1. Install all potentially required packages by calling `npm install` -1. Make a copy of `.env_sample`, rename it to `.env` and fill in the information. The projects API keys can be found on [Firebase](https://console.firebase.google.com/u/0/), download the projects admin sdk files as well. - - API - 1. In the Firebase console, open `Project Overview` > `1 app` > `Cog wheel` - 1. Scroll down to see the values - - Admin SDK - 1. In the Firebase console, open `Settings` > `Service Accounts`. - 1. Click `Generate New Private Key`, then confirm by clicking `Generate Key`. - 1. Securely store the JSON file containing the key. - -1. Start the frontend and backend server by executing `npm run dev`. This will set up all the neccesary processes that will transpile the necessary files, enable hot reload, and serve a local express app. -1. To ready the app for deployment execute `npm run build`. This will build both the frontend and backend into `.local/express` and `.local/vite`. -1. Optionally you can run only the frontend by executing `npm run vite:dev` or only the backend by executing `npm run api:dev`. - -# Message Structures (version 7) +### Installation + +1. Clone this repository +1. Install dependencies: + ```sh + npm install + ``` +1. Copy `.env_sample` to `.env` and fill in the values (see [Environment Variables](#environment-variables) below) + +### Running + +| Command | Description | +| --------- | ------------- | +| `npm run dev` | Start the server with hot reload (development) | +| `npm run build` | Compile TypeScript to `dist/` | +| `npm run preview` | Run the compiled output from `dist/` | +| `npm run lint` | Check formatting (Prettier) and linting (ESLint) | +| `npm run lint-fix` | Auto-fix formatting and lint issues | + +### Environment Variables + +| Variable | Required | Default | Description | +| ---------- | ---------- | --------- | ------------- | +| `HTTP_PORT` | | `25000` | Port the HTTP API listens on | +| `WORMHOLE_PORT` | | `25001` | Port the TCP Wormhole server listens on | +| `SERVER_API_PATH` | ✓ | | Base path for all API routes, e.g. `/api` | +| `CORS_ORIGIN` | ✓ | | The origin of the frontend, e.g. `https://astrocast.openspaceproject.com` | +| `DEBUG` | | `false` | Set to `true` to enable verbose debug logging | +| `ADMIN_AUTH_SDK_FILEPATH` | ✓ | | Path to the Firebase Admin SDK JSON file used for authentication | +| `ADMIN_DB_SDK_FILEPATH` | ✓ | | Path to the Firebase Admin SDK JSON file used for the database | +| `DATABASE_FIREBASE_DATABASE_URL` | ✓ | | URL of the Firebase Realtime Database | + +### Obtaining Firebase credentials + +1. Open the [Firebase console](https://console.firebase.google.com) +1. Go to **Project Settings** > **Service Accounts** +1. Click **Generate New Private Key** and confirm — this downloads a JSON file +1. Set `ADMIN_AUTH_SDK_FILEPATH` and `ADMIN_DB_SDK_FILEPATH` to the path(s) of the downloaded file(s) +1. The `DATABASE_FIREBASE_DATABASE_URL` can be found under **Project Settings** > **General** + +## Message Structures (version 7) This section describes the different message types that are being sent between OpenSpace and the Wormhole application. A message consists of a header and a type-appropriate payload. ```cpp @@ -46,7 +62,6 @@ struct { } ``` - ## Authentication (Type = 0) This message is sent from OpenSpace to the Wormhole server to authenticate a new peer to a running session. It consists of a password which has to be provided, and an optional host password, and the optional name. An optional parameter is represented by a length of 0. From 12e0acb657149902ab9540a5dba3cda708ff2600 Mon Sep 17 00:00:00 2001 From: Andreas Engberg Date: Thu, 2 Jul 2026 14:35:21 +0200 Subject: [PATCH 14/17] fix pr comments --- README.md | 2 +- eslint.config.mjs | 1 - package.json | 4 ++-- src/adminApi.ts | 2 +- src/session.ts | 4 ++-- src/wormhole.ts | 2 +- 6 files changed, 7 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 164fb5e..048efa7 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ For the frontend, see the [astrocast.openspaceproject.com](https://github.com/Op ```sh npm install ``` -1. Copy `.env_sample` to `.env` and fill in the values (see [Environment Variables](#environment-variables) below) +1. Copy `.env-sample` to `.env` and fill in the values (see [Environment Variables](#environment-variables) below) ### Running diff --git a/eslint.config.mjs b/eslint.config.mjs index 2a1e85f..b1bb969 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -29,7 +29,6 @@ export default tseslint.config( 'no-duplicate-imports': 'error', // Imports should be on one line 'no-console': 'warn', 'prefer-destructuring': ['error', { object: true, array: true }], - 'simple-import-sort/imports': 'error', 'simple-import-sort/exports': 'error', 'simple-import-sort/imports': [ 'error', diff --git a/package.json b/package.json index a9b367f..dd3ce9a 100644 --- a/package.json +++ b/package.json @@ -8,8 +8,8 @@ "build": "tsc", "preview": "node dist/index.js", "dev": "tsx watch src/index.ts", - "lint": "npx prettier . --check & eslint .", - "lint-fix": "npx prettier . --write & eslint . --fix" + "lint": "npx prettier . --check && eslint .", + "lint-fix": "npx prettier . --write && eslint . --fix" }, "dependencies": { "cors": "^2.8.6", diff --git a/src/adminApi.ts b/src/adminApi.ts index 39021e8..0510a9f 100644 --- a/src/adminApi.ts +++ b/src/adminApi.ts @@ -282,7 +282,7 @@ export async function removeSessionFromDb(sessionId: string): Promise { const snapshot = await instanceRef.once('value'); if (!snapshot.exists()) { - const errorMessage = `Session '${sessionId}': does not exists, cannot remove`; + const errorMessage = `Session '${sessionId}': does not exist, cannot remove`; LDEBUG(errorMessage); throw new Error(errorMessage); } diff --git a/src/session.ts b/src/session.ts index d146622..ac33ab5 100644 --- a/src/session.ts +++ b/src/session.ts @@ -135,8 +135,8 @@ export class Session { return; } - // Create a copy of the peers since `this.peers` can be mutaded concurrently by the - // Worhmhole socket 'error' callback while we're still iterating here. + // Create a copy of the peers since `this.peers` can be mutated concurrently by the + // Wormhole socket 'error' callback while we're still iterating here. const peersToDisconnect = [...this.peers]; const settled = new Set(); diff --git a/src/wormhole.ts b/src/wormhole.ts index 2ec4998..14be51c 100644 --- a/src/wormhole.ts +++ b/src/wormhole.ts @@ -78,7 +78,7 @@ export class Wormhole { LDEBUG(`Wormhole: new connection from ${socket.remoteAddress}`); socket.setNoDelay(true); - // The peer is only added to the list if the authentication is succesful. Note: the + // The peer is only added to the list if the authentication is successful. Note: the // `Id` is assigned once authentication is attempted, see `handleAuthentication` const peer: Peer = { id: -1, From d42d4362e270a7912143c0b8b73e4ce9ab35af3c Mon Sep 17 00:00:00 2001 From: Andreas Engberg Date: Wed, 8 Jul 2026 14:02:21 +0200 Subject: [PATCH 15/17] fix issues with inactiveTimestamp not updating correctly --- src/adminApi.ts | 8 ++++---- src/app.ts | 8 ++++---- src/types/types.ts | 4 ++-- 3 files changed, 10 insertions(+), 10 deletions(-) diff --git a/src/adminApi.ts b/src/adminApi.ts index 0510a9f..f214974 100644 --- a/src/adminApi.ts +++ b/src/adminApi.ts @@ -163,7 +163,7 @@ export async function postSession( const data: SessionData = { active: false, - inactiveTimeStamp: Date.now(), + inactiveTimestamp: Date.now(), created: Date.now(), usage: 0, password: metadata.password, @@ -254,7 +254,7 @@ export async function postSessionHistoryData(session: SessionData): Promise thirtyMinutes) { sessionsToRemove.push(session); } diff --git a/src/types/types.ts b/src/types/types.ts index 1c421fc..aed8df2 100644 --- a/src/types/types.ts +++ b/src/types/types.ts @@ -3,7 +3,7 @@ import { Socket } from 'net'; export type SessionData = { id: string; active: boolean; - inactiveTimeStamp: number; + inactiveTimestamp: number; created: number; usage: number; password: string; @@ -17,7 +17,7 @@ export type SessionData = { export type SessionHistoryData = { id: string; - inactiveTimeStamp: number; + inactiveTimestamp: number; created: number; uptime: number; usage: number; From a93aeb58c3688d0f8a54a13189375569df33d148 Mon Sep 17 00:00:00 2001 From: Andreas Engberg Date: Fri, 10 Jul 2026 15:04:50 +0200 Subject: [PATCH 16/17] fix pr comments Max paylaod size, try catch parsing payload messages - close connection if it fails, remove connection if we get unknown messageType --- src/wormhole.ts | 88 +++++++++++++++++++++++++++++-------------------- 1 file changed, 52 insertions(+), 36 deletions(-) diff --git a/src/wormhole.ts b/src/wormhole.ts index 14be51c..c5d28b7 100644 --- a/src/wormhole.ts +++ b/src/wormhole.ts @@ -268,52 +268,68 @@ export class Wormhole { const payloadSize = dv.getUint32(offset, true); offset += Uint32Array.BYTES_PER_ELEMENT; + + const MaxPayloadSize = 128000; // 128 kb limit + if (payloadSize > MaxPayloadSize) { + LERROR(`Peer #${peer.id}: payload too large (${payloadSize} bytes)`); + peer.socket.destroy(new Error('Payload too large')); + return ProtocolError; + } + const messageSize = offset + payloadSize; if (buffer.byteLength < messageSize) { return NeedMoreData; // Payload hasn't fully arrived yet } - // Slice the header data from the message - const messagePayload = buffer.subarray(offset, messageSize); - - // Dispatch message - switch (messageType) { - // Session name is parsed from the payload during authentication - case MessageType.Authentication: { - const status = this.handleAuthentication(messagePayload, peer); - if (status.authenticated) { - return messageSize; - } else { - peer.socket.destroy(new Error(status.message)); - return ProtocolError; + try { + // Slice the header data from the message + const messagePayload = buffer.subarray(offset, messageSize); + + // Dispatch message + switch (messageType) { + // Session name is parsed from the payload during authentication + case MessageType.Authentication: { + const status = this.handleAuthentication(messagePayload, peer); + if (status.authenticated) { + return messageSize; + } else { + peer.socket.destroy(new Error(status.message)); + return ProtocolError; + } } + default: } - default: - } - // All other message types require the peer to already be registered in a session - const session = this._sessions[peer.sessionName]; - if (!session) { - LERROR(`Wormhole: session '${peer.sessionName}' not found`); - peer.socket.destroy( - new Error(`Could not find session with name '${peer.sessionName}'`) - ); - return ProtocolError; - } + // All other message types require the peer to already be registered in a session + const session = this._sessions[peer.sessionName]; + if (!session) { + LERROR(`Wormhole: session '${peer.sessionName}' not found`); + peer.socket.destroy( + new Error(`Could not find session with name '${peer.sessionName}'`) + ); + return ProtocolError; + } - switch (messageType) { - case MessageType.Data: - session.handleData(messagePayload, peer); - break; - case MessageType.HostshipRequest: - session.handleHostshipRequest(messagePayload, peer); - break; - case MessageType.HostshipResignation: - session.handleHostshipResignation(peer); - break; - default: - throw new Error(`Unhandled messageType (${messageType})`); + switch (messageType) { + case MessageType.Data: + session.handleData(messagePayload, peer); + break; + case MessageType.HostshipRequest: + session.handleHostshipRequest(messagePayload, peer); + break; + case MessageType.HostshipResignation: + session.handleHostshipResignation(peer); + break; + default: + LERROR(`Peer #${peer.id}: Unhandled messageType (${messageType})`); + peer.socket.destroy(new Error('Unhandled message type')); + return ProtocolError; + } + } catch (error) { + LERROR(`Peer #${peer.id}: malformed payload`); + peer.socket.destroy(new Error('Malformed payload')); + return ProtocolError; } return messageSize; From 19e89849fa517dd5036573353e725556380d0f1f Mon Sep 17 00:00:00 2001 From: Andreas Engberg Date: Fri, 10 Jul 2026 15:39:32 +0200 Subject: [PATCH 17/17] fix pr comments --- src/wormhole.ts | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/src/wormhole.ts b/src/wormhole.ts index c5d28b7..1751e2f 100644 --- a/src/wormhole.ts +++ b/src/wormhole.ts @@ -140,7 +140,7 @@ export class Wormhole { // If the session already exists, remove it (this *should* not happen) if (name in this._sessions) { const oldSession = this._sessions[name]; - await this.removeSession(oldSession.getSessionMetadata().id!); + await this.removeSession(oldSession); } const session = new Session(name, password, hostPassword, id); @@ -411,6 +411,16 @@ export class Wormhole { return { authenticated: false, message: `Session '${sessionName}': not found` }; } + if (!session.getSessionMetadata().id) { + LERROR( + `Wormhole: session '${sessionName}' has no ID set when peer '${peer.socket.remoteAddress} tried to connect` + ); + return { + authenticated: false, + message: 'Missing session ID, session not ready yet, please try again later' + }; + } + if (!session.isPasswordValid(password)) { LDEBUG(`Wormhole: session '${sessionName}' invalid password provided`); return {