From 3c3364c7f6d4dcd490f26b9a2984f09d4155eb3d Mon Sep 17 00:00:00 2001 From: morizon Date: Thu, 17 Sep 2026 15:15:06 +0800 Subject: [PATCH 1/6] feat: support chunked transfer relay for OK-63101 --- .../transfer-server/src/JsBridgeE2EEServer.ts | 40 +++++++++++++++ packages/transfer-server/src/roomManager.ts | 3 ++ packages/transfer-server/test/smoke.ts | 50 ++++++++++++++++++- 3 files changed, 92 insertions(+), 1 deletion(-) diff --git a/packages/transfer-server/src/JsBridgeE2EEServer.ts b/packages/transfer-server/src/JsBridgeE2EEServer.ts index 69492b7..03b290a 100644 --- a/packages/transfer-server/src/JsBridgeE2EEServer.ts +++ b/packages/transfer-server/src/JsBridgeE2EEServer.ts @@ -115,6 +115,10 @@ export class JsBridgeE2EEServer extends JsBridgeBase { */ private rateLimitState = new Map(); + private chunkWindowStartedAt = 0; + + private chunkRequestsInWindow = 0; + private invalidPayloadLogWindowStart = 0; private invalidPayloadLogCount = 0; @@ -247,6 +251,42 @@ export class JsBridgeE2EEServer extends JsBridgeBase { const req = payload?.data as IJsonRpcRequest | undefined; const method = typeof req?.method === 'string' ? req.method : ''; + // Chunk RPCs are bounded by both message size and connection throughput. + // Keep the existing per-method limit for all other operations. + if (eventName === 'e2ee-c2c-request' && method === 'sendTransferChunk') { + const now = Date.now(); + if (now - this.chunkWindowStartedAt >= 1000) { + this.chunkWindowStartedAt = now; + this.chunkRequestsInWindow = 0; + } + this.chunkRequestsInWindow += 1; + const params = req?.params; + const chunk = (Array.isArray(params) ? params[0] : undefined) as + | { data?: unknown; transferId?: unknown; index?: unknown } + | undefined; + if ( + this.chunkRequestsInWindow > 512 || + !Array.isArray(params) || + params.length !== 1 || + !chunk || + Object.keys(chunk).length !== 3 || + typeof chunk.transferId !== 'string' || + !/^[a-zA-Z0-9-]{1,64}$/.test(chunk.transferId) || + typeof chunk.index !== 'number' || + !Number.isSafeInteger(chunk.index) || + chunk.index < 0 || + chunk.index >= 1024 || + typeof chunk.data !== 'string' || + chunk.data.length === 0 || + chunk.data.length > 64 * 1024 || + !/^[A-Za-z0-9+/]*={0,2}$/.test(chunk.data) + ) { + sendErrorResponse(); + return true; + } + return false; + } + // Check if method is in whitelist if (RATE_LIMIT_WHITELIST.has(method)) { return false; diff --git a/packages/transfer-server/src/roomManager.ts b/packages/transfer-server/src/roomManager.ts index 508cd03..288aece 100644 --- a/packages/transfer-server/src/roomManager.ts +++ b/packages/transfer-server/src/roomManager.ts @@ -120,6 +120,7 @@ export class RoomManager { roomKey?: string; error?: string; userCount?: number; + chunkedTransferVersion?: number; }> { await timerUtils.wait(1000); if (!context) { @@ -161,6 +162,7 @@ export class RoomManager { userId, roomId, roomKey: room.encryptionKey, + chunkedTransferVersion: 1, userCount: room.users.size, }; } @@ -192,6 +194,7 @@ export class RoomManager { roomId, userCount: room.users.size, roomKey: room.encryptionKey, + chunkedTransferVersion: 1, }; } diff --git a/packages/transfer-server/test/smoke.ts b/packages/transfer-server/test/smoke.ts index 653a314..6061b70 100644 --- a/packages/transfer-server/test/smoke.ts +++ b/packages/transfer-server/test/smoke.ts @@ -397,7 +397,8 @@ async function main(): Promise { await clientA.call('roomManager', 'joinRoomAfterCreate', [ { roomId: room.roomId, ...appInfo('A') }, ]); - await clientB.call('roomManager', 'joinRoom', [{ roomId: room.roomId, ...appInfo('B') }]); + const joined = await clientB.call('roomManager', 'joinRoom', [{ roomId: room.roomId, ...appInfo('B') }]); + check(joined.chunkedTransferVersion === 1, 'relay advertises chunked transfer support'); const usersBefore = await clientA.call('roomManager', 'getRoomUsers', [ { roomId: room.roomId }, @@ -407,6 +408,53 @@ async function main(): Promise { // --- baseline: peers can talk both ways before anything goes wrong --- await checkBidirectional(clientA, clientB, room.roomId, 'before'); + // More than one chunk must pass the relay without the legacy 3 second limit. + const chunkStart = clientB.c2cRequests.length; + const chunkData = 'A'.repeat(64 * 1024); + for (let index = 0; index < 12; index += 1) { + clientA.socket.emit('e2ee-c2c-request', { + roomId: room.roomId, + payload: { id: 70000 + index, type: 'REQUEST', data: { + module: 'api', method: 'sendTransferChunk', + params: [{ transferId: 'smoke-chunks', index, data: chunkData }], + } }, + }); + } + await wait(500); + const chunks = clientB.c2cRequests.slice(chunkStart); + check(chunks.length === 12, 'consecutive 64 KiB chunks are forwarded', String(chunks.length)); + check(chunks.every((packet, index) => packet.data.params[0].index === index && packet.data.params[0].data === chunkData), 'chunk contents and order remain intact'); + + const invalidStart = clientB.c2cRequests.length; + for (const params of [ + [{ transferId: 'smoke-chunks', index: 0, data: chunkData + 'A' }], + [{ transferId: 'smoke-chunks', index: -1, data: 'AAAA' }], + [{ transferId: 'smoke-chunks', index: 0, data: 'AAAA', extra: 'large' }], + [{ transferId: 'smoke-chunks', index: 0, data: 'AAAA' }, 'extra'], + [{ transferId: 'smoke-chunks', index: 0, data: '????' }], + ]) { + clientA.socket.emit('e2ee-c2c-request', { + roomId: room.roomId, + payload: { id: 71000, type: 'REQUEST', data: { module: 'api', method: 'sendTransferChunk', params } }, + }); + } + await wait(300); + check(clientB.c2cRequests.length === invalidStart, 'oversized and malformed chunks never reach the peer'); + + await wait(1100); + const burstStart = clientB.c2cRequests.length; + for (let index = 0; index < 530; index += 1) { + clientA.socket.emit('e2ee-c2c-request', { + roomId: room.roomId, + payload: { id: 72000 + index, type: 'REQUEST', data: { + module: 'api', method: 'sendTransferChunk', + params: [{ transferId: 'smoke-burst', index, data: 'AAAA' }], + } }, + }); + } + await wait(500); + check(clientB.c2cRequests.length - burstStart === 512, 'chunk floods stay capped at 512 messages per second'); + // --- both clients attack every listener --- console.log( `\nfiring ${MALFORMED_PACKETS.length} malformed packets from each client (${ From 9c397915498febf45af7d51bce38a5d3cca0c866 Mon Sep 17 00:00:00 2001 From: morizon Date: Thu, 17 Sep 2026 16:32:41 +0800 Subject: [PATCH 2/6] build(deps): consolidate updates and adopt eslint 10 --- .yarnrc.yml | 3 +- eslint.config.cjs | 28 + examples/mock-app/.eslintrc.js | 22 - examples/mock-app/package.json | 6 +- package.json | 11 +- packages/cloud-sync-server/.eslintrc.js | 23 - packages/cloud-sync-server/package.json | 12 +- packages/transfer-server/package.json | 6 +- .../transfer-server/src/JsBridgeE2EEServer.ts | 1 - .../src/decorators/e2eeApiMethod.ts | 5 - packages/transfer-server/src/e2eeServerApi.ts | 7 - .../transfer-server/src/e2eeServerApiProxy.ts | 2 - packages/transfer-server/src/roomManager.ts | 2 - packages/transfer-server/src/server.ts | 33 +- .../src/utils/RemoteApiProxyBase.ts | 15 +- .../transfer-server/src/utils/bufferUtils.ts | 3 - .../transfer-server/src/utils/cacheUtils.ts | 1 - .../transfer-server/src/utils/cryptoUtils.ts | 2 - packages/transfer-server/src/utils/logger.ts | 2 +- .../transfer-server/src/utils/stringUtils.ts | 1 - .../transfer-server/src/utils/timerUtils.ts | 1 - packages/transfer-server/test/smoke.ts | 4 - yarn.lock | 1223 ++++++++--------- 23 files changed, 664 insertions(+), 749 deletions(-) create mode 100644 eslint.config.cjs delete mode 100644 examples/mock-app/.eslintrc.js delete mode 100644 packages/cloud-sync-server/.eslintrc.js diff --git a/.yarnrc.yml b/.yarnrc.yml index 8b757b2..ccdea23 100644 --- a/.yarnrc.yml +++ b/.yarnrc.yml @@ -1 +1,2 @@ -nodeLinker: node-modules \ No newline at end of file +nodeLinker: node-modules +compressionLevel: 0 diff --git a/eslint.config.cjs b/eslint.config.cjs new file mode 100644 index 0000000..1e14244 --- /dev/null +++ b/eslint.config.cjs @@ -0,0 +1,28 @@ +const js = require('@eslint/js'); +const { defineConfig } = require('eslint/config'); +const prettier = require('eslint-config-prettier'); +const globals = require('globals'); +const tseslint = require('typescript-eslint'); + +module.exports = defineConfig([ + { ignores: ['**/dist/**', '**/coverage/**', '**/node_modules/**'] }, + { + files: ['packages/*/{src,test}/**/*.ts', 'examples/*/{src,test}/**/*.ts'], + extends: [js.configs.recommended, tseslint.configs.recommended, prettier], + languageOptions: { + ecmaVersion: 2021, + sourceType: 'module', + globals: globals.node, + }, + rules: { + '@typescript-eslint/no-explicit-any': 'off', + '@typescript-eslint/no-unused-vars': 'off', + '@typescript-eslint/explicit-module-boundary-types': 'off', + '@typescript-eslint/no-empty-function': 'off', + }, + }, + { + files: ['**/test/**/*.ts'], + languageOptions: { globals: globals.jest }, + }, +]); diff --git a/examples/mock-app/.eslintrc.js b/examples/mock-app/.eslintrc.js deleted file mode 100644 index 1fb4c3f..0000000 --- a/examples/mock-app/.eslintrc.js +++ /dev/null @@ -1,22 +0,0 @@ -module.exports = { - root: true, - parser: '@typescript-eslint/parser', - extends: ['eslint:recommended', 'plugin:@typescript-eslint/recommended'], - plugins: ['@typescript-eslint'], - parserOptions: { - ecmaVersion: 2021, - sourceType: 'module', - project: './tsconfig.eslint.json', - tsconfigRootDir: __dirname, - }, - env: { - node: true, - jest: true, - }, - rules: { - '@typescript-eslint/no-explicit-any': 'off', - '@typescript-eslint/no-unused-vars': 'off', - '@typescript-eslint/explicit-module-boundary-types': 'off', - '@typescript-eslint/no-empty-function': 'off', - }, -}; \ No newline at end of file diff --git a/examples/mock-app/package.json b/examples/mock-app/package.json index 3da7ebf..574f96b 100644 --- a/examples/mock-app/package.json +++ b/examples/mock-app/package.json @@ -9,7 +9,7 @@ "start": "cross-env NODE_ENV=production node ./bootstrap.js", "test": "midway-bin test --ts", "cov": "midway-bin cov --ts", - "lint": "eslint src --ext .ts" + "lint": "eslint src test --max-warnings 0" }, "dependencies": { "@midwayjs/bootstrap": "^3.20.0", @@ -25,10 +25,8 @@ "@midwayjs/mock": "^3.20.11", "@types/jest": "^29.0.0", "@types/node": "^20.0.0", - "@typescript-eslint/eslint-plugin": "^5.0.0", - "@typescript-eslint/parser": "^5.0.0", "cross-env": "^10.0.0", - "eslint": "^7.32.0", + "eslint": "^10.10.0", "jest": "^29.0.0", "nodemon": "^3.0.0", "ts-jest": "^29.0.0", diff --git a/package.json b/package.json index 172e39e..3feca49 100644 --- a/package.json +++ b/package.json @@ -2,6 +2,7 @@ "name": "@onekeyhq/monorepo", "version": "1.0.0", "private": true, + "packageManager": "yarn@4.14.1", "description": "OneKey Monorepo", "workspaces": [ "packages/*", @@ -19,12 +20,18 @@ "test": "yarn workspaces foreach --all run test", "test:sync": "yarn workspace @onekeyhq/cloud-sync-server test", "test:mock": "yarn workspace @onekeyhq/mock-app test", - "lint": "yarn workspaces foreach --all run lint", + "lint": "eslint packages/*/src packages/*/test examples/*/src examples/*/test --max-warnings 0", "lint:sync": "yarn workspace @onekeyhq/cloud-sync-server lint", "clean": "yarn workspaces foreach --all run clean" }, "devDependencies": { - "rimraf": "^5.0.5" + "@eslint/js": "^10.0.1", + "eslint": "^10.10.0", + "eslint-config-prettier": "^10.1.8", + "globals": "^17.12.0", + "rimraf": "^5.0.5", + "typescript": "^5.0.0", + "typescript-eslint": "^8.70.0" }, "engines": { "node": ">=24" diff --git a/packages/cloud-sync-server/.eslintrc.js b/packages/cloud-sync-server/.eslintrc.js deleted file mode 100644 index abeb1e7..0000000 --- a/packages/cloud-sync-server/.eslintrc.js +++ /dev/null @@ -1,23 +0,0 @@ -module.exports = { - root: true, - parser: '@typescript-eslint/parser', - extends: ['eslint:recommended', 'plugin:@typescript-eslint/recommended', 'prettier'], - plugins: ['@typescript-eslint', 'prettier'], - parserOptions: { - ecmaVersion: 2021, - sourceType: 'module', - project: './tsconfig.eslint.json', - tsconfigRootDir: __dirname, - }, - env: { - node: true, - jest: true, - }, - rules: { - '@typescript-eslint/no-explicit-any': 'off', - '@typescript-eslint/no-unused-vars': 'off', - '@typescript-eslint/explicit-module-boundary-types': 'off', - '@typescript-eslint/no-empty-function': 'off', - 'node/no-extraneous-import': 'off', - }, -}; \ No newline at end of file diff --git a/packages/cloud-sync-server/package.json b/packages/cloud-sync-server/package.json index bcfad2d..60ece7f 100644 --- a/packages/cloud-sync-server/package.json +++ b/packages/cloud-sync-server/package.json @@ -8,14 +8,14 @@ "build": "tsc", "dev": "tsc -w", "watch": "tsc -w", - "lint": "eslint src test --ext .ts", - "lint:fix": "eslint src test --ext .ts --fix", + "lint": "eslint src test --max-warnings 0", + "lint:fix": "eslint src test --fix --max-warnings 0", "test": "jest", "test:watch": "jest --watch", "test:cov": "jest --coverage" }, "dependencies": { - "lodash": "^4.17.21" + "lodash": "^4.18.1" }, "peerDependencies": { "@midwayjs/core": "^3.0.0", @@ -27,11 +27,7 @@ "devDependencies": { "@types/jest": "^29.0.0", "@types/lodash": "^4.14.200", - "@typescript-eslint/eslint-plugin": "^5.0.0", - "@typescript-eslint/parser": "^5.0.0", - "eslint": "^7.32.0", - "eslint-config-prettier": "^8.5.0", - "eslint-plugin-prettier": "^4.0.0", + "eslint": "^10.10.0", "jest": "^29.0.0", "prettier": "^2.8.8", "ts-jest": "^29.0.0", diff --git a/packages/transfer-server/package.json b/packages/transfer-server/package.json index 0a2c110..6367194 100644 --- a/packages/transfer-server/package.json +++ b/packages/transfer-server/package.json @@ -11,7 +11,8 @@ "clean": "rimraf dist", "test": "yarn build && ts-node --project test/tsconfig.json test/smoke.ts && ts-node --project test/tsconfig.json test/crash-logging.ts", "test:crash": "yarn build && ts-node --project test/tsconfig.json test/crash-logging.ts", - "postinstall": "echo 'e2ee-server dependencies installed'" + "postinstall": "echo 'e2ee-server dependencies installed'", + "lint": "eslint src test --max-warnings 0" }, "dependencies": { "@noble/hashes": "^1.8.0", @@ -21,7 +22,7 @@ "cors": "^2.8.5", "express": "^4.18.2", "fast-json-stable-stringify": "^2.1.0", - "lodash": "^4.17.21", + "lodash": "^4.18.1", "lru-cache": "^10.0.0", "memoizee": "^0.4.15", "nanoid": "^5.0.4", @@ -35,6 +36,7 @@ "@types/lodash": "^4.14.202", "@types/memoizee": "^0.4.11", "@types/node": "^20.0.0", + "eslint": "^10.10.0", "nodemon": "^3.0.0", "pino-pretty": "^13.1.3", "rimraf": "^5.0.5", diff --git a/packages/transfer-server/src/JsBridgeE2EEServer.ts b/packages/transfer-server/src/JsBridgeE2EEServer.ts index 03b290a..e686d75 100644 --- a/packages/transfer-server/src/JsBridgeE2EEServer.ts +++ b/packages/transfer-server/src/JsBridgeE2EEServer.ts @@ -1,4 +1,3 @@ -/* eslint-disable no-restricted-syntax */ import { JsBridgeBase } from '@onekeyfe/cross-inpage-provider-core'; import { IJsBridgeMessageTypes } from '@onekeyfe/cross-inpage-provider-types'; diff --git a/packages/transfer-server/src/decorators/e2eeApiMethod.ts b/packages/transfer-server/src/decorators/e2eeApiMethod.ts index 329e8fb..0982979 100644 --- a/packages/transfer-server/src/decorators/e2eeApiMethod.ts +++ b/packages/transfer-server/src/decorators/e2eeApiMethod.ts @@ -17,15 +17,12 @@ export function e2eeApiMethod() { ) { // Get existing allowed methods or initialize empty set const allowedMethods = - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access target.constructor[E2EE_API_METHODS_KEY] || new Set(); // Add this method to the allowed methods set - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-call allowedMethods.add(propertyKey); // Store the updated set back on the constructor - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access target.constructor[E2EE_API_METHODS_KEY] = allowedMethods; return descriptor; @@ -36,9 +33,7 @@ export function e2eeApiMethod() { * Check if a method is allowed to be called via E2EE API */ export function isMethodAllowed(instance: any, methodName: string): boolean { - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access const constructor = instance.constructor; - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access const allowedMethods = constructor[E2EE_API_METHODS_KEY] as Set; return allowedMethods?.has(methodName) || false; diff --git a/packages/transfer-server/src/e2eeServerApi.ts b/packages/transfer-server/src/e2eeServerApi.ts index 32815a0..4f4fb19 100644 --- a/packages/transfer-server/src/e2eeServerApi.ts +++ b/packages/transfer-server/src/e2eeServerApi.ts @@ -1,7 +1,3 @@ -/* eslint-disable prettier/prettier */ -/* eslint-disable no-restricted-syntax */ -/* eslint-disable new-cap */ - import { JsBridgeE2EEServer } from './JsBridgeE2EEServer'; import { E2eeError, E2eeErrorCode } from './errors'; import { memoizee } from './utils/cacheUtils'; @@ -61,10 +57,7 @@ function createBridgeE2EEServer({ receiveHandler: async (payload) => { const req: IJsonRpcRequest = payload.data as IJsonRpcRequest; - // @ts-ignore - // eslint-disable-next-line @typescript-eslint/no-unsafe-call const result = await callE2EEServerApiMethod(req); - // eslint-disable-next-line @typescript-eslint/no-unsafe-return return result; }, }, diff --git a/packages/transfer-server/src/e2eeServerApiProxy.ts b/packages/transfer-server/src/e2eeServerApiProxy.ts index b3ea321..c37dbc6 100644 --- a/packages/transfer-server/src/e2eeServerApiProxy.ts +++ b/packages/transfer-server/src/e2eeServerApiProxy.ts @@ -1,5 +1,3 @@ -/* eslint-disable no-restricted-syntax */ - import { JsBridgeE2EEClient } from './JsBridgeE2EEClient'; import { RemoteApiProxyBase } from './utils/RemoteApiProxyBase'; diff --git a/packages/transfer-server/src/roomManager.ts b/packages/transfer-server/src/roomManager.ts index 288aece..25c825e 100644 --- a/packages/transfer-server/src/roomManager.ts +++ b/packages/transfer-server/src/roomManager.ts @@ -1,5 +1,3 @@ -/* eslint-disable no-restricted-syntax */ - import { sortBy } from "lodash"; import { e2eeApiMethod } from "./decorators/e2eeApiMethod"; diff --git a/packages/transfer-server/src/server.ts b/packages/transfer-server/src/server.ts index c6b42ea..ff0cca4 100644 --- a/packages/transfer-server/src/server.ts +++ b/packages/transfer-server/src/server.ts @@ -1,9 +1,7 @@ // Must come first: installs browser-global shims that // @onekeyfe/cross-inpage-provider-core reads while its module is evaluated. -// eslint-disable-next-line import/order, import/first import './utils/nodeCompat'; // Then the crash guards, before any other module can throw at load time. -// eslint-disable-next-line import/order, import/first import { markServerStarted } from './utils/processGuards'; import { createServer } from 'http'; @@ -78,7 +76,6 @@ class E2EEServer { this.corsOptions = { origin: (origin, callback) => { - // eslint-disable-next-line @typescript-eslint/no-unsafe-argument if (!origin || this.config.corsOrigins.includes(origin)) { callback(null, true); } else { @@ -241,7 +238,7 @@ class E2EEServer { private getNetworkIPs(): string[] { const interfaces = networkInterfaces(); const ips: string[] = []; - + for (const name of Object.keys(interfaces)) { const netInterface = interfaces[name]; if (netInterface) { @@ -253,7 +250,7 @@ class E2EEServer { } } } - + return ips; } @@ -270,45 +267,45 @@ class E2EEServer { this.httpServer.listen(this.config.port, () => { const networkIPs = this.getNetworkIPs(); - + // Calculate padding for proper alignment (box width is 58 chars inside) const boxWidth = 58; - + const portText = `πŸš€ Server started successfully`; const portLine = `β•‘ ${portText}${' '.repeat(boxWidth - portText.length - 2)} β•‘`; - + const portInfoText = `πŸ“‘ Port: ${this.config.port}`; const portInfoLine = `β•‘ ${portInfoText}${' '.repeat(boxWidth - portInfoText.length - 2)} β•‘`; - + const usersText = `πŸ‘₯ Max room users: ${this.config.roomConfig.maxUsers}`; const usersLine = `β•‘ ${usersText}${' '.repeat(boxWidth - usersText.length - 2)} β•‘`; - + const timeoutMinutes = Math.floor(this.config.roomConfig.roomTimeout / 60_000); const timeoutText = `⏰ Room timeout: ${timeoutMinutes} minutes`; const timeoutLine = `β•‘ ${timeoutText}${' '.repeat(boxWidth - timeoutText.length - 2)} β•‘`; - + const localhostTitleText = `🏠 Localhost`; const localhostTitleLine = `β•‘ ${localhostTitleText}${' '.repeat(boxWidth - localhostTitleText.length - 2)} β•‘`; - + const localhostEndpointText = ` - endpoint: http://localhost:${this.config.port}`; const localhostEndpointLine = `β•‘ ${localhostEndpointText}${' '.repeat(boxWidth - localhostEndpointText.length - 2)} β•‘`; - + const localhostHealthText = ` - health: http://localhost:${this.config.port}/health`; const localhostHealthLine = `β•‘ ${localhostHealthText}${' '.repeat(boxWidth - localhostHealthText.length - 2)} β•‘`; - + const networkLines = networkIPs.map(ip => { const lanTitleText = `πŸ”— LAN (${ip})`; const lanTitleLine = `β•‘ ${lanTitleText}${' '.repeat(boxWidth - lanTitleText.length - 2)} β•‘`; - + const lanEndpointText = ` - endpoint: http://${ip}:${this.config.port}`; const lanEndpointLine = `β•‘ ${lanEndpointText}${' '.repeat(boxWidth - lanEndpointText.length - 2)} β•‘`; - + const lanHealthText = ` - health: http://${ip}:${this.config.port}/health`; const lanHealthLine = `β•‘ ${lanHealthText}${' '.repeat(boxWidth - lanHealthText.length - 2)} β•‘`; - + return `${lanTitleLine}\n${lanEndpointLine}\n${lanHealthLine}`; }).join('\n'); - + // human-readable banner; the structured startup event is logged below console.log(` ╔══════════════════════════════════════════════════════════╗ diff --git a/packages/transfer-server/src/utils/RemoteApiProxyBase.ts b/packages/transfer-server/src/utils/RemoteApiProxyBase.ts index d3d1350..02852dc 100644 --- a/packages/transfer-server/src/utils/RemoteApiProxyBase.ts +++ b/packages/transfer-server/src/utils/RemoteApiProxyBase.ts @@ -1,21 +1,19 @@ -/* eslint-disable no-restricted-syntax */ -/* eslint-disable @typescript-eslint/no-unsafe-return, @typescript-eslint/no-unsafe-member-access */ - import { isMethodAllowed } from '../decorators/e2eeApiMethod'; import { E2eeError, E2eeErrorCode } from '../errors'; import type { IRoomManagerContext } from '../roomManager'; import type { IJsonRpcRequest } from '@onekeyfe/cross-inpage-provider-types'; -export function buildCallRemoteApiMethod( +export function buildCallRemoteApiMethod< + T extends IJsonRpcRequest & { module?: string }, +>( moduleGetter: (module: any) => Promise, remoteApiType: 'e2eeServerApi', context: IRoomManagerContext, ) { return async function callRemoteApiMethod(message: T) { const { method, params = [] } = message; - // @ts-ignore - const module = message?.module as any; + const module = message?.module; if (!module) { throw new E2eeError(E2eeErrorCode.MODULE_REQUIRED, 'callRemoteApiMethod ERROR: module is required'); } @@ -29,10 +27,8 @@ export function buildCallRemoteApiMethod( ); } - // eslint-disable-next-line @typescript-eslint/no-unsafe-call const result = await moduleInstance[method]( - // @ts-ignore - ...[].concat(params as any[]), + ...(Array.isArray(params) ? params : [params]), context, ); return result; @@ -69,7 +65,6 @@ abstract class RemoteApiProxyBase { async callRemoteMethod(key: string, ...params: any[]) { this.checkEnvAvailable(); - // eslint-disable-next-line @typescript-eslint/await-thenable await this.checkEnvAvailable(); // make this method to promise, so that background won't crash if error occurs diff --git a/packages/transfer-server/src/utils/bufferUtils.ts b/packages/transfer-server/src/utils/bufferUtils.ts index 5dd3522..74d7bc2 100644 --- a/packages/transfer-server/src/utils/bufferUtils.ts +++ b/packages/transfer-server/src/utils/bufferUtils.ts @@ -1,5 +1,3 @@ -/* eslint-disable no-restricted-syntax */ - import { bytesToHex as bytesToHex0, hexToBytes, @@ -17,7 +15,6 @@ function toBuffer( ): Buffer { if (isString(data)) { if (encoding === 'hex') { - // eslint-disable-next-line no-param-reassign data = hexUtils.stripHexPrefix(data); } // buffer from hex string in default diff --git a/packages/transfer-server/src/utils/cacheUtils.ts b/packages/transfer-server/src/utils/cacheUtils.ts index 88fd438..9ef22c1 100644 --- a/packages/transfer-server/src/utils/cacheUtils.ts +++ b/packages/transfer-server/src/utils/cacheUtils.ts @@ -5,7 +5,6 @@ import cache from 'memoizee'; export type IMemoizeeOptions = cache.Options; export const memoizee: typeof cache = (f, options) => { - // eslint-disable-next-line @typescript-eslint/unbound-method let { normalizer } = options ?? {}; if (!normalizer) { normalizer = (...args) => { diff --git a/packages/transfer-server/src/utils/cryptoUtils.ts b/packages/transfer-server/src/utils/cryptoUtils.ts index a1148d3..4f076be 100644 --- a/packages/transfer-server/src/utils/cryptoUtils.ts +++ b/packages/transfer-server/src/utils/cryptoUtils.ts @@ -1,4 +1,3 @@ -/* eslint-disable no-restricted-syntax */ import crypto from 'crypto'; // TODO use node native module @@ -246,7 +245,6 @@ export default class CryptoUtils { let result = 0; for (let i = 0; i < a.length; i += 1) { - // eslint-disable-next-line no-bitwise result |= a.charCodeAt(i) ^ b.charCodeAt(i); } diff --git a/packages/transfer-server/src/utils/logger.ts b/packages/transfer-server/src/utils/logger.ts index f918dea..78706eb 100644 --- a/packages/transfer-server/src/utils/logger.ts +++ b/packages/transfer-server/src/utils/logger.ts @@ -13,7 +13,7 @@ const LOG_PRETTY = function createPrettyStream(): DestinationStream | undefined { try { - // eslint-disable-next-line @typescript-eslint/no-var-requires, global-require, @typescript-eslint/no-unsafe-assignment + // eslint-disable-next-line @typescript-eslint/no-require-imports -- Load the optional development dependency lazily. const pretty = require('pino-pretty') as ( options: Record, ) => DestinationStream; diff --git a/packages/transfer-server/src/utils/stringUtils.ts b/packages/transfer-server/src/utils/stringUtils.ts index 443b7f8..d9ea26b 100644 --- a/packages/transfer-server/src/utils/stringUtils.ts +++ b/packages/transfer-server/src/utils/stringUtils.ts @@ -1,4 +1,3 @@ -/* eslint-disable no-restricted-syntax */ import { E2eeError, E2eeErrorCode } from '../errors'; function addSeparatorToString({ str, diff --git a/packages/transfer-server/src/utils/timerUtils.ts b/packages/transfer-server/src/utils/timerUtils.ts index 623f906..0494683 100644 --- a/packages/transfer-server/src/utils/timerUtils.ts +++ b/packages/transfer-server/src/utils/timerUtils.ts @@ -47,7 +47,6 @@ const timeout = (p: Promise, ms: number, message?: string) => p.then((value) => resolve(value)).catch((err) => reject(err)); }); -// eslint-disable-next-line @typescript-eslint/no-unused-vars const sleepUntil = ({ conditionFn, until, diff --git a/packages/transfer-server/test/smoke.ts b/packages/transfer-server/test/smoke.ts index 6061b70..dea92ef 100644 --- a/packages/transfer-server/test/smoke.ts +++ b/packages/transfer-server/test/smoke.ts @@ -65,11 +65,9 @@ async function startServer(): Promise { if (server.exitCode !== null) { throw new Error(`server exited during startup:\n${output.join('')}`); } - // eslint-disable-next-line no-await-in-loop if ((await health()) === 200) { return server; } - // eslint-disable-next-line no-await-in-loop await wait(250); } @@ -194,7 +192,6 @@ async function deliver( : (received?.data?.result as string); return carried === token; } - // eslint-disable-next-line no-await-in-loop await wait(100); } return false; @@ -206,7 +203,6 @@ async function waitFor(read: () => T | undefined, timeoutMs = 3000): Promise< if (value !== undefined) { return value; } - // eslint-disable-next-line no-await-in-loop await wait(100); } return undefined; diff --git a/yarn.lock b/yarn.lock index c1925e8..ea5c7ba 100644 --- a/yarn.lock +++ b/yarn.lock @@ -15,15 +15,6 @@ __metadata: languageName: node linkType: hard -"@babel/code-frame@npm:7.12.11": - version: 7.12.11 - resolution: "@babel/code-frame@npm:7.12.11" - dependencies: - "@babel/highlight": "npm:^7.10.4" - checksum: 10c0/836ffd155506768e991d6dd8c51db37cad5958ed1c8e0a2329ccd9527165d5c752e943d66a5c3c92ffd45f343419f0742e7636629a529f4fbd5303e3637746b9 - languageName: node - linkType: hard - "@babel/code-frame@npm:^7.0.0, @babel/code-frame@npm:^7.12.13, @babel/code-frame@npm:^7.27.1": version: 7.27.1 resolution: "@babel/code-frame@npm:7.27.1" @@ -135,7 +126,7 @@ __metadata: languageName: node linkType: hard -"@babel/helper-validator-identifier@npm:^7.25.9, @babel/helper-validator-identifier@npm:^7.27.1": +"@babel/helper-validator-identifier@npm:^7.27.1": version: 7.27.1 resolution: "@babel/helper-validator-identifier@npm:7.27.1" checksum: 10c0/c558f11c4871d526498e49d07a84752d1800bf72ac0d3dad100309a2eaba24efbf56ea59af5137ff15e3a00280ebe588560534b0e894a4750f8b1411d8f78b84 @@ -159,18 +150,6 @@ __metadata: languageName: node linkType: hard -"@babel/highlight@npm:^7.10.4": - version: 7.25.9 - resolution: "@babel/highlight@npm:7.25.9" - dependencies: - "@babel/helper-validator-identifier": "npm:^7.25.9" - chalk: "npm:^2.4.2" - js-tokens: "npm:^4.0.0" - picocolors: "npm:^1.0.0" - checksum: 10c0/ae0ed93c151b85a07df42936117fa593ce91563a22dfc8944a90ae7088c9679645c33e00dcd20b081c1979665d65f986241172dae1fc9e5922692fc3ff685a49 - languageName: node - linkType: hard - "@babel/parser@npm:^7.1.0, @babel/parser@npm:^7.14.7, @babel/parser@npm:^7.20.7, @babel/parser@npm:^7.23.9, @babel/parser@npm:^7.27.2, @babel/parser@npm:^7.28.0": version: 7.28.0 resolution: "@babel/parser@npm:7.28.0" @@ -412,6 +391,28 @@ __metadata: languageName: node linkType: hard +"@cacheable/memory@npm:^2.2.0": + version: 2.2.0 + resolution: "@cacheable/memory@npm:2.2.0" + dependencies: + "@cacheable/utils": "npm:^2.5.0" + "@keyv/bigmap": "npm:^1.3.1" + hookified: "npm:^1.15.1" + keyv: "npm:^5.6.0" + checksum: 10c0/c74889eb09871be0696065778fff013a498ddc73b538d113c2cb6ece361746f2959fbddfa4ce03ce53c9259998be05949e393212e72413dbd07616744eabd261 + languageName: node + linkType: hard + +"@cacheable/utils@npm:^2.5.0": + version: 2.5.0 + resolution: "@cacheable/utils@npm:2.5.0" + dependencies: + hashery: "npm:^1.5.1" + keyv: "npm:^5.6.0" + checksum: 10c0/99e901accd71eeb0bce91484d6c66d7ef2c731b541561eb5dc9ea7fed78bdc0b65e5dd2a80edde50191143bcfc5f238fc4742475b32e82d43b43eea224038e35 + languageName: node + linkType: hard + "@cspotcode/source-map-support@npm:^0.8.0": version: 0.8.1 resolution: "@cspotcode/source-map-support@npm:0.8.1" @@ -428,38 +429,79 @@ __metadata: languageName: node linkType: hard -"@eslint-community/eslint-utils@npm:^4.2.0": - version: 4.7.0 - resolution: "@eslint-community/eslint-utils@npm:4.7.0" +"@eslint-community/eslint-utils@npm:^4.8.0, @eslint-community/eslint-utils@npm:^4.9.1": + version: 4.10.1 + resolution: "@eslint-community/eslint-utils@npm:4.10.1" dependencies: eslint-visitor-keys: "npm:^3.4.3" peerDependencies: eslint: ^6.0.0 || ^7.0.0 || >=8.0.0 - checksum: 10c0/c0f4f2bd73b7b7a9de74b716a664873d08ab71ab439e51befe77d61915af41a81ecec93b408778b3a7856185244c34c2c8ee28912072ec14def84ba2dec70adf + checksum: 10c0/b514586655698bc6b74db72a496c77e813c78b63e36a83429845ac7057dd77113b0b6c31e6e590d5baaeee2abae6ea22363a41209bcafa078d895ab83ce83011 languageName: node linkType: hard -"@eslint-community/regexpp@npm:^4.4.0": - version: 4.12.1 - resolution: "@eslint-community/regexpp@npm:4.12.1" - checksum: 10c0/a03d98c246bcb9109aec2c08e4d10c8d010256538dcb3f56610191607214523d4fb1b00aa81df830b6dffb74c5fa0be03642513a289c567949d3e550ca11cdf6 +"@eslint-community/regexpp@npm:^4.12.2": + version: 4.12.2 + resolution: "@eslint-community/regexpp@npm:4.12.2" + checksum: 10c0/fddcbc66851b308478d04e302a4d771d6917a0b3740dc351513c0da9ca2eab8a1adf99f5e0aa7ab8b13fa0df005c81adeee7e63a92f3effd7d367a163b721c2d languageName: node linkType: hard -"@eslint/eslintrc@npm:^0.4.3": - version: 0.4.3 - resolution: "@eslint/eslintrc@npm:0.4.3" +"@eslint/config-array@npm:^0.23.5": + version: 0.23.5 + resolution: "@eslint/config-array@npm:0.23.5" dependencies: - ajv: "npm:^6.12.4" - debug: "npm:^4.1.1" - espree: "npm:^7.3.0" - globals: "npm:^13.9.0" - ignore: "npm:^4.0.6" - import-fresh: "npm:^3.2.1" - js-yaml: "npm:^3.13.1" - minimatch: "npm:^3.0.4" - strip-json-comments: "npm:^3.1.1" - checksum: 10c0/0eed93369f72ef044686d07824742121f9b95153ff34f4614e4e69d64332ee68c84eb70da851a9005bb76b3d1d64ad76c2e6293a808edc0f7dfb883689ca136d + "@eslint/object-schema": "npm:^3.0.5" + debug: "npm:^4.3.1" + minimatch: "npm:^10.2.4" + checksum: 10c0/b24833c4c76e78ee075d306cd3f095db46b2db0f90cc13a6ee6e4275f9889731c05bf5403ab5fefb79c756e07ac9184ed0e04570341382f9eccbccc80e6d1a0c + languageName: node + linkType: hard + +"@eslint/config-helpers@npm:^0.7.0": + version: 0.7.0 + resolution: "@eslint/config-helpers@npm:0.7.0" + dependencies: + "@eslint/core": "npm:^1.2.1" + checksum: 10c0/fd40d57d6f1db49f7b647048b88a433dc7f6522ef3edf855a43cb526ef4fc40622ceed0dc8de2e03d254f30f8e035370570de1d4bd8e7c2b1200131451e0d331 + languageName: node + linkType: hard + +"@eslint/core@npm:^1.2.1": + version: 1.2.1 + resolution: "@eslint/core@npm:1.2.1" + dependencies: + "@types/json-schema": "npm:^7.0.15" + checksum: 10c0/10979b40588ecfef771fcb5013a542a35fb30692cc95a65f3481b0b36fbd89f5679efeb30d57f4eed35203d859aabace2a620177d6c536f71b299a1af2f3398f + languageName: node + linkType: hard + +"@eslint/js@npm:^10.0.1": + version: 10.0.1 + resolution: "@eslint/js@npm:10.0.1" + peerDependencies: + eslint: ^10.0.0 + peerDependenciesMeta: + eslint: + optional: true + checksum: 10c0/9f3fcaf71ba7fdf65d82e8faad6ecfe97e11801cc3c362b306a88ea1ed1344ae0d35330dddb0e8ad18f010f6687a70b75491b9e01c8af57acd7987cee6b3ec6c + languageName: node + linkType: hard + +"@eslint/object-schema@npm:^3.0.5": + version: 3.0.5 + resolution: "@eslint/object-schema@npm:3.0.5" + checksum: 10c0/1db337431f520b99e9edda64ef5fafd7ec6a029843eeb608753025125b6649d861d843cffafafd3c4e37926d7d5f9ec0c6a8e3665c13c3da2144e8132892e92e + languageName: node + linkType: hard + +"@eslint/plugin-kit@npm:^0.7.3": + version: 0.7.3 + resolution: "@eslint/plugin-kit@npm:0.7.3" + dependencies: + "@eslint/core": "npm:^1.2.1" + levn: "npm:^0.4.1" + checksum: 10c0/2daebaedcfbd261478e8b87dfb46d3118f48277c19eb298483ebc6f3b438e9688b86ddca13b3aeb08dfb06be00fb6073506852386aa124645c1170e7e33d140c languageName: node linkType: hard @@ -486,21 +528,44 @@ __metadata: languageName: node linkType: hard -"@humanwhocodes/config-array@npm:^0.5.0": - version: 0.5.0 - resolution: "@humanwhocodes/config-array@npm:0.5.0" +"@humanfs/core@npm:^0.19.2": + version: 0.19.2 + resolution: "@humanfs/core@npm:0.19.2" dependencies: - "@humanwhocodes/object-schema": "npm:^1.2.0" - debug: "npm:^4.1.1" - minimatch: "npm:^3.0.4" - checksum: 10c0/217fac9e03492361825a2bf761d4bb7ec6d10002a10f7314142245eb13ac9d123523d24d5619c3c4159af215c7b3e583ed386108e227014bef4efbf9caca8ccc + "@humanfs/types": "npm:^0.15.0" + checksum: 10c0/d0a1d52d7b30c27d49475a53072d1510b81c5803e44b342fb8faf3887f1aa27593a1e6dc76a45268e7892d3f4e198146659281f6b6d55eacf3fd5a38bac30c5c languageName: node linkType: hard -"@humanwhocodes/object-schema@npm:^1.2.0": - version: 1.2.1 - resolution: "@humanwhocodes/object-schema@npm:1.2.1" - checksum: 10c0/c3c35fdb70c04a569278351c75553e293ae339684ed75895edc79facc7276e351115786946658d78133130c0cca80e57e2203bc07f8fa7fe7980300e8deef7db +"@humanfs/node@npm:^0.16.6": + version: 0.16.8 + resolution: "@humanfs/node@npm:0.16.8" + dependencies: + "@humanfs/core": "npm:^0.19.2" + "@humanfs/types": "npm:^0.15.0" + "@humanwhocodes/retry": "npm:^0.4.0" + checksum: 10c0/56140579db811af4e160b195d45d0f29acf644d192c93fe24c9e594ebf06f19dfc157494a07c84540b8a071c0e4b37209c2362765d31734f4d0be869c2422e25 + languageName: node + linkType: hard + +"@humanfs/types@npm:^0.15.0": + version: 0.15.0 + resolution: "@humanfs/types@npm:0.15.0" + checksum: 10c0/fc26b9a024b0e55f7eaf64036df94345bf5d36d6a41ef80ef38e78f1f7430ce26cf435af736adae58913baae18eac3f38c18739054a3d379102015978eae862e + languageName: node + linkType: hard + +"@humanwhocodes/module-importer@npm:^1.0.1": + version: 1.0.1 + resolution: "@humanwhocodes/module-importer@npm:1.0.1" + checksum: 10c0/909b69c3b86d482c26b3359db16e46a32e0fb30bd306a3c176b8313b9e7313dba0f37f519de6aa8b0a1921349e505f259d19475e123182416a506d7f87e7f529 + languageName: node + linkType: hard + +"@humanwhocodes/retry@npm:^0.4.0, @humanwhocodes/retry@npm:^0.4.2": + version: 0.4.3 + resolution: "@humanwhocodes/retry@npm:0.4.3" + checksum: 10c0/3775bb30087d4440b3f7406d5a057777d90e4b9f435af488a4923ef249e93615fb78565a85f173a186a076c7706a81d0d57d563a2624e4de2c5c9c66c486ce42 languageName: node linkType: hard @@ -821,6 +886,25 @@ __metadata: languageName: node linkType: hard +"@keyv/bigmap@npm:^1.3.1": + version: 1.3.1 + resolution: "@keyv/bigmap@npm:1.3.1" + dependencies: + hashery: "npm:^1.4.0" + hookified: "npm:^1.15.0" + peerDependencies: + keyv: ^5.6.0 + checksum: 10c0/acc6a4a5edf462ce23e95672ab4bfaf7cd1941dff6bf3a2f671ce467961ace1fac9d3eb75a9ed9a8e92012e00a7d8b16ad1677bc539a52c3ad0cec31473e2349 + languageName: node + linkType: hard + +"@keyv/serialize@npm:^1.1.1": + version: 1.1.1 + resolution: "@keyv/serialize@npm:1.1.1" + checksum: 10c0/b0008cae4a54400c3abf587b8cc2474c6f528ee58969ce6cf9cb07a04006f80c73c85971d6be6544408318a2bc40108236a19a82aea0a6de95aae49533317374 + languageName: node + linkType: hard + "@koa/router@npm:^12.0.0": version: 12.0.2 resolution: "@koa/router@npm:12.0.2" @@ -1239,13 +1323,9 @@ __metadata: dependencies: "@types/jest": "npm:^29.0.0" "@types/lodash": "npm:^4.14.200" - "@typescript-eslint/eslint-plugin": "npm:^5.0.0" - "@typescript-eslint/parser": "npm:^5.0.0" - eslint: "npm:^7.32.0" - eslint-config-prettier: "npm:^8.5.0" - eslint-plugin-prettier: "npm:^4.0.0" + eslint: "npm:^10.10.0" jest: "npm:^29.0.0" - lodash: "npm:^4.17.21" + lodash: "npm:^4.18.1" prettier: "npm:^2.8.8" ts-jest: "npm:^29.0.0" typescript: "npm:^5.0.0" @@ -1272,10 +1352,8 @@ __metadata: "@midwayjs/validate": "npm:^3.20.12" "@types/jest": "npm:^29.0.0" "@types/node": "npm:^20.0.0" - "@typescript-eslint/eslint-plugin": "npm:^5.0.0" - "@typescript-eslint/parser": "npm:^5.0.0" cross-env: "npm:^10.0.0" - eslint: "npm:^7.32.0" + eslint: "npm:^10.10.0" jest: "npm:^29.0.0" mongoose: "npm:^8.17.1" nodemon: "npm:^3.0.0" @@ -1288,12 +1366,18 @@ __metadata: version: 0.0.0-use.local resolution: "@onekeyhq/monorepo@workspace:." dependencies: + "@eslint/js": "npm:^10.0.1" "@midwayjs/core": "npm:^3.20.11" "@midwayjs/decorator": "npm:^3.20.11" "@midwayjs/logger": "npm:^3.4.2" "@midwayjs/validate": "npm:^3.20.12" + eslint: "npm:^10.10.0" + eslint-config-prettier: "npm:^10.1.8" + globals: "npm:^17.12.0" mongoose: "npm:^8.17.1" rimraf: "npm:^5.0.5" + typescript: "npm:^5.0.0" + typescript-eslint: "npm:^8.70.0" languageName: unknown linkType: soft @@ -1311,9 +1395,10 @@ __metadata: "@types/node": "npm:^20.0.0" asmcrypto.js: "npm:^2.3.2" cors: "npm:^2.8.5" + eslint: "npm:^10.10.0" express: "npm:^4.18.2" fast-json-stable-stringify: "npm:^2.1.0" - lodash: "npm:^4.17.21" + lodash: "npm:^4.18.1" lru-cache: "npm:^10.0.0" memoizee: "npm:^0.4.15" nanoid: "npm:^5.0.4" @@ -1538,6 +1623,20 @@ __metadata: languageName: node linkType: hard +"@types/esrecurse@npm:^4.3.1": + version: 4.3.1 + resolution: "@types/esrecurse@npm:4.3.1" + checksum: 10c0/90dad74d5da3ad27606d8e8e757322f33171cfeaa15ad558b615cf71bb2a516492d18f55f4816384685a3eb2412142e732bbae9a4a7cd2cf3deb7572aa4ebe03 + languageName: node + linkType: hard + +"@types/estree@npm:^1.0.6, @types/estree@npm:^1.0.8": + version: 1.0.9 + resolution: "@types/estree@npm:1.0.9" + checksum: 10c0/3ad3286ca2988cd550dafb8f2ad599c8474868e954fa601a36655bdfefd8039f7c714b8c1c7f2ae219ffbd58bd4660e66fa7479a0120fc02d4777057d4865387 + languageName: node + linkType: hard + "@types/express-serve-static-core@npm:^4.17.33": version: 4.19.6 resolution: "@types/express-serve-static-core@npm:4.19.6" @@ -1653,7 +1752,7 @@ __metadata: languageName: node linkType: hard -"@types/json-schema@npm:^7.0.9": +"@types/json-schema@npm:^7.0.15": version: 7.0.15 resolution: "@types/json-schema@npm:7.0.15" checksum: 10c0/a996a745e6c5d60292f36731dd41341339d4eeed8180bb09226e5c8d23759067692b1d88e5d91d72ee83dfc00d3aca8e7bd43ea120516c17922cbcb7c3e252db @@ -1789,13 +1888,6 @@ __metadata: languageName: node linkType: hard -"@types/semver@npm:^7.3.12": - version: 7.7.0 - resolution: "@types/semver@npm:7.7.0" - checksum: 10c0/6b5f65f647474338abbd6ee91a6bbab434662ddb8fe39464edcbcfc96484d388baad9eb506dff217b6fc1727a88894930eb1f308617161ac0f376fe06be4e1ee - languageName: node - linkType: hard - "@types/send@npm:*": version: 0.17.5 resolution: "@types/send@npm:0.17.5" @@ -1887,124 +1979,138 @@ __metadata: languageName: node linkType: hard -"@typescript-eslint/eslint-plugin@npm:^5.0.0": - version: 5.62.0 - resolution: "@typescript-eslint/eslint-plugin@npm:5.62.0" +"@typescript-eslint/eslint-plugin@npm:8.70.0": + version: 8.70.0 + resolution: "@typescript-eslint/eslint-plugin@npm:8.70.0" dependencies: - "@eslint-community/regexpp": "npm:^4.4.0" - "@typescript-eslint/scope-manager": "npm:5.62.0" - "@typescript-eslint/type-utils": "npm:5.62.0" - "@typescript-eslint/utils": "npm:5.62.0" - debug: "npm:^4.3.4" - graphemer: "npm:^1.4.0" - ignore: "npm:^5.2.0" - natural-compare-lite: "npm:^1.4.0" - semver: "npm:^7.3.7" - tsutils: "npm:^3.21.0" + "@eslint-community/regexpp": "npm:^4.12.2" + "@typescript-eslint/scope-manager": "npm:8.70.0" + "@typescript-eslint/type-utils": "npm:8.70.0" + "@typescript-eslint/utils": "npm:8.70.0" + "@typescript-eslint/visitor-keys": "npm:8.70.0" + ignore: "npm:^7.0.5" + natural-compare: "npm:^1.4.0" + ts-api-utils: "npm:^2.5.0" peerDependencies: - "@typescript-eslint/parser": ^5.0.0 - eslint: ^6.0.0 || ^7.0.0 || ^8.0.0 - peerDependenciesMeta: - typescript: - optional: true - checksum: 10c0/3f40cb6bab5a2833c3544e4621b9fdacd8ea53420cadc1c63fac3b89cdf5c62be1e6b7bcf56976dede5db4c43830de298ced3db60b5494a3b961ca1b4bff9f2a + "@typescript-eslint/parser": ^8.70.0 + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: ">=4.8.4 <6.1.0" + checksum: 10c0/e69f6f697493f4b5cb0411498edd63d75afb3e6881c632f9b23c416e64dd06458956ae489a253f0d08b90645b935a32a509d1e2b17068974a2a34a5c2b3f334b languageName: node linkType: hard -"@typescript-eslint/parser@npm:^5.0.0": - version: 5.62.0 - resolution: "@typescript-eslint/parser@npm:5.62.0" +"@typescript-eslint/parser@npm:8.70.0": + version: 8.70.0 + resolution: "@typescript-eslint/parser@npm:8.70.0" dependencies: - "@typescript-eslint/scope-manager": "npm:5.62.0" - "@typescript-eslint/types": "npm:5.62.0" - "@typescript-eslint/typescript-estree": "npm:5.62.0" - debug: "npm:^4.3.4" + "@typescript-eslint/scope-manager": "npm:8.70.0" + "@typescript-eslint/types": "npm:8.70.0" + "@typescript-eslint/typescript-estree": "npm:8.70.0" + "@typescript-eslint/visitor-keys": "npm:8.70.0" + debug: "npm:^4.4.3" peerDependencies: - eslint: ^6.0.0 || ^7.0.0 || ^8.0.0 - peerDependenciesMeta: - typescript: - optional: true - checksum: 10c0/315194b3bf39beb9bd16c190956c46beec64b8371e18d6bb72002108b250983eb1e186a01d34b77eb4045f4941acbb243b16155fbb46881105f65e37dc9e24d4 + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: ">=4.8.4 <6.1.0" + checksum: 10c0/6d35e0cad56902295599c3da7e4f4c97ebf6f19fcc4333100ad05ef757ba9c431db9cdbd886b6748eb88afd0bc64f8c1c7a81b37589bbd4cc346f6e9eb4e3d12 languageName: node linkType: hard -"@typescript-eslint/scope-manager@npm:5.62.0": - version: 5.62.0 - resolution: "@typescript-eslint/scope-manager@npm:5.62.0" +"@typescript-eslint/project-service@npm:8.70.0": + version: 8.70.0 + resolution: "@typescript-eslint/project-service@npm:8.70.0" dependencies: - "@typescript-eslint/types": "npm:5.62.0" - "@typescript-eslint/visitor-keys": "npm:5.62.0" - checksum: 10c0/861253235576c1c5c1772d23cdce1418c2da2618a479a7de4f6114a12a7ca853011a1e530525d0931c355a8fd237b9cd828fac560f85f9623e24054fd024726f + "@typescript-eslint/tsconfig-utils": "npm:^8.70.0" + "@typescript-eslint/types": "npm:^8.70.0" + debug: "npm:^4.4.3" + peerDependencies: + typescript: ">=4.8.4 <6.1.0" + checksum: 10c0/eb7d0b4c91015e9aa47672ba20dfda0717e5fa09afa7e28f25b5d7b7b0cd8af4b394f94c3a1528358a7d8c8be4165ed4291a345cbe19bb137a24c6a4f006b398 languageName: node linkType: hard -"@typescript-eslint/type-utils@npm:5.62.0": - version: 5.62.0 - resolution: "@typescript-eslint/type-utils@npm:5.62.0" +"@typescript-eslint/scope-manager@npm:8.70.0": + version: 8.70.0 + resolution: "@typescript-eslint/scope-manager@npm:8.70.0" dependencies: - "@typescript-eslint/typescript-estree": "npm:5.62.0" - "@typescript-eslint/utils": "npm:5.62.0" - debug: "npm:^4.3.4" - tsutils: "npm:^3.21.0" + "@typescript-eslint/types": "npm:8.70.0" + "@typescript-eslint/visitor-keys": "npm:8.70.0" + checksum: 10c0/ca05825fb16266d90e73bd1745407f719fc6d5fdb33045cdae21d3259f13c9b54d8b956d5c64a3eb83baff7690527fb16d01a2d7318379f97fb79a1cbf8ca103 + languageName: node + linkType: hard + +"@typescript-eslint/tsconfig-utils@npm:8.70.0, @typescript-eslint/tsconfig-utils@npm:^8.70.0": + version: 8.70.0 + resolution: "@typescript-eslint/tsconfig-utils@npm:8.70.0" peerDependencies: - eslint: "*" - peerDependenciesMeta: - typescript: - optional: true - checksum: 10c0/93112e34026069a48f0484b98caca1c89d9707842afe14e08e7390af51cdde87378df29d213d3bbd10a7cfe6f91b228031b56218515ce077bdb62ddea9d9f474 + typescript: ">=4.8.4 <6.1.0" + checksum: 10c0/925df7c1fbb4e8050dda3fa3dad4a6f515a1f7f2b43f5c145d9eaac856adb84bba04eb5038cc0f78c2307c1df4e2d64dbb4e2e47abffa4844e25cfe62ce402cf + languageName: node + linkType: hard + +"@typescript-eslint/type-utils@npm:8.70.0": + version: 8.70.0 + resolution: "@typescript-eslint/type-utils@npm:8.70.0" + dependencies: + "@typescript-eslint/types": "npm:8.70.0" + "@typescript-eslint/typescript-estree": "npm:8.70.0" + "@typescript-eslint/utils": "npm:8.70.0" + debug: "npm:^4.4.3" + ts-api-utils: "npm:^2.5.0" + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: ">=4.8.4 <6.1.0" + checksum: 10c0/b745dc16a9ae50fc1bf7880388425d05d7d1775d23819dfcbdbe5930829fb426d7a011a9848494f427f61e236a4deeb9e8f0fd60db4b7a717a791c8454d63584 languageName: node linkType: hard -"@typescript-eslint/types@npm:5.62.0": - version: 5.62.0 - resolution: "@typescript-eslint/types@npm:5.62.0" - checksum: 10c0/7febd3a7f0701c0b927e094f02e82d8ee2cada2b186fcb938bc2b94ff6fbad88237afc304cbaf33e82797078bbbb1baf91475f6400912f8b64c89be79bfa4ddf +"@typescript-eslint/types@npm:8.70.0, @typescript-eslint/types@npm:^8.70.0": + version: 8.70.0 + resolution: "@typescript-eslint/types@npm:8.70.0" + checksum: 10c0/e3f98d0c0e708fcadebb3b457bf3f38a2512220b6bc9823212cacf310377539fb3c24f9e9e754d13b611a3ac6028a0bba16d02eeef03198c65e7ba605bb2036d languageName: node linkType: hard -"@typescript-eslint/typescript-estree@npm:5.62.0": - version: 5.62.0 - resolution: "@typescript-eslint/typescript-estree@npm:5.62.0" +"@typescript-eslint/typescript-estree@npm:8.70.0": + version: 8.70.0 + resolution: "@typescript-eslint/typescript-estree@npm:8.70.0" dependencies: - "@typescript-eslint/types": "npm:5.62.0" - "@typescript-eslint/visitor-keys": "npm:5.62.0" - debug: "npm:^4.3.4" - globby: "npm:^11.1.0" - is-glob: "npm:^4.0.3" - semver: "npm:^7.3.7" - tsutils: "npm:^3.21.0" - peerDependenciesMeta: - typescript: - optional: true - checksum: 10c0/d7984a3e9d56897b2481940ec803cb8e7ead03df8d9cfd9797350be82ff765dfcf3cfec04e7355e1779e948da8f02bc5e11719d07a596eb1cb995c48a95e38cf + "@typescript-eslint/project-service": "npm:8.70.0" + "@typescript-eslint/tsconfig-utils": "npm:8.70.0" + "@typescript-eslint/types": "npm:8.70.0" + "@typescript-eslint/visitor-keys": "npm:8.70.0" + debug: "npm:^4.4.3" + minimatch: "npm:^10.2.2" + semver: "npm:^7.7.3" + tinyglobby: "npm:^0.2.15" + ts-api-utils: "npm:^2.5.0" + peerDependencies: + typescript: ">=4.8.4 <6.1.0" + checksum: 10c0/11e6c9c0108184902bc229376cce00eb81ee2b88a7cc3bf02a8c159bf59b2c7af21d2b330976b1751fedd5c27e257731b0d60840268299582c458c2a0a29525e languageName: node linkType: hard -"@typescript-eslint/utils@npm:5.62.0": - version: 5.62.0 - resolution: "@typescript-eslint/utils@npm:5.62.0" +"@typescript-eslint/utils@npm:8.70.0": + version: 8.70.0 + resolution: "@typescript-eslint/utils@npm:8.70.0" dependencies: - "@eslint-community/eslint-utils": "npm:^4.2.0" - "@types/json-schema": "npm:^7.0.9" - "@types/semver": "npm:^7.3.12" - "@typescript-eslint/scope-manager": "npm:5.62.0" - "@typescript-eslint/types": "npm:5.62.0" - "@typescript-eslint/typescript-estree": "npm:5.62.0" - eslint-scope: "npm:^5.1.1" - semver: "npm:^7.3.7" + "@eslint-community/eslint-utils": "npm:^4.9.1" + "@typescript-eslint/scope-manager": "npm:8.70.0" + "@typescript-eslint/types": "npm:8.70.0" + "@typescript-eslint/typescript-estree": "npm:8.70.0" peerDependencies: - eslint: ^6.0.0 || ^7.0.0 || ^8.0.0 - checksum: 10c0/f09b7d9952e4a205eb1ced31d7684dd55cee40bf8c2d78e923aa8a255318d97279825733902742c09d8690f37a50243f4c4d383ab16bd7aefaf9c4b438f785e1 + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: ">=4.8.4 <6.1.0" + checksum: 10c0/15353ecd0c5e29057e5846c6b84de601b679448e01293dbf1ae7dbe94ab6d4b586f8690a262630522441307fafad01ab3b0b46a85eb71907c19a2f124d7bba3f languageName: node linkType: hard -"@typescript-eslint/visitor-keys@npm:5.62.0": - version: 5.62.0 - resolution: "@typescript-eslint/visitor-keys@npm:5.62.0" +"@typescript-eslint/visitor-keys@npm:8.70.0": + version: 8.70.0 + resolution: "@typescript-eslint/visitor-keys@npm:8.70.0" dependencies: - "@typescript-eslint/types": "npm:5.62.0" - eslint-visitor-keys: "npm:^3.3.0" - checksum: 10c0/7c3b8e4148e9b94d9b7162a596a1260d7a3efc4e65199693b8025c71c4652b8042501c0bc9f57654c1e2943c26da98c0f77884a746c6ae81389fcb0b513d995d + "@typescript-eslint/types": "npm:8.70.0" + eslint-visitor-keys: "npm:^5.0.0" + checksum: 10c0/43fe28dc1045a686deead8c9334f6e9d8b3fa8f84917147aabd9747783b8bc531254a95a9bfb33355b25cf5ec9a72c62bcde313f13869d7a5e5603d96be593a9 languageName: node linkType: hard @@ -2036,7 +2142,7 @@ __metadata: languageName: node linkType: hard -"acorn-jsx@npm:^5.3.1": +"acorn-jsx@npm:^5.3.2": version: 5.3.2 resolution: "acorn-jsx@npm:5.3.2" peerDependencies: @@ -2054,21 +2160,21 @@ __metadata: languageName: node linkType: hard -"acorn@npm:^7.4.0": - version: 7.4.1 - resolution: "acorn@npm:7.4.1" +"acorn@npm:^8.11.0, acorn@npm:^8.4.1": + version: 8.15.0 + resolution: "acorn@npm:8.15.0" bin: acorn: bin/acorn - checksum: 10c0/bd0b2c2b0f334bbee48828ff897c12bd2eb5898d03bf556dcc8942022cec795ac5bb5b6b585e2de687db6231faf07e096b59a361231dd8c9344d5df5f7f0e526 + checksum: 10c0/dec73ff59b7d6628a01eebaece7f2bdb8bb62b9b5926dcad0f8931f2b8b79c2be21f6c68ac095592adb5adb15831a3635d9343e6a91d028bbe85d564875ec3ec languageName: node linkType: hard -"acorn@npm:^8.11.0, acorn@npm:^8.4.1": - version: 8.15.0 - resolution: "acorn@npm:8.15.0" +"acorn@npm:^8.16.0": + version: 8.18.0 + resolution: "acorn@npm:8.18.0" bin: acorn: bin/acorn - checksum: 10c0/dec73ff59b7d6628a01eebaece7f2bdb8bb62b9b5926dcad0f8931f2b8b79c2be21f6c68ac095592adb5adb15831a3635d9343e6a91d028bbe85d564875ec3ec + checksum: 10c0/be771be2135cc07910cf76f444ad514d7dcfd6d4a8026e597e93155275abc8ef61eee12211d52146e9d962874269b634f397464942087be013c89d0c54c5f8e5 languageName: node linkType: hard @@ -2086,27 +2192,15 @@ __metadata: languageName: node linkType: hard -"ajv@npm:^6.10.0, ajv@npm:^6.12.4": - version: 6.12.6 - resolution: "ajv@npm:6.12.6" +"ajv@npm:^6.14.0": + version: 6.15.0 + resolution: "ajv@npm:6.15.0" dependencies: fast-deep-equal: "npm:^3.1.1" fast-json-stable-stringify: "npm:^2.0.0" json-schema-traverse: "npm:^0.4.1" uri-js: "npm:^4.2.2" - checksum: 10c0/41e23642cbe545889245b9d2a45854ebba51cda6c778ebced9649420d9205f2efb39cb43dbc41e358409223b1ea43303ae4839db682c848b891e4811da1a5a71 - languageName: node - linkType: hard - -"ajv@npm:^8.0.1": - version: 8.17.1 - resolution: "ajv@npm:8.17.1" - dependencies: - fast-deep-equal: "npm:^3.1.3" - fast-uri: "npm:^3.0.1" - json-schema-traverse: "npm:^1.0.0" - require-from-string: "npm:^2.0.2" - checksum: 10c0/ec3ba10a573c6b60f94639ffc53526275917a2df6810e4ab5a6b959d87459f9ef3f00d5e7865b82677cb7d21590355b34da14d1d0b9c32d75f95a187e76fff35 + checksum: 10c0/67966499dd272ecde1c2e467084411132891523d057487587879d39ac04207f4351b7b2324c83198013967fbfa632c1612adc960114a30770fbe07a0773b32c2 languageName: node linkType: hard @@ -2140,15 +2234,6 @@ __metadata: languageName: node linkType: hard -"ansi-styles@npm:^3.2.1": - version: 3.2.1 - resolution: "ansi-styles@npm:3.2.1" - dependencies: - color-convert: "npm:^1.9.0" - checksum: 10c0/ece5a8ef069fcc5298f67e3f4771a663129abd174ea2dfa87923a2be2abf6cd367ef72ac87942da00ce85bd1d651d4cd8595aebdb1b385889b89b205860e977b - languageName: node - linkType: hard - "ansi-styles@npm:^4.0.0, ansi-styles@npm:^4.1.0": version: 4.3.0 resolution: "ansi-styles@npm:4.3.0" @@ -2233,13 +2318,6 @@ __metadata: languageName: node linkType: hard -"astral-regex@npm:^2.0.0": - version: 2.0.0 - resolution: "astral-regex@npm:2.0.0" - checksum: 10c0/f63d439cc383db1b9c5c6080d1e240bd14dae745f15d11ec5da863e182bbeca70df6c8191cffef5deba0b566ef98834610a68be79ac6379c95eeb26e1b310e25 - languageName: node - linkType: hard - "async@npm:^3.2.6": version: 3.2.6 resolution: "async@npm:3.2.6" @@ -2347,6 +2425,13 @@ __metadata: languageName: node linkType: hard +"balanced-match@npm:^4.0.2": + version: 4.0.4 + resolution: "balanced-match@npm:4.0.4" + checksum: 10c0/07e86102a3eb2ee2a6a1a89164f29d0dbaebd28f2ca3f5ca786f36b8b23d9e417eb3be45a4acf754f837be5ac0a2317de90d3fcb7f4f4dc95720a1f36b26a17b + languageName: node + linkType: hard + "base64id@npm:2.0.0, base64id@npm:~2.0.0": version: 2.0.0 resolution: "base64id@npm:2.0.0" @@ -2400,6 +2485,15 @@ __metadata: languageName: node linkType: hard +"brace-expansion@npm:^5.0.8": + version: 5.0.12 + resolution: "brace-expansion@npm:5.0.12" + dependencies: + balanced-match: "npm:^4.0.2" + checksum: 10c0/a40ac64d3bdb5bb222456514d3633c1f627f03b844f509c69e70f98b78be0ce85e21b44bbfecdd0ddd4cc0855ca4242b41ea96ddccf88c7c3c7a84405474bb3a + languageName: node + linkType: hard + "braces@npm:^3.0.3, braces@npm:~3.0.2": version: 3.0.3 resolution: "braces@npm:3.0.3" @@ -2492,6 +2586,19 @@ __metadata: languageName: node linkType: hard +"cacheable@npm:^2.5.0": + version: 2.5.0 + resolution: "cacheable@npm:2.5.0" + dependencies: + "@cacheable/memory": "npm:^2.2.0" + "@cacheable/utils": "npm:^2.5.0" + hookified: "npm:^1.15.0" + keyv: "npm:^5.6.0" + qified: "npm:^0.10.1" + checksum: 10c0/ad4086a60ed327196901c16360312705e35bfde5118457449cfabaf4ece0fecad046985ea8b66b3ece46dd469cb33798d87e1dac3ed2c00746578054bccbf74d + languageName: node + linkType: hard + "call-bind-apply-helpers@npm:^1.0.1, call-bind-apply-helpers@npm:^1.0.2": version: 1.0.2 resolution: "call-bind-apply-helpers@npm:1.0.2" @@ -2540,17 +2647,6 @@ __metadata: languageName: node linkType: hard -"chalk@npm:^2.4.2": - version: 2.4.2 - resolution: "chalk@npm:2.4.2" - dependencies: - ansi-styles: "npm:^3.2.1" - escape-string-regexp: "npm:^1.0.5" - supports-color: "npm:^5.3.0" - checksum: 10c0/e6543f02ec877732e3a2d1c3c3323ddb4d39fbab687c23f526e25bd4c6a9bf3b83a696e8c769d078e04e5754921648f7821b2a2acfd16c550435fd630026e073 - languageName: node - linkType: hard - "chalk@npm:^4.0.0, chalk@npm:^4.1.0, chalk@npm:^4.1.1": version: 4.1.2 resolution: "chalk@npm:4.1.2" @@ -2660,15 +2756,6 @@ __metadata: languageName: node linkType: hard -"color-convert@npm:^1.9.0": - version: 1.9.3 - resolution: "color-convert@npm:1.9.3" - dependencies: - color-name: "npm:1.1.3" - checksum: 10c0/5ad3c534949a8c68fca8fbc6f09068f435f0ad290ab8b2f76841b9e6af7e0bb57b98cb05b0e19fe33f5d91e5a8611ad457e5f69e0a484caad1f7487fd0e8253c - languageName: node - linkType: hard - "color-convert@npm:^2.0.1": version: 2.0.1 resolution: "color-convert@npm:2.0.1" @@ -2678,13 +2765,6 @@ __metadata: languageName: node linkType: hard -"color-name@npm:1.1.3": - version: 1.1.3 - resolution: "color-name@npm:1.1.3" - checksum: 10c0/566a3d42cca25b9b3cd5528cd7754b8e89c0eb646b7f214e8e2eaddb69994ac5f0557d9c175eb5d8f0ad73531140d9c47525085ee752a91a2ab15ab459caf6d6 - languageName: node - linkType: hard - "color-name@npm:~1.1.4": version: 1.1.4 resolution: "color-name@npm:1.1.4" @@ -2844,7 +2924,7 @@ __metadata: languageName: node linkType: hard -"cross-spawn@npm:^7.0.2, cross-spawn@npm:^7.0.3, cross-spawn@npm:^7.0.6": +"cross-spawn@npm:^7.0.3, cross-spawn@npm:^7.0.6": version: 7.0.6 resolution: "cross-spawn@npm:7.0.6" dependencies: @@ -2895,7 +2975,7 @@ __metadata: languageName: node linkType: hard -"debug@npm:4, debug@npm:4.x, debug@npm:^4, debug@npm:^4.0.1, debug@npm:^4.1.0, debug@npm:^4.1.1, debug@npm:^4.3.1, debug@npm:^4.3.2, debug@npm:^4.3.4": +"debug@npm:4, debug@npm:4.x, debug@npm:^4, debug@npm:^4.1.0, debug@npm:^4.1.1, debug@npm:^4.3.1, debug@npm:^4.3.2, debug@npm:^4.3.4": version: 4.4.1 resolution: "debug@npm:4.4.1" dependencies: @@ -2907,6 +2987,18 @@ __metadata: languageName: node linkType: hard +"debug@npm:^4.4.3, debug@npm:~4.4.1": + version: 4.4.3 + resolution: "debug@npm:4.4.3" + dependencies: + ms: "npm:^2.1.3" + peerDependenciesMeta: + supports-color: + optional: true + checksum: 10c0/d79136ec6c83ecbefd0f6a5593da6a9c91ec4d7ddc4b54c883d6e71ec9accb5f67a1a5e96d00a328196b5b5c86d365e98d8a3a70856aaf16b4e7b1985e67f5a6 + languageName: node + linkType: hard + "debug@npm:~4.3.1, debug@npm:~4.3.2, debug@npm:~4.3.4": version: 4.3.7 resolution: "debug@npm:4.3.7" @@ -3025,9 +3117,9 @@ __metadata: linkType: hard "diff@npm:^4.0.1": - version: 4.0.2 - resolution: "diff@npm:4.0.2" - checksum: 10c0/81b91f9d39c4eaca068eb0c1eb0e4afbdc5bb2941d197f513dd596b820b956fef43485876226d65d497bebc15666aa2aa82c679e84f65d5f2bfbf14ee46e32c1 + version: 4.0.4 + resolution: "diff@npm:4.0.4" + checksum: 10c0/855fb70b093d1d9643ddc12ea76dca90dc9d9cdd7f82c08ee8b9325c0dc5748faf3c82e2047ced5dcaa8b26e58f7903900be2628d0380a222c02d79d8de385df languageName: node linkType: hard @@ -3040,15 +3132,6 @@ __metadata: languageName: node linkType: hard -"doctrine@npm:^3.0.0": - version: 3.0.0 - resolution: "doctrine@npm:3.0.0" - dependencies: - esutils: "npm:^2.0.2" - checksum: 10c0/c96bdccabe9d62ab6fea9399fdff04a66e6563c1d6fb3a3a063e8d53c3bb136ba63e84250bbf63d00086a769ad53aef92d2bd483f03f837fc97b71cbee6b2520 - languageName: node - linkType: hard - "dunder-proto@npm:^1.0.1": version: 1.0.1 resolution: "dunder-proto@npm:1.0.1" @@ -3182,7 +3265,7 @@ __metadata: languageName: node linkType: hard -"enquirer@npm:^2.3.4, enquirer@npm:^2.3.5": +"enquirer@npm:^2.3.4": version: 2.4.1 resolution: "enquirer@npm:2.4.1" dependencies: @@ -3309,13 +3392,6 @@ __metadata: languageName: node linkType: hard -"escape-string-regexp@npm:^1.0.5": - version: 1.0.5 - resolution: "escape-string-regexp@npm:1.0.5" - checksum: 10c0/a968ad453dd0c2724e14a4f20e177aaf32bb384ab41b674a8454afe9a41c5e6fe8903323e0a1052f56289d04bd600f81278edf140b0fcc02f5cac98d0f5b5371 - languageName: node - linkType: hard - "escape-string-regexp@npm:^2.0.0": version: 2.0.0 resolution: "escape-string-regexp@npm:2.0.0" @@ -3330,119 +3406,85 @@ __metadata: languageName: node linkType: hard -"eslint-config-prettier@npm:^8.5.0": - version: 8.10.2 - resolution: "eslint-config-prettier@npm:8.10.2" +"eslint-config-prettier@npm:^10.1.8": + version: 10.1.8 + resolution: "eslint-config-prettier@npm:10.1.8" peerDependencies: eslint: ">=7.0.0" bin: eslint-config-prettier: bin/cli.js - checksum: 10c0/b5953cf7a86f685e1218b16707bf36643b525513d08495226a6820caccd8b7bfc6b9aa64ac7cb2415dbe2c1f7dc4995832148bdc53ad45777f75a8ded1073b29 - languageName: node - linkType: hard - -"eslint-plugin-prettier@npm:^4.0.0": - version: 4.2.5 - resolution: "eslint-plugin-prettier@npm:4.2.5" - dependencies: - prettier-linter-helpers: "npm:^1.0.0" - peerDependencies: - eslint: ">=7.28.0" - prettier: ">=2.0.0" - peerDependenciesMeta: - eslint-config-prettier: - optional: true - checksum: 10c0/75b3cdc90328aacf4cc7fabc522e651bd8208d40634c9b2772274332a696548136dac4608b141863bc462500c5a8012fbc2495623f684f631ddb62c2f5bca0a3 + checksum: 10c0/e1bcfadc9eccd526c240056b1e59c5cd26544fe59feb85f38f4f1f116caed96aea0b3b87868e68b3099e55caaac3f2e5b9f58110f85db893e83a332751192682 languageName: node linkType: hard -"eslint-scope@npm:^5.1.1": - version: 5.1.1 - resolution: "eslint-scope@npm:5.1.1" +"eslint-scope@npm:^9.1.2": + version: 9.1.2 + resolution: "eslint-scope@npm:9.1.2" dependencies: + "@types/esrecurse": "npm:^4.3.1" + "@types/estree": "npm:^1.0.8" esrecurse: "npm:^4.3.0" - estraverse: "npm:^4.1.1" - checksum: 10c0/d30ef9dc1c1cbdece34db1539a4933fe3f9b14e1ffb27ecc85987902ee663ad7c9473bbd49a9a03195a373741e62e2f807c4938992e019b511993d163450e70a - languageName: node - linkType: hard - -"eslint-utils@npm:^2.1.0": - version: 2.1.0 - resolution: "eslint-utils@npm:2.1.0" - dependencies: - eslint-visitor-keys: "npm:^1.1.0" - checksum: 10c0/69521c5d6569384b24093125d037ba238d3d6e54367f7143af9928f5286369e912c26cad5016d730c0ffb9797ac9e83831059d7f1d863f7dc84330eb02414611 - languageName: node - linkType: hard - -"eslint-visitor-keys@npm:^1.1.0, eslint-visitor-keys@npm:^1.3.0": - version: 1.3.0 - resolution: "eslint-visitor-keys@npm:1.3.0" - checksum: 10c0/10c91fdbbe36810dd4308e57f9a8bc7177188b2a70247e54e3af1fa05ebc66414ae6fd4ce3c6c6821591f43a556e9037bc6b071122e099b5f8b7d2f76df553e3 - languageName: node - linkType: hard - -"eslint-visitor-keys@npm:^2.0.0": - version: 2.1.0 - resolution: "eslint-visitor-keys@npm:2.1.0" - checksum: 10c0/9f0e3a2db751d84067d15977ac4b4472efd6b303e369e6ff241a99feac04da758f46d5add022c33d06b53596038dbae4b4aceb27c7e68b8dfc1055b35e495787 + estraverse: "npm:^5.2.0" + checksum: 10c0/9fb8bca5a73e5741efb6cec84467027b6cb6f4203ff9b43a938e272c5cd30800bde46a5c20dfd1609f840225f0b62b7673be391b20acadf8658ca9fa4729b3dd languageName: node linkType: hard -"eslint-visitor-keys@npm:^3.3.0, eslint-visitor-keys@npm:^3.4.3": +"eslint-visitor-keys@npm:^3.4.3": version: 3.4.3 resolution: "eslint-visitor-keys@npm:3.4.3" checksum: 10c0/92708e882c0a5ffd88c23c0b404ac1628cf20104a108c745f240a13c332a11aac54f49a22d5762efbffc18ecbc9a580d1b7ad034bf5f3cc3307e5cbff2ec9820 languageName: node linkType: hard -"eslint@npm:^7.32.0": - version: 7.32.0 - resolution: "eslint@npm:7.32.0" - dependencies: - "@babel/code-frame": "npm:7.12.11" - "@eslint/eslintrc": "npm:^0.4.3" - "@humanwhocodes/config-array": "npm:^0.5.0" - ajv: "npm:^6.10.0" - chalk: "npm:^4.0.0" - cross-spawn: "npm:^7.0.2" - debug: "npm:^4.0.1" - doctrine: "npm:^3.0.0" - enquirer: "npm:^2.3.5" +"eslint-visitor-keys@npm:^5.0.0, eslint-visitor-keys@npm:^5.0.1": + version: 5.0.1 + resolution: "eslint-visitor-keys@npm:5.0.1" + checksum: 10c0/16190bdf2cbae40a1109384c94450c526a79b0b9c3cb21e544256ed85ac48a4b84db66b74a6561d20fe6ab77447f150d711c2ad5ad74df4fcc133736bce99678 + languageName: node + linkType: hard + +"eslint@npm:^10.10.0": + version: 10.10.0 + resolution: "eslint@npm:10.10.0" + dependencies: + "@eslint-community/eslint-utils": "npm:^4.8.0" + "@eslint-community/regexpp": "npm:^4.12.2" + "@eslint/config-array": "npm:^0.23.5" + "@eslint/config-helpers": "npm:^0.7.0" + "@eslint/core": "npm:^1.2.1" + "@eslint/plugin-kit": "npm:^0.7.3" + "@humanfs/node": "npm:^0.16.6" + "@humanwhocodes/module-importer": "npm:^1.0.1" + "@humanwhocodes/retry": "npm:^0.4.2" + "@types/estree": "npm:^1.0.6" + ajv: "npm:^6.14.0" + cross-spawn: "npm:^7.0.6" + debug: "npm:^4.3.2" escape-string-regexp: "npm:^4.0.0" - eslint-scope: "npm:^5.1.1" - eslint-utils: "npm:^2.1.0" - eslint-visitor-keys: "npm:^2.0.0" - espree: "npm:^7.3.1" - esquery: "npm:^1.4.0" + eslint-scope: "npm:^9.1.2" + eslint-visitor-keys: "npm:^5.0.1" + espree: "npm:^11.2.0" + esquery: "npm:^1.7.0" esutils: "npm:^2.0.2" fast-deep-equal: "npm:^3.1.3" - file-entry-cache: "npm:^6.0.1" - functional-red-black-tree: "npm:^1.0.1" - glob-parent: "npm:^5.1.2" - globals: "npm:^13.6.0" - ignore: "npm:^4.0.6" - import-fresh: "npm:^3.0.0" + file-entry-cache: "npm:11.1.5 || >11.1.6 <12" + find-up: "npm:^5.0.0" + glob-parent: "npm:^6.0.2" + ignore: "npm:^5.2.0" imurmurhash: "npm:^0.1.4" is-glob: "npm:^4.0.0" - js-yaml: "npm:^3.13.1" json-stable-stringify-without-jsonify: "npm:^1.0.1" - levn: "npm:^0.4.1" - lodash.merge: "npm:^4.6.2" - minimatch: "npm:^3.0.4" + minimatch: "npm:^10.2.5" natural-compare: "npm:^1.4.0" - optionator: "npm:^0.9.1" - progress: "npm:^2.0.0" - regexpp: "npm:^3.1.0" - semver: "npm:^7.2.1" - strip-ansi: "npm:^6.0.0" - strip-json-comments: "npm:^3.1.0" - table: "npm:^6.0.9" - text-table: "npm:^0.2.0" - v8-compile-cache: "npm:^2.0.3" + optionator: "npm:^0.9.3" + peerDependencies: + jiti: "*" + peerDependenciesMeta: + jiti: + optional: true bin: eslint: bin/eslint.js - checksum: 10c0/84409f7767556179cb11529f1215f335c7dfccf90419df6147f949f14c347a960c7b569e80ed84011a0b6d10da1ef5046edbbb9b11c3e59aa6696d5217092e93 + checksum: 10c0/ba583145305f088585f91c5dab6825492ec657614558e4610cffc94174cf800f456e39ec3cafd3eed73e04bd9a05794067ae33b553f1323e3e7c1157242409d2 languageName: node linkType: hard @@ -3458,14 +3500,14 @@ __metadata: languageName: node linkType: hard -"espree@npm:^7.3.0, espree@npm:^7.3.1": - version: 7.3.1 - resolution: "espree@npm:7.3.1" +"espree@npm:^11.2.0": + version: 11.2.0 + resolution: "espree@npm:11.2.0" dependencies: - acorn: "npm:^7.4.0" - acorn-jsx: "npm:^5.3.1" - eslint-visitor-keys: "npm:^1.3.0" - checksum: 10c0/f4e81b903f03eaf0e6925cea20571632da427deb6e14ca37e481f72c11f36d7bb4945fe8a2ff15ab22d078d3cd93ee65355fa94de9c27485c356481775f25d85 + acorn: "npm:^8.16.0" + acorn-jsx: "npm:^5.3.2" + eslint-visitor-keys: "npm:^5.0.1" + checksum: 10c0/cf87e18ffd9dc113eb8d16588e7757701bc10c9934a71cce8b89c2611d51672681a918307bd6b19ac3ccd0e7ba1cbccc2f815b36b52fa7e73097b251014c3d81 languageName: node linkType: hard @@ -3479,12 +3521,12 @@ __metadata: languageName: node linkType: hard -"esquery@npm:^1.4.0": - version: 1.6.0 - resolution: "esquery@npm:1.6.0" +"esquery@npm:^1.7.0": + version: 1.7.0 + resolution: "esquery@npm:1.7.0" dependencies: estraverse: "npm:^5.1.0" - checksum: 10c0/cb9065ec605f9da7a76ca6dadb0619dfb611e37a81e318732977d90fab50a256b95fee2d925fba7c2f3f0523aa16f91587246693bc09bc34d5a59575fe6e93d2 + checksum: 10c0/77d5173db450b66f3bc685d11af4c90cffeedb340f34a39af96d43509a335ce39c894fd79233df32d38f5e4e219fa0f7076f6ec90bae8320170ba082c0db4793 languageName: node linkType: hard @@ -3497,13 +3539,6 @@ __metadata: languageName: node linkType: hard -"estraverse@npm:^4.1.1": - version: 4.3.0 - resolution: "estraverse@npm:4.3.0" - checksum: 10c0/9cb46463ef8a8a4905d3708a652d60122a0c20bb58dec7e0e12ab0e7235123d74214fc0141d743c381813e1b992767e2708194f6f6e0f9fd00c1b4e0887b8b6d - languageName: node - linkType: hard - "estraverse@npm:^5.1.0, estraverse@npm:^5.2.0": version: 5.3.0 resolution: "estraverse@npm:5.3.0" @@ -3648,14 +3683,7 @@ __metadata: languageName: node linkType: hard -"fast-diff@npm:^1.1.2": - version: 1.3.0 - resolution: "fast-diff@npm:1.3.0" - checksum: 10c0/5c19af237edb5d5effda008c891a18a585f74bf12953be57923f17a3a4d0979565fc64dbc73b9e20926b9d895f5b690c618cbb969af0cf022e3222471220ad29 - languageName: node - linkType: hard - -"fast-glob@npm:^3.0.3, fast-glob@npm:^3.2.9": +"fast-glob@npm:^3.0.3": version: 3.3.3 resolution: "fast-glob@npm:3.3.3" dependencies: @@ -3689,13 +3717,6 @@ __metadata: languageName: node linkType: hard -"fast-uri@npm:^3.0.1": - version: 3.0.6 - resolution: "fast-uri@npm:3.0.6" - checksum: 10c0/74a513c2af0584448aee71ce56005185f81239eab7a2343110e5bad50c39ad4fb19c5a6f99783ead1cac7ccaf3461a6034fda89fffa2b30b6d99b9f21c2f9d29 - languageName: node - linkType: hard - "fastq@npm:^1.6.0": version: 1.19.1 resolution: "fastq@npm:1.19.1" @@ -3726,12 +3747,24 @@ __metadata: languageName: node linkType: hard -"file-entry-cache@npm:^6.0.1": - version: 6.0.1 - resolution: "file-entry-cache@npm:6.0.1" +"fdir@npm:^6.5.0": + version: 6.5.0 + resolution: "fdir@npm:6.5.0" + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + checksum: 10c0/e345083c4306b3aed6cb8ec551e26c36bab5c511e99ea4576a16750ddc8d3240e63826cc624f5ae17ad4dc82e68a253213b60d556c11bfad064b7607847ed07f + languageName: node + linkType: hard + +"file-entry-cache@npm:11.1.5 || >11.1.6 <12": + version: 11.1.5 + resolution: "file-entry-cache@npm:11.1.5" dependencies: - flat-cache: "npm:^3.0.4" - checksum: 10c0/58473e8a82794d01b38e5e435f6feaf648e3f36fdb3a56e98f417f4efae71ad1c0d4ebd8a9a7c50c3ad085820a93fc7494ad721e0e4ebc1da3573f4e1c3c7cdd + flat-cache: "npm:^6.1.23" + checksum: 10c0/d05eca8794b27824d79a0f1a0bc76b81ca7232b6c9634bc086f365a6d9ddac749c4826b2b2d36bb7ef9ed84fdf0097f34979527daeb9255b4f17673a4ae50fc5 languageName: node linkType: hard @@ -3785,21 +3818,31 @@ __metadata: languageName: node linkType: hard -"flat-cache@npm:^3.0.4": - version: 3.2.0 - resolution: "flat-cache@npm:3.2.0" +"find-up@npm:^5.0.0": + version: 5.0.0 + resolution: "find-up@npm:5.0.0" dependencies: - flatted: "npm:^3.2.9" - keyv: "npm:^4.5.3" - rimraf: "npm:^3.0.2" - checksum: 10c0/b76f611bd5f5d68f7ae632e3ae503e678d205cf97a17c6ab5b12f6ca61188b5f1f7464503efae6dc18683ed8f0b41460beb48ac4b9ac63fe6201296a91ba2f75 + locate-path: "npm:^6.0.0" + path-exists: "npm:^4.0.0" + checksum: 10c0/062c5a83a9c02f53cdd6d175a37ecf8f87ea5bbff1fdfb828f04bfa021441bc7583e8ebc0872a4c1baab96221fb8a8a275a19809fb93fbc40bd69ec35634069a languageName: node linkType: hard -"flatted@npm:^3.2.9": - version: 3.3.3 - resolution: "flatted@npm:3.3.3" - checksum: 10c0/e957a1c6b0254aa15b8cce8533e24165abd98fadc98575db082b786b5da1b7d72062b81bfdcd1da2f4d46b6ed93bec2434e62333e9b4261d79ef2e75a10dd538 +"flat-cache@npm:^6.1.23": + version: 6.1.23 + resolution: "flat-cache@npm:6.1.23" + dependencies: + cacheable: "npm:^2.5.0" + flatted: "npm:^3.4.2" + hookified: "npm:^1.15.0" + checksum: 10c0/c757c24f51334e1917958cd9d62d4ed3d520a3815d5a1ec50d993ac3a93fa81b6de7e28264231914bb0d990babd426e4d713d3e2f3d782dae65c42a2ded1e651 + languageName: node + linkType: hard + +"flatted@npm:^3.4.2": + version: 3.4.4 + resolution: "flatted@npm:3.4.4" + checksum: 10c0/a3a52a88ea5a4c333e5a1f097dcd87a037fa31c236a77cf46222c2aa4036ef895ab9bc76138a66d9dc22bdb3652128f9598cd26e7439561bf19f67276e2bc37e languageName: node linkType: hard @@ -3905,13 +3948,6 @@ __metadata: languageName: node linkType: hard -"functional-red-black-tree@npm:^1.0.1": - version: 1.0.1 - resolution: "functional-red-black-tree@npm:1.0.1" - checksum: 10c0/5959eed0375803d9924f47688479bb017e0c6816a0e5ac151e22ba6bfe1d12c41de2f339188885e0aa8eeea2072dad509d8e4448467e816bde0a2ca86a0670d3 - languageName: node - linkType: hard - "gensync@npm:^1.0.0-beta.2": version: 1.0.0-beta.2 resolution: "gensync@npm:1.0.0-beta.2" @@ -3977,6 +4013,15 @@ __metadata: languageName: node linkType: hard +"glob-parent@npm:^6.0.2": + version: 6.0.2 + resolution: "glob-parent@npm:6.0.2" + dependencies: + is-glob: "npm:^4.0.3" + checksum: 10c0/317034d88654730230b3f43bb7ad4f7c90257a426e872ea0bf157473ac61c99bf5d205fad8f0185f989be8d2fa6d3c7dce1645d99d545b6ea9089c39f838e7f8 + languageName: node + linkType: hard + "glob@npm:^10.2.2, glob@npm:^10.3.7": version: 10.4.5 resolution: "glob@npm:10.4.5" @@ -4007,12 +4052,10 @@ __metadata: languageName: node linkType: hard -"globals@npm:^13.6.0, globals@npm:^13.9.0": - version: 13.24.0 - resolution: "globals@npm:13.24.0" - dependencies: - type-fest: "npm:^0.20.2" - checksum: 10c0/d3c11aeea898eb83d5ec7a99508600fbe8f83d2cf00cbb77f873dbf2bcb39428eff1b538e4915c993d8a3b3473fa71eeebfe22c9bb3a3003d1e26b1f2c8a42cd +"globals@npm:^17.12.0": + version: 17.12.0 + resolution: "globals@npm:17.12.0" + checksum: 10c0/8325cf8818c848871d17c32974c2c74e9c5f09c1b0e04421886f64f21e77c3e95f3ba25586743895a73acd860ada151132481d87d44a1f12993ae7758a694d03 languageName: node linkType: hard @@ -4032,20 +4075,6 @@ __metadata: languageName: node linkType: hard -"globby@npm:^11.1.0": - version: 11.1.0 - resolution: "globby@npm:11.1.0" - dependencies: - array-union: "npm:^2.1.0" - dir-glob: "npm:^3.0.1" - fast-glob: "npm:^3.2.9" - ignore: "npm:^5.2.0" - merge2: "npm:^1.4.1" - slash: "npm:^3.0.0" - checksum: 10c0/b39511b4afe4bd8a7aead3a27c4ade2b9968649abab0a6c28b1a90141b96ca68ca5db1302f7c7bd29eab66bf51e13916b8e0a3d0ac08f75e1e84a39b35691189 - languageName: node - linkType: hard - "gopd@npm:^1.2.0": version: 1.2.0 resolution: "gopd@npm:1.2.0" @@ -4060,16 +4089,9 @@ __metadata: languageName: node linkType: hard -"graphemer@npm:^1.4.0": - version: 1.4.0 - resolution: "graphemer@npm:1.4.0" - checksum: 10c0/e951259d8cd2e0d196c72ec711add7115d42eb9a8146c8eeda5b8d3ac91e5dd816b9cd68920726d9fd4490368e7ed86e9c423f40db87e2d8dfafa00fa17c3a31 - languageName: node - linkType: hard - "handlebars@npm:^4.7.8": - version: 4.7.8 - resolution: "handlebars@npm:4.7.8" + version: 4.7.9 + resolution: "handlebars@npm:4.7.9" dependencies: minimist: "npm:^1.2.5" neo-async: "npm:^2.6.2" @@ -4081,7 +4103,7 @@ __metadata: optional: true bin: handlebars: bin/handlebars - checksum: 10c0/7aff423ea38a14bb379316f3857fe0df3c5d66119270944247f155ba1f08e07a92b340c58edaa00cfe985c21508870ee5183e0634dcb53dd405f35c93ef7f10d + checksum: 10c0/22f8105a7e68e81aff2662bb434edf05f757d21d850731d71cec886d69c10cd33d3c43e34b2892968ec62de8241611851d3d0674c8ef324ea3e01dc66262faa9 languageName: node linkType: hard @@ -4115,6 +4137,15 @@ __metadata: languageName: node linkType: hard +"hashery@npm:^1.4.0, hashery@npm:^1.5.1": + version: 1.5.1 + resolution: "hashery@npm:1.5.1" + dependencies: + hookified: "npm:^1.15.0" + checksum: 10c0/ab4225b655a7b0d05df99b1a59d5b3a51fe433f82422ca25e6f3f4c4ddd30adb49ebd38e0047ef9bded93319c1e9fc857e16aa382e554929c871cb77d39fc463 + languageName: node + linkType: hard + "hasown@npm:^2.0.2": version: 2.0.2 resolution: "hasown@npm:2.0.2" @@ -4131,6 +4162,20 @@ __metadata: languageName: node linkType: hard +"hookified@npm:^1.15.0, hookified@npm:^1.15.1": + version: 1.15.1 + resolution: "hookified@npm:1.15.1" + checksum: 10c0/6b691374fa97ae57169fb29f90e723499fda5e85494654fbe55c4768b3ccbf3e14c0adc8d0f365f32c503b60d7c06f907781f5966c03d41c423575eb5e16860c + languageName: node + linkType: hard + +"hookified@npm:^2.1.1": + version: 2.2.0 + resolution: "hookified@npm:2.2.0" + checksum: 10c0/7017d2b66945490293a5aba239e7b39f39071dd940fa019348c7ffea92b91b8c267853c4c51680ed0f3687b33352582fa4d2cff6dd4c5a1c5c44b037276f07aa + languageName: node + linkType: hard + "html-escaper@npm:^2.0.0": version: 2.0.2 resolution: "html-escaper@npm:2.0.2" @@ -4233,13 +4278,6 @@ __metadata: languageName: node linkType: hard -"ignore@npm:^4.0.6": - version: 4.0.6 - resolution: "ignore@npm:4.0.6" - checksum: 10c0/836ee7dc7fd9436096e2dba429359dbb9fa0e33d309e2b2d81692f375f6ca82024fc00567f798613d50c6b989e9cd2ad2b065acf116325cde177f02c86b7d4e0 - languageName: node - linkType: hard - "ignore@npm:^5.1.1, ignore@npm:^5.2.0": version: 5.3.2 resolution: "ignore@npm:5.3.2" @@ -4247,13 +4285,10 @@ __metadata: languageName: node linkType: hard -"import-fresh@npm:^3.0.0, import-fresh@npm:^3.2.1": - version: 3.3.1 - resolution: "import-fresh@npm:3.3.1" - dependencies: - parent-module: "npm:^1.0.0" - resolve-from: "npm:^4.0.0" - checksum: 10c0/bf8cc494872fef783249709385ae883b447e3eb09db0ebd15dcead7d9afe7224dad7bd7591c6b73b0b19b3c0f9640eb8ee884f01cfaf2887ab995b0b36a0cbec +"ignore@npm:^7.0.5": + version: 7.0.9 + resolution: "ignore@npm:7.0.9" + checksum: 10c0/fec02e67b8c01a873d3b60eff6cce5e390365d7c45fea57f60f4ff52b9be5873c366867a3b8e154a3140330b72b41b94f19e1f28adfe0ef558c6b28bbf02a1b4 languageName: node linkType: hard @@ -5024,13 +5059,6 @@ __metadata: languageName: node linkType: hard -"json-buffer@npm:3.0.1": - version: 3.0.1 - resolution: "json-buffer@npm:3.0.1" - checksum: 10c0/0d1c91569d9588e7eef2b49b59851f297f3ab93c7b35c7c221e288099322be6b562767d11e4821da500f3219542b9afd2e54c5dc573107c1126ed1080f8e96d7 - languageName: node - linkType: hard - "json-parse-even-better-errors@npm:^2.3.0": version: 2.3.1 resolution: "json-parse-even-better-errors@npm:2.3.1" @@ -5045,13 +5073,6 @@ __metadata: languageName: node linkType: hard -"json-schema-traverse@npm:^1.0.0": - version: 1.0.0 - resolution: "json-schema-traverse@npm:1.0.0" - checksum: 10c0/71e30015d7f3d6dc1c316d6298047c8ef98a06d31ad064919976583eb61e1018a60a0067338f0f79cabc00d84af3fcc489bd48ce8a46ea165d9541ba17fb30c6 - languageName: node - linkType: hard - "json-stable-stringify-without-jsonify@npm:^1.0.1": version: 1.0.1 resolution: "json-stable-stringify-without-jsonify@npm:1.0.1" @@ -5107,12 +5128,12 @@ __metadata: languageName: node linkType: hard -"keyv@npm:^4.5.3": - version: 4.5.4 - resolution: "keyv@npm:4.5.4" +"keyv@npm:^5.6.0": + version: 5.6.0 + resolution: "keyv@npm:5.6.0" dependencies: - json-buffer: "npm:3.0.1" - checksum: 10c0/aa52f3c5e18e16bb6324876bb8b59dd02acf782a4b789c7b2ae21107fab95fab3890ed448d4f8dba80ce05391eeac4bfabb4f02a20221342982f806fa2cf271e + "@keyv/serialize": "npm:^1.1.1" + checksum: 10c0/c3ea795b6e03593ca57c8f70928a69bad14c13389a7fb75649a115ff55615244b04d8902798d841c17f0bb4a8a8866c97133b543b93f151b440170bba09176db languageName: node linkType: hard @@ -5222,10 +5243,19 @@ __metadata: languageName: node linkType: hard +"locate-path@npm:^6.0.0": + version: 6.0.0 + resolution: "locate-path@npm:6.0.0" + dependencies: + p-locate: "npm:^5.0.0" + checksum: 10c0/d3972ab70dfe58ce620e64265f90162d247e87159b6126b01314dd67be43d50e96a50b517bce2d9452a79409c7614054c277b5232377de50416564a77ac7aad3 + languageName: node + linkType: hard + "lodash-es@npm:^4.17.21": - version: 4.17.21 - resolution: "lodash-es@npm:4.17.21" - checksum: 10c0/fb407355f7e6cd523a9383e76e6b455321f0f153a6c9625e21a8827d10c54c2a2341bd2ae8d034358b60e07325e1330c14c224ff582d04612a46a4f0479ff2f2 + version: 4.18.1 + resolution: "lodash-es@npm:4.18.1" + checksum: 10c0/35d4dcf87ef07f8d090f409447575800108057e360b445f590d0d25d09e3d1e33a163d2fc100d4d072b0f901d5e2fc533cd7c4bfd8eeb38a06abec693823c8b8 languageName: node linkType: hard @@ -5236,24 +5266,10 @@ __metadata: languageName: node linkType: hard -"lodash.merge@npm:^4.6.2": - version: 4.6.2 - resolution: "lodash.merge@npm:4.6.2" - checksum: 10c0/402fa16a1edd7538de5b5903a90228aa48eb5533986ba7fa26606a49db2572bf414ff73a2c9f5d5fd36b31c46a5d5c7e1527749c07cbcf965ccff5fbdf32c506 - languageName: node - linkType: hard - -"lodash.truncate@npm:^4.4.2": - version: 4.4.2 - resolution: "lodash.truncate@npm:4.4.2" - checksum: 10c0/4e870d54e8a6c86c8687e057cec4069d2e941446ccab7f40b4d9555fa5872d917d0b6aa73bece7765500a3123f1723bcdba9ae881b679ef120bba9e1a0b0ed70 - languageName: node - linkType: hard - -"lodash@npm:^4.17.21": - version: 4.17.21 - resolution: "lodash@npm:4.17.21" - checksum: 10c0/d8cbea072bb08655bb4c989da418994b073a608dffa608b09ac04b43a791b12aeae7cd7ad919aa4c925f33b48490b5cfe6c1f71d827956071dae2e7bb3a6b74c +"lodash@npm:^4.18.1": + version: 4.18.1 + resolution: "lodash@npm:4.18.1" + checksum: 10c0/757228fc68805c59789e82185135cf85f05d0b2d3d54631d680ca79ec21944ec8314d4533639a14b8bcfbd97a517e78960933041a5af17ecb693ec6eecb99a27 languageName: node linkType: hard @@ -5388,7 +5404,7 @@ __metadata: languageName: node linkType: hard -"merge2@npm:^1.2.3, merge2@npm:^1.3.0, merge2@npm:^1.4.1": +"merge2@npm:^1.2.3, merge2@npm:^1.3.0": version: 1.4.1 resolution: "merge2@npm:1.4.1" checksum: 10c0/254a8a4605b58f450308fc474c82ac9a094848081bf4c06778200207820e5193726dc563a0d2c16468810516a5c97d9d3ea0ca6585d23c58ccfff2403e8dbbeb @@ -5465,12 +5481,21 @@ __metadata: languageName: node linkType: hard +"minimatch@npm:^10.2.2, minimatch@npm:^10.2.4, minimatch@npm:^10.2.5": + version: 10.2.6 + resolution: "minimatch@npm:10.2.6" + dependencies: + brace-expansion: "npm:^5.0.8" + checksum: 10c0/4559a836243b98bd4d17ea9f7edae698717c76399eea7be374f3737f33164e4907f19e9726891ddeb122f750a5a7fa80d2ac43e851d6e5984dc4ff42ec127d3a + languageName: node + linkType: hard + "minimatch@npm:^3.0.4, minimatch@npm:^3.1.1, minimatch@npm:^3.1.2": - version: 3.1.2 - resolution: "minimatch@npm:3.1.2" + version: 3.1.5 + resolution: "minimatch@npm:3.1.5" dependencies: brace-expansion: "npm:^1.1.7" - checksum: 10c0/0262810a8fc2e72cca45d6fd86bd349eee435eb95ac6aa45c9ea2180e7ee875ef44c32b55b5973ceabe95ea12682f6e3725cbb63d7a2d1da3ae1163c8b210311 + checksum: 10c0/2ecbdc0d33f07bddb0315a8b5afbcb761307a8778b48f0b312418ccbced99f104a2d17d8aca7573433c70e8ccd1c56823a441897a45e384ea76ef401a26ace70 languageName: node linkType: hard @@ -5575,6 +5600,15 @@ __metadata: languageName: node linkType: hard +"minizlib@npm:^3.1.0": + version: 3.1.0 + resolution: "minizlib@npm:3.1.0" + dependencies: + minipass: "npm:^7.1.2" + checksum: 10c0/5aad75ab0090b8266069c9aabe582c021ae53eb33c6c691054a13a45db3b4f91a7fb1bd79151e6b4e9e9a86727b522527c0a06ec7d45206b745d54cd3097bcec + languageName: node + linkType: hard + "mkdirp@npm:^0.5.1": version: 0.5.6 resolution: "mkdirp@npm:0.5.6" @@ -5586,15 +5620,6 @@ __metadata: languageName: node linkType: hard -"mkdirp@npm:^3.0.1": - version: 3.0.1 - resolution: "mkdirp@npm:3.0.1" - bin: - mkdirp: dist/cjs/src/bin.js - checksum: 10c0/9f2b975e9246351f5e3a40dcfac99fcd0baa31fbfab615fe059fb11e51f10e4803c63de1f384c54d656e4db31d000e4767e9ef076a22e12a641357602e31d57d - languageName: node - linkType: hard - "mod-info@npm:^1.0.0": version: 1.0.2 resolution: "mod-info@npm:1.0.2" @@ -5700,13 +5725,6 @@ __metadata: languageName: node linkType: hard -"natural-compare-lite@npm:^1.4.0": - version: 1.4.0 - resolution: "natural-compare-lite@npm:1.4.0" - checksum: 10c0/f6cef26f5044515754802c0fc475d81426f3b90fe88c20fabe08771ce1f736ce46e0397c10acb569a4dd0acb84c7f1ee70676122f95d5bfdd747af3a6c6bbaa8 - languageName: node - linkType: hard - "natural-compare@npm:^1.4.0": version: 1.4.0 resolution: "natural-compare@npm:1.4.0" @@ -5892,7 +5910,7 @@ __metadata: languageName: node linkType: hard -"optionator@npm:^0.9.1": +"optionator@npm:^0.9.3": version: 0.9.4 resolution: "optionator@npm:0.9.4" dependencies: @@ -5915,7 +5933,7 @@ __metadata: languageName: node linkType: hard -"p-limit@npm:^3.1.0": +"p-limit@npm:^3.0.2, p-limit@npm:^3.1.0": version: 3.1.0 resolution: "p-limit@npm:3.1.0" dependencies: @@ -5933,6 +5951,15 @@ __metadata: languageName: node linkType: hard +"p-locate@npm:^5.0.0": + version: 5.0.0 + resolution: "p-locate@npm:5.0.0" + dependencies: + p-limit: "npm:^3.0.2" + checksum: 10c0/2290d627ab7903b8b70d11d384fee714b797f6040d9278932754a6860845c4d3190603a0772a663c8cb5a7b21d1b16acb3a6487ebcafa9773094edc3dfe6009a + languageName: node + linkType: hard + "p-map@npm:^7.0.2": version: 7.0.3 resolution: "p-map@npm:7.0.3" @@ -5954,15 +5981,6 @@ __metadata: languageName: node linkType: hard -"parent-module@npm:^1.0.0": - version: 1.0.1 - resolution: "parent-module@npm:1.0.1" - dependencies: - callsites: "npm:^3.0.0" - checksum: 10c0/c63d6e80000d4babd11978e0d3fee386ca7752a02b035fd2435960ffaa7219dc42146f07069fb65e6e8bf1caef89daf9af7535a39bddf354d78bf50d8294f556 - languageName: node - linkType: hard - "parse-json@npm:^5.2.0": version: 5.2.0 resolution: "parse-json@npm:5.2.0" @@ -6041,7 +6059,7 @@ __metadata: languageName: node linkType: hard -"picocolors@npm:^1.0.0, picocolors@npm:^1.1.1": +"picocolors@npm:^1.1.1": version: 1.1.1 resolution: "picocolors@npm:1.1.1" checksum: 10c0/e2e3e8170ab9d7c7421969adaa7e1b31434f789afb9b3f115f6b96d91945041ac3ceb02e9ec6fe6510ff036bcc0bf91e69a1772edc0b707e12b19c0f2d6bcf58 @@ -6062,6 +6080,13 @@ __metadata: languageName: node linkType: hard +"picomatch@npm:^4.0.4": + version: 4.0.7 + resolution: "picomatch@npm:4.0.7" + checksum: 10c0/beb6ae02c43ae44e84883b90830196d9046b1726ead292adcf7f57945e0bb0d992d68563d87e03b484b6f3c9a5c6defda7523477f047d7f0e663f126cc01787f + languageName: node + linkType: hard + "pino-abstract-transport@npm:^3.0.0": version: 3.0.0 resolution: "pino-abstract-transport@npm:3.0.0" @@ -6145,15 +6170,6 @@ __metadata: languageName: node linkType: hard -"prettier-linter-helpers@npm:^1.0.0": - version: 1.0.0 - resolution: "prettier-linter-helpers@npm:1.0.0" - dependencies: - fast-diff: "npm:^1.1.2" - checksum: 10c0/81e0027d731b7b3697ccd2129470ed9913ecb111e4ec175a12f0fcfab0096516373bf0af2fef132af50cafb0a905b74ff57996d615f59512bb9ac7378fcc64ab - languageName: node - linkType: hard - "prettier@npm:^2.8.8": version: 2.8.8 resolution: "prettier@npm:2.8.8" @@ -6188,13 +6204,6 @@ __metadata: languageName: node linkType: hard -"progress@npm:^2.0.0": - version: 2.0.3 - resolution: "progress@npm:2.0.3" - checksum: 10c0/1697e07cb1068055dbe9fe858d242368ff5d2073639e652b75a7eb1f2a1a8d4afd404d719de23c7b48481a6aa0040686310e2dac2f53d776daa2176d3f96369c - languageName: node - linkType: hard - "promise-retry@npm:^2.0.1": version: 2.0.1 resolution: "promise-retry@npm:2.0.1" @@ -6256,6 +6265,15 @@ __metadata: languageName: node linkType: hard +"qified@npm:^0.10.1": + version: 0.10.1 + resolution: "qified@npm:0.10.1" + dependencies: + hookified: "npm:^2.1.1" + checksum: 10c0/4a39d45492c65a4b9795381acede1bd566028ce9764ead5b41d776b391a898bf0855dd2b59d724a6711fe253ba3cacf7c9a8832887cf9dc6d48a2c5e4997c82d + languageName: node + linkType: hard + "qs@npm:6.13.0": version: 6.13.0 resolution: "qs@npm:6.13.0" @@ -6353,13 +6371,6 @@ __metadata: languageName: node linkType: hard -"regexpp@npm:^3.1.0": - version: 3.2.0 - resolution: "regexpp@npm:3.2.0" - checksum: 10c0/d1da82385c8754a1681416b90b9cca0e21b4a2babef159099b88f640637d789c69011d0bc94705dacab85b81133e929d027d85210e8b8b03f8035164dbc14710 - languageName: node - linkType: hard - "require-directory@npm:^2.1.1": version: 2.1.1 resolution: "require-directory@npm:2.1.1" @@ -6367,13 +6378,6 @@ __metadata: languageName: node linkType: hard -"require-from-string@npm:^2.0.2": - version: 2.0.2 - resolution: "require-from-string@npm:2.0.2" - checksum: 10c0/aaa267e0c5b022fc5fd4eef49d8285086b15f2a1c54b28240fdf03599cbd9c26049fee3eab894f2e1f6ca65e513b030a7c264201e3f005601e80c49fb2937ce2 - languageName: node - linkType: hard - "resolve-cwd@npm:^3.0.0": version: 3.0.0 resolution: "resolve-cwd@npm:3.0.0" @@ -6383,13 +6387,6 @@ __metadata: languageName: node linkType: hard -"resolve-from@npm:^4.0.0": - version: 4.0.0 - resolution: "resolve-from@npm:4.0.0" - checksum: 10c0/8408eec31a3112ef96e3746c37be7d64020cda07c03a920f5024e77290a218ea758b26ca9529fd7b1ad283947f34b2291c1c0f6aa0ed34acfdda9c6014c8d190 - languageName: node - linkType: hard - "resolve-from@npm:^5.0.0": version: 5.0.0 resolution: "resolve-from@npm:5.0.0" @@ -6444,17 +6441,6 @@ __metadata: languageName: node linkType: hard -"rimraf@npm:^3.0.2": - version: 3.0.2 - resolution: "rimraf@npm:3.0.2" - dependencies: - glob: "npm:^7.1.3" - bin: - rimraf: bin.js - checksum: 10c0/9cb7757acb489bd83757ba1a274ab545eafd75598a9d817e0c3f8b164238dd90eba50d6b848bd4dcc5f3040912e882dc7ba71653e35af660d77b25c381d402e8 - languageName: node - linkType: hard - "rimraf@npm:^5.0.5": version: 5.0.10 resolution: "rimraf@npm:5.0.10" @@ -6530,7 +6516,7 @@ __metadata: languageName: node linkType: hard -"semver@npm:^7.2.1, semver@npm:^7.3.4, semver@npm:^7.3.5, semver@npm:^7.3.7, semver@npm:^7.3.8, semver@npm:^7.5.3, semver@npm:^7.5.4, semver@npm:^7.7.2": +"semver@npm:^7.3.4, semver@npm:^7.3.5, semver@npm:^7.3.8, semver@npm:^7.5.3, semver@npm:^7.5.4, semver@npm:^7.7.2": version: 7.7.2 resolution: "semver@npm:7.7.2" bin: @@ -6539,6 +6525,15 @@ __metadata: languageName: node linkType: hard +"semver@npm:^7.7.3": + version: 7.8.5 + resolution: "semver@npm:7.8.5" + bin: + semver: bin/semver.js + checksum: 10c0/b1f3127a5be8125a94f37188b361c212466c292c6910adce3ec106cff5dc211ccaedc4739c11bb70fda59d6fc1f040a9bca289f4e093451521a2372e5231fe0c + languageName: node + linkType: hard + "send@npm:0.19.0": version: 0.19.0 resolution: "send@npm:0.19.0" @@ -6694,17 +6689,6 @@ __metadata: languageName: node linkType: hard -"slice-ansi@npm:^4.0.0": - version: 4.0.0 - resolution: "slice-ansi@npm:4.0.0" - dependencies: - ansi-styles: "npm:^4.0.0" - astral-regex: "npm:^2.0.0" - is-fullwidth-code-point: "npm:^3.0.0" - checksum: 10c0/6c25678db1270d4793e0327620f1e0f9f5bea4630123f51e9e399191bc52c87d6e6de53ed33538609e5eacbd1fab769fae00f3705d08d029f02102a540648918 - languageName: node - linkType: hard - "smart-buffer@npm:^4.2.0": version: 4.2.0 resolution: "smart-buffer@npm:4.2.0" @@ -6735,12 +6719,12 @@ __metadata: linkType: hard "socket.io-parser@npm:~4.2.4": - version: 4.2.4 - resolution: "socket.io-parser@npm:4.2.4" + version: 4.2.7 + resolution: "socket.io-parser@npm:4.2.7" dependencies: "@socket.io/component-emitter": "npm:~3.1.0" - debug: "npm:~4.3.1" - checksum: 10c0/9383b30358fde4a801ea4ec5e6860915c0389a091321f1c1f41506618b5cf7cd685d0a31c587467a0c4ee99ef98c2b99fb87911f9dfb329716c43b587f29ca48 + debug: "npm:~4.4.1" + checksum: 10c0/16a5579718b871114ca644d688987dd3cf9292010c949fb083633eefbc1d8fdaf424691d6511d746e5c10f5c88cbd8911cd0b1e807242159f40989be90842b4e languageName: node linkType: hard @@ -6942,7 +6926,7 @@ __metadata: languageName: node linkType: hard -"strip-json-comments@npm:^3.1.0, strip-json-comments@npm:^3.1.1": +"strip-json-comments@npm:^3.1.1": version: 3.1.1 resolution: "strip-json-comments@npm:3.1.1" checksum: 10c0/9681a6257b925a7fa0f285851c0e613cc934a50661fa7bb41ca9cbbff89686bb4a0ee366e6ecedc4daafd01e83eee0720111ab294366fe7c185e935475ebcecd @@ -6984,7 +6968,7 @@ __metadata: languageName: node linkType: hard -"supports-color@npm:^5.3.0, supports-color@npm:^5.5.0": +"supports-color@npm:^5.5.0": version: 5.5.0 resolution: "supports-color@npm:5.5.0" dependencies: @@ -7018,30 +7002,16 @@ __metadata: languageName: node linkType: hard -"table@npm:^6.0.9": - version: 6.9.0 - resolution: "table@npm:6.9.0" - dependencies: - ajv: "npm:^8.0.1" - lodash.truncate: "npm:^4.4.2" - slice-ansi: "npm:^4.0.0" - string-width: "npm:^4.2.3" - strip-ansi: "npm:^6.0.1" - checksum: 10c0/35646185712bb65985fbae5975dda46696325844b78735f95faefae83e86df0a265277819a3e67d189de6e858c509b54e66ca3958ffd51bde56ef1118d455bf4 - languageName: node - linkType: hard - "tar@npm:^7.4.3": - version: 7.4.3 - resolution: "tar@npm:7.4.3" + version: 7.5.22 + resolution: "tar@npm:7.5.22" dependencies: "@isaacs/fs-minipass": "npm:^4.0.0" chownr: "npm:^3.0.0" minipass: "npm:^7.1.2" - minizlib: "npm:^3.0.1" - mkdirp: "npm:^3.0.1" + minizlib: "npm:^3.1.0" yallist: "npm:^5.0.0" - checksum: 10c0/d4679609bb2a9b48eeaf84632b6d844128d2412b95b6de07d53d8ee8baf4ca0857c9331dfa510390a0727b550fd543d4d1a10995ad86cdf078423fbb8d99831d + checksum: 10c0/1311f6be85a8157ac4c9147bae43e13923d2a1aae15e4aa1bd5239e4e03d2cf53cfe103dde7f35832fbb4c938b042856bc8e9a0afd29abd05e2d1608788c4fea languageName: node linkType: hard @@ -7056,13 +7026,6 @@ __metadata: languageName: node linkType: hard -"text-table@npm:^0.2.0": - version: 0.2.0 - resolution: "text-table@npm:0.2.0" - checksum: 10c0/02805740c12851ea5982686810702e2f14369a5f4c5c40a836821e3eefc65ffeec3131ba324692a37608294b0fd8c1e55a2dd571ffed4909822787668ddbee5c - languageName: node - linkType: hard - "thread-stream@npm:^4.0.0": version: 4.2.0 resolution: "thread-stream@npm:4.2.0" @@ -7092,6 +7055,16 @@ __metadata: languageName: node linkType: hard +"tinyglobby@npm:^0.2.15": + version: 0.2.17 + resolution: "tinyglobby@npm:0.2.17" + dependencies: + fdir: "npm:^6.5.0" + picomatch: "npm:^4.0.4" + checksum: 10c0/7f7bb0f197c88bc4b20c231e0deca4240ca3bf313a88f5a7fee93a872b84966a4d50220947c0455ad07a60b3b360961c5b7fd979222aeb716a9f99b412002e4c + languageName: node + linkType: hard + "tmpl@npm:1.0.5": version: 1.0.5 resolution: "tmpl@npm:1.0.5" @@ -7140,6 +7113,15 @@ __metadata: languageName: node linkType: hard +"ts-api-utils@npm:^2.5.0": + version: 2.5.0 + resolution: "ts-api-utils@npm:2.5.0" + peerDependencies: + typescript: ">=4.8.4" + checksum: 10c0/767849383c114e7f1971fa976b20e73ac28fd0c70d8d65c0004790bf4d8f89888c7e4cf6d5949f9c1beae9bc3c64835bef77bbe27fddf45a3c7b60cebcf85c8c + languageName: node + linkType: hard + "ts-jest@npm:^29.0.0": version: 29.4.1 resolution: "ts-jest@npm:29.4.1" @@ -7230,13 +7212,6 @@ __metadata: languageName: node linkType: hard -"tslib@npm:^1.8.1": - version: 1.14.1 - resolution: "tslib@npm:1.14.1" - checksum: 10c0/69ae09c49eea644bc5ebe1bca4fa4cc2c82b7b3e02f43b84bd891504edf66dbc6b2ec0eef31a957042de2269139e4acff911e6d186a258fb14069cd7f6febce2 - languageName: node - linkType: hard - "tsscmp@npm:1.0.6": version: 1.0.6 resolution: "tsscmp@npm:1.0.6" @@ -7244,17 +7219,6 @@ __metadata: languageName: node linkType: hard -"tsutils@npm:^3.21.0": - version: 3.21.0 - resolution: "tsutils@npm:3.21.0" - dependencies: - tslib: "npm:^1.8.1" - peerDependencies: - typescript: ">=2.8.0 || >= 3.2.0-dev || >= 3.3.0-dev || >= 3.4.0-dev || >= 3.5.0-dev || >= 3.6.0-dev || >= 3.6.0-beta || >= 3.7.0-dev || >= 3.7.0-beta" - checksum: 10c0/02f19e458ec78ead8fffbf711f834ad8ecd2cc6ade4ec0320790713dccc0a412b99e7fd907c4cda2a1dc602c75db6f12e0108e87a5afad4b2f9e90a24cabd5a2 - languageName: node - linkType: hard - "type-check@npm:^0.4.0, type-check@npm:~0.4.0": version: 0.4.0 resolution: "type-check@npm:0.4.0" @@ -7271,13 +7235,6 @@ __metadata: languageName: node linkType: hard -"type-fest@npm:^0.20.2": - version: 0.20.2 - resolution: "type-fest@npm:0.20.2" - checksum: 10c0/dea9df45ea1f0aaa4e2d3bed3f9a0bfe9e5b2592bddb92eb1bf06e50bcf98dbb78189668cd8bc31a0511d3fc25539b4cd5c704497e53e93e2d40ca764b10bfc3 - languageName: node - linkType: hard - "type-fest@npm:^0.21.3": version: 0.21.3 resolution: "type-fest@npm:0.21.3" @@ -7309,6 +7266,21 @@ __metadata: languageName: node linkType: hard +"typescript-eslint@npm:^8.70.0": + version: 8.70.0 + resolution: "typescript-eslint@npm:8.70.0" + dependencies: + "@typescript-eslint/eslint-plugin": "npm:8.70.0" + "@typescript-eslint/parser": "npm:8.70.0" + "@typescript-eslint/typescript-estree": "npm:8.70.0" + "@typescript-eslint/utils": "npm:8.70.0" + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: ">=4.8.4 <6.1.0" + checksum: 10c0/bf6625631bfcc7392bb1403681ef934f4f428c41a6a090b081a77ed0d342326c31097956900d701d078ac0c8a599d53377b504455fa9ce7214365c4c8d4635a3 + languageName: node + linkType: hard + "typescript@npm:^4.1.0": version: 4.9.5 resolution: "typescript@npm:4.9.5" @@ -7448,13 +7420,6 @@ __metadata: languageName: node linkType: hard -"v8-compile-cache@npm:^2.0.3": - version: 2.4.0 - resolution: "v8-compile-cache@npm:2.4.0" - checksum: 10c0/387851192545e7f4d691ba674de90890bba76c0f08ee4909ab862377f556221e75b3a361466490e201203401d64d7795f889882bdabc98b6f3c0bf1038a535be - languageName: node - linkType: hard - "v8-to-istanbul@npm:^9.0.1": version: 9.3.0 resolution: "v8-to-istanbul@npm:9.3.0" From f9edf3819ae9cd8abff721ebc4bd5f41e79f5553 Mon Sep 17 00:00:00 2001 From: morizon Date: Thu, 17 Sep 2026 16:33:47 +0800 Subject: [PATCH 3/6] fix: consolidate relay authorization and error hardening --- CLAUDE.md | 13 ++- README.md | 4 +- packages/transfer-server/README.md | 15 ++- packages/transfer-server/env.example | 3 - .../transfer-server/src/JsBridgeE2EEServer.ts | 74 ++++++++++++- packages/transfer-server/src/e2eeServerApi.ts | 1 + packages/transfer-server/src/errors.ts | 14 ++- packages/transfer-server/src/roomManager.ts | 55 +++++++-- packages/transfer-server/src/server.ts | 27 +---- packages/transfer-server/src/types.ts | 22 +++- packages/transfer-server/test/smoke.ts | 104 ++++++++++++++++++ 11 files changed, 275 insertions(+), 57 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 154ec4c..6b7f2af 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -57,7 +57,6 @@ The transfer server is a Socket.IO-based real-time communication server with end **Configuration (via environment variables):** - `PORT` (default: 3868) -- `CORS_ORIGINS` (comma-separated list) - `MAX_USERS_PER_ROOM` (default: 2) - `ROOM_TIMEOUT` (default: 3600000ms) - `MAX_MESSAGE_SIZE` (default: 10485760 bytes) @@ -88,9 +87,9 @@ A Midway.js-based component for OneKey Prime synchronization functionality. ## Code Style and Linting - TypeScript is used throughout the project -- ESLint configuration with TypeScript plugin +- ESLint 10 with the shared root `eslint.config.cjs` and typescript-eslint 8 - Prettier integration for code formatting -- Each package has its own `tsconfig.json` and `.eslintrc.js` +- Each package has its own `tsconfig.json`; source and tests share the root flat lint configuration - Node.js version requirement: >= 24 ## Important Implementation Notes @@ -100,7 +99,11 @@ A Midway.js-based component for OneKey Prime synchronization functionality. 2. **Error Handling**: The transfer-server includes custom error codes (see `errors.ts`). The cloud-sync-server uses Midway.js error handling patterns. 3. **Security**: - - CORS is configured but currently allows all origins in development + - CORS is intentionally permissive; the `Origin` header is not an auth + boundary here (native/desktop clients send no usable Origin, and there are + no cookie credentials to protect). Access control is the out-of-band + pairing code plus the room membership check on the c2c relay. See the + comment on `corsOptions` in `server.ts`. - Message size limits are enforced - Room timeouts prevent resource exhaustion @@ -115,4 +118,4 @@ A Midway.js-based component for OneKey Prime synchronization functionality. 6. **Dependency Injection**: The cloud-sync-server heavily uses Midway.js DI patterns. When adding new services, follow the existing pattern with decorators. -7. **Real-time Communication**: Socket.IO is configured with specific ping/pong intervals and buffer sizes. Be mindful of these settings when debugging connection issues. \ No newline at end of file +7. **Real-time Communication**: Socket.IO is configured with specific ping/pong intervals and buffer sizes. Be mindful of these settings when debugging connection issues. diff --git a/README.md b/README.md index 7b80023..34ae9c7 100644 --- a/README.md +++ b/README.md @@ -133,7 +133,6 @@ Each package can be configured using environment variables. Create `.env` files #### transfer-server ```env PORT=3868 -CORS_ORIGINS=http://localhost:3000 MAX_USERS_PER_ROOM=2 ROOM_TIMEOUT=3600000 MAX_MESSAGE_SIZE=10485760 @@ -300,7 +299,8 @@ chore: Update dependencies - All sensitive configuration should use environment variables - Never commit `.env` files - Use HTTPS in production -- Configure CORS appropriately +- Do not treat CORS as access control in transfer-server: it is deliberately + permissive (see `corsOptions` in `packages/transfer-server/src/server.ts`) - Implement rate limiting - Regular dependency updates diff --git a/packages/transfer-server/README.md b/packages/transfer-server/README.md index 98f5c9b..c70ed26 100644 --- a/packages/transfer-server/README.md +++ b/packages/transfer-server/README.md @@ -49,7 +49,6 @@ The server can be configured using environment variables: | Variable | Default | Description | |----------|---------|-------------| | `PORT` | `3868` | Server listening port | -| `CORS_ORIGINS` | `*` | Comma-separated list of allowed CORS origins | | `MAX_USERS_PER_ROOM` | `2` | Maximum users allowed per room | | `ROOM_TIMEOUT` | `3600000` | Room timeout in milliseconds (1 hour) | | `MAX_MESSAGE_SIZE` | `10485760` | Maximum message size in bytes (10MB) | @@ -57,7 +56,6 @@ The server can be configured using environment variables: Example `.env` file: ```env PORT=3868 -CORS_ORIGINS=http://localhost:3000,https://app.onekey.so MAX_USERS_PER_ROOM=2 ROOM_TIMEOUT=3600000 MAX_MESSAGE_SIZE=10485760 @@ -130,13 +128,15 @@ MAX_MESSAGE_SIZE=10485760 1. **Message Size Limits**: Prevents DoS attacks by limiting message sizes 2. **Room Timeouts**: Automatic cleanup of inactive rooms 3. **User Limits**: Configurable maximum users per room -4. **CORS Protection**: Configurable CORS origins +4. **Room Membership Enforcement**: Client-to-client messages are relayed only + for a sender that has actually joined the target room 5. **Input Validation**: Automatic validation of all API inputs ### Best Practices - Always use HTTPS in production -- Configure CORS origins appropriately +- Do not treat CORS as access control: it is deliberately permissive and + `Origin` is not the auth boundary here (see `corsOptions` in `src/server.ts`) - Implement rate limiting with a reverse proxy - Monitor room creation patterns for abuse - Use environment variables for sensitive configuration @@ -276,8 +276,11 @@ curl http://localhost:3868/stats ``` 2. **CORS Issues** - - Ensure `CORS_ORIGINS` environment variable is properly configured - - Check that client origin matches allowed origins + - CORS is intentionally permissive: every origin is accepted and there is no + allowlist to configure + - `Origin` is not the auth boundary here - access control is the out-of-band + pairing code plus the room membership check on the client-to-client relay. + See the comment on `corsOptions` in `src/server.ts` for why 3. **Connection Timeouts** - Verify firewall settings diff --git a/packages/transfer-server/env.example b/packages/transfer-server/env.example index ab8f1fe..9ad361e 100644 --- a/packages/transfer-server/env.example +++ b/packages/transfer-server/env.example @@ -3,9 +3,6 @@ # Server port (default: 3868) PORT=3868 -# CORS allowed origins, comma-separated -CORS_ORIGINS=http://localhost:3000,http://localhost:3001,http://localhost:3868 - # Maximum users per room (default: 2) MAX_USERS_PER_ROOM=2 diff --git a/packages/transfer-server/src/JsBridgeE2EEServer.ts b/packages/transfer-server/src/JsBridgeE2EEServer.ts index e686d75..ae37110 100644 --- a/packages/transfer-server/src/JsBridgeE2EEServer.ts +++ b/packages/transfer-server/src/JsBridgeE2EEServer.ts @@ -9,6 +9,7 @@ import type { IJsBridgeMessagePayload, IJsonRpcRequest, } from '@onekeyfe/cross-inpage-provider-types'; +import type { RoomManager } from './roomManager'; import type { Socket } from 'socket.io'; const logger = createModuleLogger('jsBridge'); @@ -45,11 +46,43 @@ const CLIENT_TO_CLIENT_RATE_LIMIT_ERROR_CODE = -387_155_488; // Rate limiting whitelist - methods that are exempt from rate limiting const RATE_LIMIT_WHITELIST = new Set([ 'changeTransferDirection', - 'getRoomUsers', 'leaveRoom', 'cancelTransfer', ]); +/** + * Per-method rate limit windows, overriding RATE_LIMIT_INTERVAL_MS. + * + * This table is for legitimate high-frequency callers that a shipped client + * already depends on. It is not a way to opt out of rate limiting: a method + * absent from here is limited at RATE_LIMIT_INTERVAL_MS, and that default is + * the point - a newly added method is protected without anyone having to + * remember to protect it. + * + * Adding an entry means stating, on that entry, which caller needs it, at what + * frequency, and why the default window cannot serve it. An entry is a + * compatibility shim and has a lifetime: remove it once its caller no longer + * needs it, rather than leaving a permanent hole behind. + * + * A window also has to stay meaningfully below the caller's polling interval. + * setInterval fixes the interval at which requests are *sent*; network latency + * shifts every request by roughly the same amount, so it cancels out of the gap + * the server observes, and only jitter moves that gap - in both directions. A + * 1000ms window against a 1000ms poll therefore sits exactly on the threshold + * rather than safely above it: measured over localhost, where latency is under + * a millisecond and stable, timer drift alone still pushed one poll in 30 below + * it and into a rejection. + */ +const METHOD_RATE_LIMIT_INTERVAL_MS = new Map([ + // CLI, once per second for the whole pairing phase + // (ROOM_USERS_POLL_INTERVAL_MS in transfer-receiver-adapter.ts): it had no + // user-joined push to wait on, so it polls to notice a peer arriving. The + // default 3s window would reject two of every three polls. Remove this entry + // once the CLI consumes the user-joined event instead - the poll and this + // shim go together. + ['getRoomUsers', 800], +]); + const SUPPORTED_MESSAGE_TYPES: ReadonlySet = new Set([ IJsBridgeMessageTypes.REQUEST, IJsBridgeMessageTypes.RESPONSE, @@ -93,15 +126,21 @@ function checkBridgePayload( export class JsBridgeE2EEServer extends JsBridgeBase { constructor( config: IJsBridgeConfig, - { socketClient }: { socketClient: Socket }, + { + socketClient, + roomManager, + }: { socketClient: Socket; roomManager: RoomManager }, ) { super(config); this.socketClient = socketClient; + this.roomManager = roomManager; this.setup(); } private socketClient: Socket; + private roomManager: RoomManager; + /** * Rate limit state, scoped to this connection rather than kept in a * module-level map that lived for the lifetime of the process. @@ -128,6 +167,11 @@ export class JsBridgeE2EEServer extends JsBridgeBase { sendPayload(payload: IJsBridgeMessagePayload | string): void { const p = payload as IJsBridgeMessagePayload; + // The bridge copies errors into plain objects before reaching this exit, + // so E2eeError.toJSON() alone cannot keep server stacks off the socket. + if (p?.error && typeof p.error === 'object') { + delete (p.error as { stack?: string }).stack; + } const e = p?.error as { message: string; code: number } | undefined; if (e && e?.code && e?.code === CLIENT_TO_CLIENT_RATE_LIMIT_ERROR_CODE) { this.socketClient.emit('e2ee-c2c-response', payload); @@ -296,8 +340,10 @@ export class JsBridgeE2EEServer extends JsBridgeBase { const now = Date.now(); const lastTime = this.rateLimitState.get(rateLimitKey); + const interval = + METHOD_RATE_LIMIT_INTERVAL_MS.get(method) ?? RATE_LIMIT_INTERVAL_MS; - if (lastTime !== undefined && now - lastTime < RATE_LIMIT_INTERVAL_MS) { + if (lastTime !== undefined && now - lastTime < interval) { sendErrorResponse(); return true; } @@ -337,7 +383,14 @@ export class JsBridgeE2EEServer extends JsBridgeBase { */ private pruneRateLimitState(now: number): void { for (const [key, time] of this.rateLimitState) { - if (now - time >= RATE_LIMIT_INTERVAL_MS) { + // Expire each entry against its own window, not the default one: a + // per-method window longer than the default would otherwise be dropped + // while still live, which is exactly the rate limit reset this function + // is written to prevent. + const method = key.slice(key.indexOf(':') + 1); + const interval = + METHOD_RATE_LIMIT_INTERVAL_MS.get(method) ?? RATE_LIMIT_INTERVAL_MS; + if (now - time >= interval) { this.rateLimitState.delete(key); } } @@ -469,6 +522,19 @@ export class JsBridgeE2EEServer extends JsBridgeBase { return undefined; } + // `socket.to(roomId)` is a delivery operator: it reads the membership of the + // recipients and never checks the sender's. Without this, any connected + // socket that knows a roomId can inject client-to-client calls into a room + // it never joined - bypassing the room-slot invariant the pairing flow + // relies on. Membership is authoritative in RoomManager, so ask it. + if ( + !this.socketClient.rooms.has(roomId) || + !this.roomManager.isUserInRoom(roomId, this.socketClient.id).isInRoom + ) { + this.logInvalidPayload(eventName, payload, 'sender is not a room member'); + return undefined; + } + return { payload: checked.payload, roomId }; } } diff --git a/packages/transfer-server/src/e2eeServerApi.ts b/packages/transfer-server/src/e2eeServerApi.ts index 4f4fb19..ce8f116 100644 --- a/packages/transfer-server/src/e2eeServerApi.ts +++ b/packages/transfer-server/src/e2eeServerApi.ts @@ -63,6 +63,7 @@ function createBridgeE2EEServer({ }, { socketClient, + roomManager, }, ); } diff --git a/packages/transfer-server/src/errors.ts b/packages/transfer-server/src/errors.ts index 334e468..f8197f6 100644 --- a/packages/transfer-server/src/errors.ts +++ b/packages/transfer-server/src/errors.ts @@ -64,13 +64,21 @@ export class E2eeError extends Error { return new E2eeError(code, message); } - // Convert to JSON for serialization + // Convert to JSON for serialization, without the server stack trace. + // + // This is not what keeps the stack off the socket path: JsBridgeBase + // .createPayload() replaces payload.error with its own plain copy + // (toPlainError), reading err.stack off the instance directly, so toJSON() + // never runs before a response is emitted. The stack is stripped at the + // single egress point instead - see JsBridgeE2EEServer.sendPayload(). This + // stays as defence in depth for any path that serializes an E2eeError + // directly. `stack` remains on the instance for server-side pino logging, + // which also reads err.stack rather than going through toJSON. toJSON() { return { name: this.name, message: this.message, code: this.code, - stack: this.stack, }; } -} \ No newline at end of file +} diff --git a/packages/transfer-server/src/roomManager.ts b/packages/transfer-server/src/roomManager.ts index 25c825e..23cb63e 100644 --- a/packages/transfer-server/src/roomManager.ts +++ b/packages/transfer-server/src/roomManager.ts @@ -44,6 +44,16 @@ export class RoomManager { /** * Create new room * @returns Room information (room ID and encryption key) + * + * NOTE on `encryptionKey` (returned here and as `roomKey` from joinRoom): + * this key is NOT used by the OneKey client, and it is not what protects + * transferred data. The client derives its own end-to-end key locally from + * the pairing code shown in the QR code (only its roomId prefix reaches the + * server), an ECDHE shared secret negotiated between the devices, and the + * room's user list. The server knows the user list but not the pairing-code + * secret or the ECDHE shared secret. It never encrypts or decrypts payloads + * with this generated key - it only relays them. The field is + * kept for wire compatibility with existing clients. */ @e2eeApiMethod() async createRoom(): Promise<{ roomId: string; encryptionKey: string }> { @@ -99,6 +109,11 @@ export class RoomManager { * @param encryptionKey Encryption key * @param socketId User's Socket ID * @returns Join result + * + * The returned `roomKey` is server-generated and unused by the client - see + * the note on createRoom(). It is not part of the end-to-end encryption + * scheme; the client derives its own key from the pairing code and an ECDHE + * exchange this server is not party to. */ @e2eeApiMethod() async joinRoom( @@ -186,6 +201,20 @@ export class RoomManager { await context?.socketClient.join(roomId); + // Tell the members already in the room that someone joined. The server + // never pushed this before, so a peer that needed to know had to poll + // getRoomUsers instead - which is why that method carries a high call rate. + // + // Emitted from the joining socket rather than the server, so the joiner is + // excluded (it does not need to be told about itself), and only after + // join() resolves, so the membership the event describes is already in + // effect if a receiver immediately calls getRoomUsers. + context?.socketClient.to(roomId).emit("user-joined", { + roomId, + userId, + userCount: room.users.size, + }); + return { success: true, userId, @@ -311,18 +340,26 @@ export class RoomManager { ); } logger.debug({ roomId }, "room.getRoomUsers"); - const room = this.rooms.get(roomId); - if (!room) { - logger.debug({ roomId }, "room.getRoomUsersNotFound"); - return []; - } - // Validate that the socket is in the room + + // A room the caller cannot see must be indistinguishable from a room that + // does not exist. Returning [] for a missing room while throwing for a room + // the caller is not in turned this into a room existence oracle, previously + // exposed without a rate limit. + // isUserInRoom() already reports false for a missing room, so both cases + // fail here identically, with the same error code and message. const socketValidation = this.isUserInRoom(roomId, context.socketClient.id); if (!socketValidation.isInRoom) { - throw new E2eeError( - E2eeErrorCode.SOCKET_NOT_IN_ROOM, - "Socket must be in the room to set transfer direction" + logger.debug( + { roomId, roomExists: this.rooms.has(roomId) }, + "room.getRoomUsersDenied" ); + throw new E2eeError(E2eeErrorCode.ROOM_NOT_FOUND, "Room not found"); + } + + // isInRoom implies the room exists. + const room = this.rooms.get(roomId); + if (!room) { + throw new E2eeError(E2eeErrorCode.ROOM_NOT_FOUND, "Room not found"); } const users: IE2EESocketUserInfo[] = sortBy( diff --git a/packages/transfer-server/src/server.ts b/packages/transfer-server/src/server.ts index ff0cca4..ec1c0cd 100644 --- a/packages/transfer-server/src/server.ts +++ b/packages/transfer-server/src/server.ts @@ -45,19 +45,6 @@ class E2EEServer { constructor() { this.config = { port: parseInt(process.env.PORT || '3868', 10), - corsOrigins: process.env.CORS_ORIGINS?.split(',') || [ - 'http://localhost:3000', - 'http://localhost:3001', - 'http://localhost:3868', - 'null', - 'chrome-extension://*', - 'moz-extension://*', - 'ws://*', - 'wss://*', - 'http://*', - 'https://*', - '*', - ], roomConfig: { maxUsers: parseInt(process.env.MAX_USERS_PER_ROOM || '2', 10), roomTimeout: parseInt(process.env.ROOM_TIMEOUT || '3600000', 10), // 1 hour @@ -74,17 +61,13 @@ class E2EEServer { this.setupMiddleware(); this.setupRoutes(); + // Native clients may omit Origin and desktop clients may send "null". + // Connections carry no cookie credentials; Origin is not authorization. + // Preserve the previous allow-all behavior, which never enforced its + // configured allowlist. Relay access is checked through room membership. this.corsOptions = { - origin: (origin, callback) => { - if (!origin || this.config.corsOrigins.includes(origin)) { - callback(null, true); - } else { - callback(null, true); - // callback(new Error('Invalid CORS request')); - } - }, + origin: true, methods: ['GET', 'POST'], - credentials: true, }; this.socketServer = new SocketIOServer< diff --git a/packages/transfer-server/src/types.ts b/packages/transfer-server/src/types.ts index 9c2a21f..fabfa10 100644 --- a/packages/transfer-server/src/types.ts +++ b/packages/transfer-server/src/types.ts @@ -8,8 +8,19 @@ export { E2eeError, E2eeErrorCode } from './errors'; export interface IServerToClientEvents { 'room-created': (data: { roomId: string; encryptionKey: string }) => void; 'room-joined': (data: { roomId: string; userId: string }) => void; - 'user-joined': (data: { userId: string; userCount: number }) => void; - 'user-left': (data: { userId: string; userCount: number }) => void; + // roomId is part of both payloads: a client multiplexes every room over one + // socket, so it needs to tell which room an event refers to. `user-left` has + // always been emitted with it - the declaration was simply out of date. + 'user-joined': (data: { + roomId: string; + userId: string; + userCount: number; + }) => void; + 'user-left': (data: { + roomId: string; + userId: string; + userCount: number; + }) => void; 'encrypted-data': (data: { encryptedData: string; senderId: string; @@ -48,6 +59,12 @@ export interface ISocketData { // Room data structure export interface IRoom { id: string; + // Server-generated key handed to clients on create/join. The OneKey client + // does not consume it, and this server never encrypts with it - payloads are + // relayed as-is. Real end-to-end protection comes from a key the clients + // derive themselves (pairing code + ECDHE shared secret + room user list). + // The server knows the user list but not the secret key material. + // See RoomManager.createRoom(). encryptionKey: string; users: Map; transferDirection?: @@ -101,7 +118,6 @@ export interface IRoomConfig { // Server configuration export interface IServerConfig { port: number; - corsOrigins: string[]; roomConfig: IRoomConfig; } diff --git a/packages/transfer-server/test/smoke.ts b/packages/transfer-server/test/smoke.ts index dea92ef..875edcf 100644 --- a/packages/transfer-server/test/smoke.ts +++ b/packages/transfer-server/test/smoke.ts @@ -393,17 +393,98 @@ async function main(): Promise { await clientA.call('roomManager', 'joinRoomAfterCreate', [ { roomId: room.roomId, ...appInfo('A') }, ]); + const joinedEvents: Array<{ roomId: string; userId: string; userCount: number }> = []; + let joinerNotified = false; + clientA.socket.on('user-joined', (event) => joinedEvents.push(event)); + clientB.socket.on('user-joined', () => { joinerNotified = true; }); const joined = await clientB.call('roomManager', 'joinRoom', [{ roomId: room.roomId, ...appInfo('B') }]); check(joined.chunkedTransferVersion === 1, 'relay advertises chunked transfer support'); + // Reading on the notified socket also waits for its preceding join event. const usersBefore = await clientA.call('roomManager', 'getRoomUsers', [ { roomId: room.roomId }, ]); + check( + joinedEvents.length === 1 && joinedEvents[0].roomId === room.roomId && + joinedEvents[0].userId === joined.userId && joinedEvents[0].userCount === 2, + 'existing member receives the peer join event with room identity', + ); + check(!joinerNotified, 'joining peer does not receive its own join event'); check(usersBefore.length === 2, 'session established', `${usersBefore.length} users in room`); // --- baseline: peers can talk both ways before anything goes wrong --- await checkBidirectional(clientA, clientB, room.roomId, 'before'); + // --- security: a socket that never joined the room must not be able to + // inject c2c traffic into it. The relay emits to the client-supplied + // roomId, so without a membership check any connected socket could push + // cancelTransfer / verifyPairingCode / a forged response into a live + // session it only knows the id of. The outsider knows room.roomId but + // never called joinRoom, so both channels must be dropped, and the two + // real members must be unaffected. --- + const outsider = makeClient('smoke-client-outsider'); + await outsider.ready; + const injectToken = `inject-${Date.now()}`; + const seenBefore = { + aReq: clientA.c2cRequests.length, + bReq: clientB.c2cRequests.length, + aRes: clientA.c2cResponses.length, + bRes: clientB.c2cResponses.length, + }; + outsider.socket.emit('e2ee-c2c-request', { + payload: { + id: Date.now(), + type: 'REQUEST', + data: { module: 'peer', method: `inject_${injectToken}`, params: [injectToken] }, + }, + roomId: room.roomId, + }); + outsider.socket.emit('e2ee-c2c-response', { + payload: { id: Date.now(), type: 'RESPONSE', data: { result: injectToken } }, + roomId: room.roomId, + }); + outsider.socket.emit('e2ee-c2c-request', { + payload: { + id: Date.now() + 1, + type: 'REQUEST', + data: { + module: 'api', method: 'sendTransferChunk', + params: [{ transferId: 'outsider-chunk', index: 0, data: 'AAAA' }], + }, + }, + roomId: room.roomId, + }); + await wait(1000); + const injected = + clientA.c2cRequests.length > seenBefore.aReq || + clientB.c2cRequests.length > seenBefore.bReq || + clientA.c2cResponses.length > seenBefore.aRes || + clientB.c2cResponses.length > seenBefore.bRes; + check( + !injected, + 'non-member cannot inject requests, responses, or transfer chunks', + injected ? 'INJECTED - membership check bypassed' : 'both channels dropped', + ); + check((await health()) === 200, 'server alive after c2c injection attempt'); + await checkBidirectional(clientA, clientB, room.roomId, 'after injection attempt'); + + const privateRoom = await outsider.callRaw('roomManager', 'getRoomUsers', [{ roomId: room.roomId }]); + const queryFlood = await outsider.callRaw('roomManager', 'getRoomUsers', [{ roomId: room.roomId }]); + check(queryFlood.error?.code === 1100, 'rapid room queries are rate limited'); + check(queryFlood.error?.stack === undefined, 'rate limit errors omit server stacks'); + await wait(900); + const missingRoom = await outsider.callRaw('roomManager', 'getRoomUsers', [{ roomId: 'missing-room' }]); + check( + Boolean(privateRoom.error) && JSON.stringify(privateRoom.error) === JSON.stringify(missingRoom.error), + 'private and missing rooms return identical errors to non-members', + ); + for (let poll = 0; poll < 3; poll += 1) { + await wait(1000); + const users = await clientA.callRaw('roomManager', 'getRoomUsers', [{ roomId: room.roomId }]); + check(!users.error && users.data?.length === 2, `one-second member poll ${poll + 1} succeeds`); + } + outsider.socket.disconnect(); + // More than one chunk must pass the relay without the legacy 3 second limit. const chunkStart = clientB.c2cRequests.length; const chunkData = 'A'.repeat(64 * 1024); @@ -551,6 +632,29 @@ async function main(): Promise { check(stillWorks.length === 2, 'session unaffected by oversized-log flood'); logFlooder.socket.disconnect(); + // --- an error response must never carry the server stack trace. It cannot + // be stopped by E2eeError.toJSON(): JsBridgeBase.createPayload() + // replaces payload.error with its own plain copy first, and that copy + // (toPlainError) reads err.stack straight off the instance. So it is + // stripped in sendPayload(), and it has to stay stripped. + // A fresh client keeps this off the rate-limit windows used above. --- + const errorProbe = makeClient('smoke-client-F'); + await errorProbe.ready; + const errorPayload = await errorProbe.callRaw('roomManager', 'joinRoom', [ + { roomId: 'not-a-valid-room-id', ...appInfo('F') }, + ]); + check( + Boolean(errorPayload.error), + 'invalid roomId is rejected with an error', + `code=${String(errorPayload.error?.code)}`, + ); + check( + errorPayload.error?.stack === undefined, + 'error response carries no server stack trace', + errorPayload.error?.stack ? 'LEAKED - server stack sent to client' : 'no stack field', + ); + errorProbe.socket.disconnect(); + const clientC = makeClient('smoke-client-C'); await clientC.ready; check(clientC.socket.connected, 'new client can still connect'); From 174a74aaab9242ca0ac2907715a25a57cfb8b54f Mon Sep 17 00:00:00 2001 From: morizon Date: Thu, 17 Sep 2026 17:21:15 +0800 Subject: [PATCH 4/6] fix: preserve relay compatibility for existing clients --- packages/transfer-server/package.json | 3 +- .../transfer-server/src/JsBridgeE2EEServer.ts | 138 +++++---- packages/transfer-server/src/errors.ts | 2 +- .../test/relay-compatibility.ts | 285 ++++++++++++++++++ packages/transfer-server/test/smoke.ts | 9 +- 5 files changed, 369 insertions(+), 68 deletions(-) create mode 100644 packages/transfer-server/test/relay-compatibility.ts diff --git a/packages/transfer-server/package.json b/packages/transfer-server/package.json index 6367194..27750e5 100644 --- a/packages/transfer-server/package.json +++ b/packages/transfer-server/package.json @@ -9,7 +9,8 @@ "start": "node dist/server.js", "dev": "nodemon src/server.ts", "clean": "rimraf dist", - "test": "yarn build && ts-node --project test/tsconfig.json test/smoke.ts && ts-node --project test/tsconfig.json test/crash-logging.ts", + "test": "yarn build && ts-node --project test/tsconfig.json test/smoke.ts && ts-node --project test/tsconfig.json test/crash-logging.ts && yarn test:compatibility", + "test:compatibility": "TS_NODE_PROJECT=test/tsconfig.json node --require ts-node/register --test --test-force-exit test/relay-compatibility.ts", "test:crash": "yarn build && ts-node --project test/tsconfig.json test/crash-logging.ts", "postinstall": "echo 'e2ee-server dependencies installed'", "lint": "eslint src test --max-warnings 0" diff --git a/packages/transfer-server/src/JsBridgeE2EEServer.ts b/packages/transfer-server/src/JsBridgeE2EEServer.ts index ae37110..eb7aac7 100644 --- a/packages/transfer-server/src/JsBridgeE2EEServer.ts +++ b/packages/transfer-server/src/JsBridgeE2EEServer.ts @@ -41,8 +41,6 @@ function capForLog(value: unknown, max: number = LOG_FIELD_MAX_LENGTH): string | return value.length > max ? `${value.slice(0, max)}...(${value.length})` : value; } -const CLIENT_TO_CLIENT_RATE_LIMIT_ERROR_CODE = -387_155_488; - // Rate limiting whitelist - methods that are exempt from rate limiting const RATE_LIMIT_WHITELIST = new Set([ 'changeTransferDirection', @@ -50,38 +48,14 @@ const RATE_LIMIT_WHITELIST = new Set([ 'cancelTransfer', ]); -/** - * Per-method rate limit windows, overriding RATE_LIMIT_INTERVAL_MS. - * - * This table is for legitimate high-frequency callers that a shipped client - * already depends on. It is not a way to opt out of rate limiting: a method - * absent from here is limited at RATE_LIMIT_INTERVAL_MS, and that default is - * the point - a newly added method is protected without anyone having to - * remember to protect it. - * - * Adding an entry means stating, on that entry, which caller needs it, at what - * frequency, and why the default window cannot serve it. An entry is a - * compatibility shim and has a lifetime: remove it once its caller no longer - * needs it, rather than leaving a permanent hole behind. - * - * A window also has to stay meaningfully below the caller's polling interval. - * setInterval fixes the interval at which requests are *sent*; network latency - * shifts every request by roughly the same amount, so it cancels out of the gap - * the server observes, and only jitter moves that gap - in both directions. A - * 1000ms window against a 1000ms poll therefore sits exactly on the threshold - * rather than safely above it: measured over localhost, where latency is under - * a millisecond and stable, timer drift alone still pushed one poll in 30 below - * it and into a rejection. - */ -const METHOD_RATE_LIMIT_INTERVAL_MS = new Map([ - // CLI, once per second for the whole pairing phase - // (ROOM_USERS_POLL_INTERVAL_MS in transfer-receiver-adapter.ts): it had no - // user-joined push to wait on, so it polls to notice a peer arriving. The - // default 3s window would reject two of every three polls. Remove this entry - // once the CLI consumes the user-joined event instead - the poll and this - // shim go together. - ['getRoomUsers', 800], -]); +// Shipped clients query during key derivation, immediately after pairing, and +// independently from a one-second CLI poll. Allow those overlapping calls while +// bounding sustained traffic; a minimum interval breaks otherwise valid pairing. +const ROOM_USERS_RATE_LIMIT_CAPACITY = 10; +const ROOM_USERS_RATE_LIMIT_REFILL_PER_MS = 5 / 1000; + +type IResponseEvent = 'e2ee-response' | 'e2ee-c2c-response'; +type IRequestEvent = 'e2ee-request' | 'e2ee-c2c-request'; const SUPPORTED_MESSAGE_TYPES: ReadonlySet = new Set([ IJsBridgeMessageTypes.REQUEST, @@ -153,6 +127,8 @@ export class JsBridgeE2EEServer extends JsBridgeBase { */ private rateLimitState = new Map(); + private roomUsersRateLimitState?: { tokens: number; updatedAt: number }; + private chunkWindowStartedAt = 0; private chunkRequestsInWindow = 0; @@ -166,18 +142,42 @@ export class JsBridgeE2EEServer extends JsBridgeBase { override sendAsString = false; sendPayload(payload: IJsBridgeMessagePayload | string): void { + this.emitResponse('e2ee-response', payload); + } + + private emitResponse( + eventName: IResponseEvent, + payload: IJsBridgeMessagePayload | string, + ): void { const p = payload as IJsBridgeMessagePayload; // The bridge copies errors into plain objects before reaching this exit, // so E2eeError.toJSON() alone cannot keep server stacks off the socket. if (p?.error && typeof p.error === 'object') { delete (p.error as { stack?: string }).stack; } - const e = p?.error as { message: string; code: number } | undefined; - if (e && e?.code && e?.code === CLIENT_TO_CLIENT_RATE_LIMIT_ERROR_CODE) { - this.socketClient.emit('e2ee-c2c-response', payload); - return; - } - this.socketClient.emit('e2ee-response', payload); + this.socketClient.emit(eventName, payload); + } + + private sendRequestError( + eventName: IRequestEvent, + payload: IJsBridgeMessagePayload, + error: E2eeError, + ): void { + // C2S and C2C bridges allocate IDs independently. Carry the response channel + // alongside this request instead of inferring it from an ID or error code. + // Match JsBridgeBase.responseError's wire envelope without its C2S egress. + this.emitResponse( + eventName === 'e2ee-c2c-request' ? 'e2ee-c2c-response' : 'e2ee-response', + { + id: payload.id, + type: IJsBridgeMessageTypes.RESPONSE, + origin: '', + scope: payload.scope, + remoteId: payload.remoteId, + peerOrigin: payload.peerOrigin, + error: error.toJSON(), + }, + ); } /** @@ -294,6 +294,26 @@ export class JsBridgeE2EEServer extends JsBridgeBase { const req = payload?.data as IJsonRpcRequest | undefined; const method = typeof req?.method === 'string' ? req.method : ''; + if (eventName === 'e2ee-request' && method === 'getRoomUsers') { + const now = Date.now(); + const state = this.roomUsersRateLimitState; + const tokens = state + ? Math.min( + ROOM_USERS_RATE_LIMIT_CAPACITY, + state.tokens + + Math.max(0, now - state.updatedAt) * + ROOM_USERS_RATE_LIMIT_REFILL_PER_MS, + ) + : ROOM_USERS_RATE_LIMIT_CAPACITY; + this.roomUsersRateLimitState = { tokens, updatedAt: now }; + if (tokens < 1) { + sendErrorResponse(); + return true; + } + this.roomUsersRateLimitState.tokens -= 1; + return false; + } + // Chunk RPCs are bounded by both message size and connection throughput. // Keep the existing per-method limit for all other operations. if (eventName === 'e2ee-c2c-request' && method === 'sendTransferChunk') { @@ -340,10 +360,7 @@ export class JsBridgeE2EEServer extends JsBridgeBase { const now = Date.now(); const lastTime = this.rateLimitState.get(rateLimitKey); - const interval = - METHOD_RATE_LIMIT_INTERVAL_MS.get(method) ?? RATE_LIMIT_INTERVAL_MS; - - if (lastTime !== undefined && now - lastTime < interval) { + if (lastTime !== undefined && now - lastTime < RATE_LIMIT_INTERVAL_MS) { sendErrorResponse(); return true; } @@ -383,35 +400,29 @@ export class JsBridgeE2EEServer extends JsBridgeBase { */ private pruneRateLimitState(now: number): void { for (const [key, time] of this.rateLimitState) { - // Expire each entry against its own window, not the default one: a - // per-method window longer than the default would otherwise be dropped - // while still live, which is exactly the rate limit reset this function - // is written to prevent. - const method = key.slice(key.indexOf(':') + 1); - const interval = - METHOD_RATE_LIMIT_INTERVAL_MS.get(method) ?? RATE_LIMIT_INTERVAL_MS; - if (now - time >= interval) { + if (now - time >= RATE_LIMIT_INTERVAL_MS) { this.rateLimitState.delete(key); } } } - private buildRateLimitResponder(payload: IJsBridgeMessagePayload) { + private buildRateLimitResponder( + eventName: IRequestEvent, + payload: IJsBridgeMessagePayload, + ) { return () => { logger.debug( { socketId: this.socketClient.id }, 'jsBridge.rateLimitExceeded', ); - this.responseError({ - id: payload.id || -9999, - error: new E2eeError( + this.sendRequestError( + eventName, + payload, + new E2eeError( E2eeErrorCode.RATE_LIMIT_EXCEEDED, 'Rate limit, please try again later', ), - scope: payload.scope, - remoteId: payload.remoteId, - peerOrigin: payload.peerOrigin, - }); + ); }; } @@ -420,6 +431,7 @@ export class JsBridgeE2EEServer extends JsBridgeBase { // released explicitly rather than left for GC to reclaim this.socketClient.on('disconnect', () => { this.rateLimitState.clear(); + this.roomUsersRateLimitState = undefined; }); this.socketClient.on( @@ -435,7 +447,7 @@ export class JsBridgeE2EEServer extends JsBridgeBase { const isRateLimited = this.checkIsRateLimited({ payload: p, eventName: 'e2ee-request', - sendErrorResponse: this.buildRateLimitResponder(p), + sendErrorResponse: this.buildRateLimitResponder('e2ee-request', p), }); if (isRateLimited) { @@ -463,13 +475,14 @@ export class JsBridgeE2EEServer extends JsBridgeBase { const isRateLimited = this.checkIsRateLimited({ payload: p, eventName: 'e2ee-c2c-request', - sendErrorResponse: this.buildRateLimitResponder(p), + sendErrorResponse: this.buildRateLimitResponder('e2ee-c2c-request', p), }); if (isRateLimited) { return; } + this.roomManager.updateRoomActivity(roomId); this.socketClient.to(roomId).emit('e2ee-c2c-request', p); }), ); @@ -486,6 +499,7 @@ export class JsBridgeE2EEServer extends JsBridgeBase { } const { payload: p, roomId } = envelope; + this.roomManager.updateRoomActivity(roomId); this.socketClient.to(roomId).emit('e2ee-c2c-response', p); }), ); diff --git a/packages/transfer-server/src/errors.ts b/packages/transfer-server/src/errors.ts index f8197f6..3f0905e 100644 --- a/packages/transfer-server/src/errors.ts +++ b/packages/transfer-server/src/errors.ts @@ -70,7 +70,7 @@ export class E2eeError extends Error { // .createPayload() replaces payload.error with its own plain copy // (toPlainError), reading err.stack off the instance directly, so toJSON() // never runs before a response is emitted. The stack is stripped at the - // single egress point instead - see JsBridgeE2EEServer.sendPayload(). This + // shared egress point instead - see JsBridgeE2EEServer.emitResponse(). This // stays as defence in depth for any path that serializes an E2eeError // directly. `stack` remains on the instance for server-side pino logging, // which also reads err.stack rather than going through toJSON. diff --git a/packages/transfer-server/test/relay-compatibility.ts b/packages/transfer-server/test/relay-compatibility.ts new file mode 100644 index 0000000..b0c8a6c --- /dev/null +++ b/packages/transfer-server/test/relay-compatibility.ts @@ -0,0 +1,285 @@ +// Load the same browser-global shims as the production server before the bridge. +import '../src/utils/nodeCompat'; + +import assert from 'node:assert/strict'; +import { createServer } from 'node:http'; +import { test } from 'node:test'; + +import { Server } from 'socket.io'; +import { io } from 'socket.io-client'; + +import { e2eeServerApiSetup } from '../src/e2eeServerApi'; +import { E2eeErrorCode } from '../src/errors'; +import { RoomManager } from '../src/roomManager'; + +import type { Socket } from 'socket.io-client'; + +type IPacket = { + id: number; + type: string; + scope?: string; + remoteId?: string; + peerOrigin?: string; + error?: { code: number; message: string; stack?: string }; + data?: unknown; +}; + +const ROOM_TIMEOUT = 60_000; +const appInfo = { + appPlatform: 'compatibility-test', appPlatformName: 'compatibility-test', + appVersion: '1.0.0', appBuildNumber: '1', appDeviceName: 'synthetic-peer', +}; + +function receive(socket: Socket, event: string, id: number): Promise { + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + socket.off(event, listener); + reject(new Error(`Timed out waiting for ${event}, id ${id}`)); + }, 5000); + const listener = (packet: IPacket) => { + if (packet.id === id) { + clearTimeout(timer); + socket.off(event, listener); + resolve(packet); + } + }; + socket.on(event, listener); + }); +} + +test('relay compatibility over real Socket.IO with a controlled activity clock', async (t) => { + // Only Date is mocked: HTTP, Socket.IO, RPC delays, and timeout guards use + // real timers. Advancing this clock never waits for a one-hour session. + let now = 1_700_000_000_000; + t.mock.timers.enable({ apis: ['Date'], now }); + const advance = (ms: number) => { + now += ms; + t.mock.timers.setTime(now); + }; + + const httpServer = createServer(); + // Keep transport heartbeats beyond the simulated timeline so these tests + // isolate RoomManager's idle TTL rather than the client's ping deadline. + const socketServer = new Server(httpServer, { pingInterval: 3_600_000, pingTimeout: 3_600_000 }); + const roomConfig = { maxUsers: 2, roomTimeout: ROOM_TIMEOUT, maxMessageSize: 10 * 1024 * 1024 }; + const manager = new RoomManager({ config: roomConfig, socketServer }); + socketServer.on('connection', (socketClient) => { + e2eeServerApiSetup({ socketClient, roomManager: manager }); + // A test-only barrier confirms all preceding synchronous relay handlers + // ran, including intentionally silent drops. It changes no production API. + socketClient.on('test-barrier', (ack: () => void) => ack()); + }); + await new Promise((resolve) => httpServer.listen(0, '127.0.0.1', resolve)); + const address = httpServer.address(); + assert(address && typeof address !== 'string'); + const url = `http://127.0.0.1:${address.port}`; + const sockets: Socket[] = []; + + t.after(async () => { + sockets.forEach((socket) => socket.disconnect()); + manager.destroy(); + await new Promise((resolve) => socketServer.close(() => resolve())); + }); + + const connect = async () => { + const socket = io(url, { transports: ['websocket'], reconnection: false }); + sockets.push(socket); + await new Promise((resolve, reject) => { + socket.once('connect', resolve); + socket.once('connect_error', reject); + }); + return socket; + }; + const [a, b, outsider] = await Promise.all([connect(), connect(), connect()]); + let sequence = 100; + const request = (method: string, params: unknown[] = [], module = 'roomManager'): IPacket => ({ + id: sequence++, type: 'REQUEST', data: { module, method, params }, + }); + const call = async (socket: Socket, method: string, params: unknown[] = []) => { + const packet = request(method, params); + const reply = receive(socket, 'e2ee-response', packet.id); + socket.emit('e2ee-request', packet); + return reply; + }; + const createSession = async () => { + const created = await call(a, 'createRoom'); + assert.equal(created.error, undefined); + const { roomId } = created.data as { roomId: string }; + const joined = await Promise.all([ + call(a, 'joinRoomAfterCreate', [{ roomId, ...appInfo }]), + call(b, 'joinRoom', [{ roomId, ...appInfo }]), + ]); + joined.forEach((reply) => assert.equal(reply.error, undefined)); + return roomId; + }; + const barrier = (socket: Socket) => socket.timeout(5000).emitWithAck('test-barrier'); + const relay = async (sender: Socket, recipient: Socket, roomId: string, event: string, packet: IPacket) => { + const delivered = receive(recipient, event, packet.id); + sender.emit(event, { roomId, payload: packet }); + assert.deepEqual(await delivered, packet); + }; + // Invoke the actual scheduled cleanup body deterministically. No mocked + // membership map or fake cleanup implementation can mask expiry regressions. + const cleanup = () => { + const cleanupExpiredRooms = Reflect.get(manager, 'cleanupExpiredRooms') as () => void; + cleanupExpiredRooms.call(manager); + }; + let roomId = await createSession(); + + await t.test('pairing queries may overlap with CLI polling, while floods remain bounded', async () => { + for (let poll = 0; poll < 3; poll += 1) { + const replies = await Promise.all([ + call(a, 'getRoomUsers', [{ roomId }]), + call(a, 'getRoomUsers', [{ roomId }]), + call(a, 'getRoomUsers', [{ roomId }]), + ]); + replies.forEach((reply) => { + assert.equal(reply.error, undefined, 'poll, key derivation, and UI queries can coincide'); + assert.equal((reply.data as unknown[]).length, 2); + }); + advance(1000); + } + advance(2000); + const burst = await Promise.all(Array.from({ length: 11 }, () => call(a, 'getRoomUsers', [{ roomId }]))); + assert.equal(burst.filter((reply) => !reply.error).length, 10); + assert.equal(burst.filter((reply) => reply.error?.code === E2eeErrorCode.RATE_LIMIT_EXCEEDED).length, 1); + advance(199); + assert.equal((await call(a, 'getRoomUsers', [{ roomId }])).error?.code, E2eeErrorCode.RATE_LIMIT_EXCEEDED); + advance(1); + assert.equal((await call(a, 'getRoomUsers', [{ roomId }])).error, undefined, 'one token refills every 200ms'); + const privateRoom = await call(outsider, 'getRoomUsers', [{ roomId }]); + const missingRoom = await call(outsider, 'getRoomUsers', [{ roomId: 'missing-room' }]); + assert.equal(privateRoom.error?.code, E2eeErrorCode.ROOM_NOT_FOUND); + assert.deepEqual(privateRoom.error, missingRoom.error); + }); + + await t.test('concurrent C2S and rejected C2C requests with the same id keep separate error channels', async () => { + advance(5000); + const sharedId = sequence++; + const c2s: IPacket = { + ...request('joinRoom', [{ roomId: 'invalid-room-id', ...appInfo }]), + id: sharedId, scope: 'c2s-scope', remoteId: 'c2s-remote', peerOrigin: 'c2s-origin', + }; + const c2c: IPacket = { + ...request('sendTransferChunk', [{ transferId: 'invalid-chunk', index: 0, data: '' }], 'api'), + id: sharedId, scope: 'c2c-scope', remoteId: 'c2c-remote', peerOrigin: 'c2c-origin', + }; + const c2sReplies: IPacket[] = []; + const c2cReplies: IPacket[] = []; + const collectC2s = (packet: IPacket) => c2sReplies.push(packet); + const collectC2c = (packet: IPacket) => c2cReplies.push(packet); + a.on('e2ee-response', collectC2s); + a.on('e2ee-c2c-response', collectC2c); + try { + const serverReply = receive(a, 'e2ee-response', sharedId); + const peerReply = receive(a, 'e2ee-c2c-response', sharedId); + a.emit('e2ee-request', c2s); + a.emit('e2ee-c2c-request', { roomId, payload: c2c }); + const replies = await Promise.all([serverReply, peerReply]); + for (const [index, response] of replies.entries()) { + const original = index === 0 ? c2s : c2c; + assert.equal(response.id, sharedId); + assert.equal(response.type, 'RESPONSE'); + assert.equal(response.scope, original.scope); + assert.equal(response.remoteId, original.remoteId); + assert.equal(response.peerOrigin, index === 0 ? 'e2ee-server' : original.peerOrigin); + assert.equal(response.error?.stack, undefined); + } + assert.equal(replies[0].error?.code, E2eeErrorCode.INVALID_ROOM_ID); + assert.equal(replies[1].error?.code, E2eeErrorCode.RATE_LIMIT_EXCEEDED); + await barrier(a); + assert.equal(c2sReplies.length, 1); + assert.equal(c2cReplies.length, 1); + } finally { + a.off('e2ee-response', collectC2s); + a.off('e2ee-c2c-response', collectC2c); + } + const zeroReply = receive(a, 'e2ee-c2c-response', 0); + a.emit('e2ee-c2c-request', { roomId, payload: { ...c2c, id: 0 } }); + assert.equal((await zeroReply).id, 0, 'a rejected C2C request preserves even a zero id'); + }); + + await t.test('ordinary C2C method throttling also responds on the C2C channel', async () => { + const accepted = request('legacyTransferMethod', ['synthetic-data'], 'api'); + await relay(a, b, roomId, 'e2ee-c2c-request', accepted); + const blocked = { ...accepted, id: sequence++ }; + const response = receive(a, 'e2ee-c2c-response', blocked.id); + a.emit('e2ee-c2c-request', { roomId, payload: blocked }); + const rejected = await response; + assert.equal(rejected.id, blocked.id); + assert.equal(rejected.error?.code, E2eeErrorCode.RATE_LIMIT_EXCEEDED); + assert.equal(rejected.error?.stack, undefined); + }); + + await t.test('legacy requests, transfer chunks, and peer responses each extend idle TTL', async () => { + let lastAcceptedAt = now; + const events: Array<[string, IPacket]> = [ + ['e2ee-c2c-request', request('sendTransferData', ['synthetic-data'], 'api')], + ['e2ee-c2c-request', request('sendTransferChunk', [{ transferId: 'active-chunk', index: 0, data: 'AAAA' }], 'api')], + ['e2ee-c2c-response', { id: sequence++, type: 'RESPONSE', data: 'synthetic-ack' }], + ]; + for (const [event, packet] of events) { + advance(lastAcceptedAt + ROOM_TIMEOUT * 0.75 - now); + await relay(a, b, roomId, event, packet); + lastAcceptedAt = now; + advance(ROOM_TIMEOUT * 0.5); + cleanup(); + assert(manager.isUserInRoom(roomId, a.id!).isInRoom, `${event} keeps an active session alive across its old expiry`); + } + advance(lastAcceptedAt + ROOM_TIMEOUT + 1 - now); + cleanup(); + assert.equal(manager.isUserInRoom(roomId, a.id!).isInRoom, false, 'genuinely idle rooms still expire'); + }); + + for (const kind of ['non-member', 'malformed-envelope', 'invalid-chunk', 'throttled-request'] as const) { + await t.test(`${kind} traffic cannot keep a room alive`, async () => { + advance(5000); + roomId = await createSession(); + const limited = request('same-method', [], 'api'); + if (kind === 'throttled-request') { + // A supported idle timeout shorter than the method throttle makes a + // rejection occur near expiry without mutating private room state. + roomConfig.roomTimeout = 1000; + await relay(a, b, roomId, 'e2ee-c2c-request', limited); + } + const idleTimeout = kind === 'throttled-request' ? 1000 : ROOM_TIMEOUT; + const before = now; + const peerMessages: IPacket[] = []; + const collect = (packet: IPacket) => peerMessages.push(packet); + b.on('e2ee-c2c-request', collect); + b.on('e2ee-c2c-response', collect); + try { + advance(idleTimeout - 1); + let sender = a; + if (kind === 'non-member') { + sender = outsider; + // Socket.IO membership alone is insufficient; RoomManager must also + // authorize the sender, even if an adapter still lists this socket. + await socketServer.sockets.sockets.get(outsider.id!)!.join(roomId); + sender.emit('e2ee-c2c-request', { roomId, payload: request('outsider', [], 'api') }); + sender.emit('e2ee-c2c-response', { roomId, payload: { id: sequence++, type: 'RESPONSE', data: 'forged' } }); + } else if (kind === 'malformed-envelope') { + sender.emit('e2ee-c2c-request', { roomId, payload: { id: sequence++, type: 'REQUEST', data: {} } }); + sender.emit('e2ee-c2c-response', { roomId, payload: { id: sequence++, type: 'BOGUS' } }); + } else { + const packet = kind === 'invalid-chunk' + ? request('sendTransferChunk', [{ transferId: 'invalid-chunk', index: -1, data: 'AAAA' }], 'api') + : { ...limited, id: sequence++ }; + const response = receive(sender, 'e2ee-c2c-response', packet.id); + sender.emit('e2ee-c2c-request', { roomId, payload: packet }); + assert.equal((await response).error?.code, E2eeErrorCode.RATE_LIMIT_EXCEEDED); + } + await barrier(sender); + await barrier(b); + assert.equal(peerMessages.length, 0); + advance(before + idleTimeout + 1 - now); + cleanup(); + assert.equal(manager.isUserInRoom(roomId, a.id!).isInRoom, false, `${kind} must not renew idle TTL`); + } finally { + b.off('e2ee-c2c-request', collect); + b.off('e2ee-c2c-response', collect); + roomConfig.roomTimeout = ROOM_TIMEOUT; + } + }); + } +}); diff --git a/packages/transfer-server/test/smoke.ts b/packages/transfer-server/test/smoke.ts index 875edcf..18bfd3f 100644 --- a/packages/transfer-server/test/smoke.ts +++ b/packages/transfer-server/test/smoke.ts @@ -469,15 +469,16 @@ async function main(): Promise { await checkBidirectional(clientA, clientB, room.roomId, 'after injection attempt'); const privateRoom = await outsider.callRaw('roomManager', 'getRoomUsers', [{ roomId: room.roomId }]); - const queryFlood = await outsider.callRaw('roomManager', 'getRoomUsers', [{ roomId: room.roomId }]); - check(queryFlood.error?.code === 1100, 'rapid room queries are rate limited'); - check(queryFlood.error?.stack === undefined, 'rate limit errors omit server stacks'); - await wait(900); const missingRoom = await outsider.callRaw('roomManager', 'getRoomUsers', [{ roomId: 'missing-room' }]); check( Boolean(privateRoom.error) && JSON.stringify(privateRoom.error) === JSON.stringify(missingRoom.error), 'private and missing rooms return identical errors to non-members', ); + const queryFlood = await Promise.all(Array.from({ length: 32 }, () => + outsider.callRaw('roomManager', 'getRoomUsers', [{ roomId: room.roomId }]), + )); + check(queryFlood.some((reply) => reply.error?.code === 1100), 'room queries exceeding the burst allowance are rate limited'); + check(queryFlood.every((reply) => reply.error?.stack === undefined), 'rate limit errors omit server stacks'); for (let poll = 0; poll < 3; poll += 1) { await wait(1000); const users = await clientA.callRaw('roomManager', 'getRoomUsers', [{ roomId: room.roomId }]); From 95239911c316a3d7127bde0fd985cfdd8ec2623a Mon Sep 17 00:00:00 2001 From: morizon Date: Thu, 17 Sep 2026 18:17:47 +0800 Subject: [PATCH 5/6] fix: bound relay traffic and make room admission atomic --- CLAUDE.md | 4 +- examples/mock-app/tsconfig.eslint.json | 14 -- .../cloud-sync-server/tsconfig.eslint.json | 8 - packages/transfer-server/README.md | 161 +++++++++++++----- packages/transfer-server/package.json | 2 +- .../transfer-server/src/JsBridgeE2EEServer.ts | 95 ++++++----- packages/transfer-server/src/relayPolicy.ts | 86 ++++++++++ packages/transfer-server/src/roomManager.ts | 150 ++++++++-------- packages/transfer-server/src/server.ts | 4 +- packages/transfer-server/src/types.ts | 53 +++--- packages/transfer-server/src/utils/logger.ts | 6 + .../test/relay-compatibility.ts | 87 +++++++++- packages/transfer-server/test/relay-policy.ts | 46 +++++ .../transfer-server/test/room-lifecycle.ts | 134 +++++++++++++++ packages/transfer-server/test/smoke.ts | 5 +- 15 files changed, 621 insertions(+), 234 deletions(-) delete mode 100644 examples/mock-app/tsconfig.eslint.json delete mode 100644 packages/cloud-sync-server/tsconfig.eslint.json create mode 100644 packages/transfer-server/src/relayPolicy.ts create mode 100644 packages/transfer-server/test/relay-policy.ts create mode 100644 packages/transfer-server/test/room-lifecycle.ts diff --git a/CLAUDE.md b/CLAUDE.md index 6b7f2af..85ad242 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -52,8 +52,8 @@ The transfer server is a Socket.IO-based real-time communication server with end - `utils/` - Utility functions for crypto, buffers, caching, etc. **Socket.IO Events:** -- Clientβ†’Server: `create-room`, `join-room`, `send-encrypted-data`, `leave-room`, `get-room-status`, `get-room-list` -- Serverβ†’Client: `room-created`, `room-joined`, `user-joined`, `user-left`, `encrypted-data`, `room-error`, `room-status` +- Clientβ†’Server: `e2ee-request`, `e2ee-c2c-request`, `e2ee-c2c-response` +- Serverβ†’Client: `e2ee-response`, `e2ee-c2c-request`, `e2ee-c2c-response`, `user-joined`, `user-left`, `room-full`, `start-transfer` **Configuration (via environment variables):** - `PORT` (default: 3868) diff --git a/examples/mock-app/tsconfig.eslint.json b/examples/mock-app/tsconfig.eslint.json deleted file mode 100644 index e4831be..0000000 --- a/examples/mock-app/tsconfig.eslint.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "extends": "./tsconfig.json", - "compilerOptions": { - "rootDir": "." - }, - "include": [ - "src/**/*", - "test/**/*" - ], - "exclude": [ - "node_modules", - "dist" - ] -} \ No newline at end of file diff --git a/packages/cloud-sync-server/tsconfig.eslint.json b/packages/cloud-sync-server/tsconfig.eslint.json deleted file mode 100644 index 29f9624..0000000 --- a/packages/cloud-sync-server/tsconfig.eslint.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "extends": "./tsconfig.json", - "compilerOptions": { - "noEmit": true - }, - "include": ["src/**/*", "test/**/*"], - "exclude": ["dist", "node_modules"] -} \ No newline at end of file diff --git a/packages/transfer-server/README.md b/packages/transfer-server/README.md index c70ed26..d0f68f6 100644 --- a/packages/transfer-server/README.md +++ b/packages/transfer-server/README.md @@ -65,37 +65,98 @@ MAX_MESSAGE_SIZE=10485760 ### Socket.IO Events -#### Client β†’ Server Events - -| Event | Description | Payload | -|-------|-------------|---------| -| `create-room` | Create a new room | `{ roomId?: string, metadata?: object }` | -| `join-room` | Join an existing room | `{ roomId: string, userId?: string }` | -| `send-encrypted-data` | Send encrypted data to room members | `{ data: any, targetUserId?: string }` | -| `leave-room` | Leave the current room | `{ roomId: string }` | -| `get-room-status` | Get room information | `{ roomId: string }` | -| `get-room-list` | Get list of available rooms | `{}` | - -#### Server β†’ Client Events - -| Event | Description | Payload | -|-------|-------------|---------| -| `room-created` | Room successfully created | `{ roomId: string, creatorId: string }` | -| `room-joined` | Successfully joined room | `{ roomId: string, userId: string, users: string[] }` | -| `user-joined` | Another user joined the room | `{ userId: string, users: string[] }` | -| `user-left` | User left the room | `{ userId: string, users: string[] }` | -| `encrypted-data` | Received encrypted data | `{ data: any, senderId: string }` | -| `room-error` | Error occurred | `{ code: string, message: string }` | -| `room-status` | Room status information | `{ roomId: string, users: User[], createdAt: number }` | +Room methods use the bridge request event, not separate `create-room` or +`join-room` events. A request payload contains `id`, `type: "REQUEST"`, and +`data: { module, method, params }`. Responses preserve correlation fields and +contain `type: "RESPONSE"` with `data` or `error: { name, message, code }`. +Optional bridge metadata (`origin`, `peerOrigin`, `scope`, `remoteId`) must be +strings no longer than 1024 characters. IDs, when supplied, are safe integers. + +| Direction | Event | Payload | +| --- | --- | --- | +| Client β†’ server | `e2ee-request` | Bridge request; module `roomManager` | +| Server β†’ client | `e2ee-response` | Bridge response to the server API | +| Client β†’ server | `e2ee-c2c-request` | `{ roomId, payload: }` | +| Client β†’ server | `e2ee-c2c-response` | `{ roomId, payload: }` | +| Server β†’ peer | `e2ee-c2c-request` | The unwrapped bridge REQUEST | +| Server β†’ peer | `e2ee-c2c-response` | The unwrapped bridge RESPONSE, or a relay rejection | +| Server β†’ existing members | `user-joined` | `{ roomId, userId, userCount }`; excludes the joiner | +| Server β†’ remaining members | `user-left` | `{ roomId, userId, userCount }` | +| Server β†’ room members | `room-full` | `{ roomId, userCount }` | +| Server β†’ room members | `start-transfer` | `{ roomId, fromUserId, toUserId, randomNumber }` | + +The event and bridge type must match. Both relay directions require membership +in Socket.IO and RoomManager. Authorized, accepted relay traffic renews room +activity; rejected traffic does not. Rooms expire after `ROOM_TIMEOUT` of +inactivity, checked every five minutes. + +### Room RPCs + +Use `data.module = "roomManager"`; `params` is an array of method arguments. + +| Method | First argument | Result | +| --- | --- | --- | +| `createRoom` | No arguments | `{ roomId, encryptionKey }` | +| `joinRoom`, `joinRoomAfterCreate` | `{ roomId, appPlatform, appPlatformName, appVersion, appBuildNumber, appDeviceName }` | `{ success, userId, roomId, userCount, roomKey, chunkedTransferVersion: 1 }` | +| `getRoomUsers` | `{ roomId }` | User records ordered by join time, without socket IDs | +| `leaveRoom` | `{ roomId, userId }` | `{ success, userCount, roomDestroyed }` | +| `startTransfer` | `{ roomId, fromUserId, toUserId }` | Transfer direction, or undefined when cleared | + +`getRoomUsers` returns `[]` for both nonexistent rooms and non-members. This +preserves legacy missing-room behavior without exposing room existence. It uses +a per-connection token bucket of 10 requests, replenishing 5 requests/second. +Joins reserve capacity while the adapter is pending and publish membership only +after it succeeds; disconnects and failed joins release that reservation. + +### Chunk protocol v1 and relay limits + +Chunking requires the relay join result and the peer's `getTransferType` result +to advertise `chunkedTransferVersion: 1`. Older clients/relays retain +`sendTransferData` single-message transfers. A new sender must fall back when an +old peer does not implement the capability method. + +| Limit | Value | +| --- | --- | +| Chunk data | 64 KiB Base64 ASCII (`params[0].data`) | +| Complete chunk envelope | 72 KiB JSON UTF-8 bytes, including room ID, all extra fields and bridge metadata | +| Transfer total | 64 MiB of encrypted Base64 wire data; not the original wallet data size | +| Chunk indices | 0–1023 inclusive, derived from total bytes / chunk size | +| Chunk requests | At most 512 per connection per one-second window | +| Complete response envelope | 256 KiB JSON UTF-8 bytes | +| Responses | At most 512 per connection per one-second window | +| Requests + responses | At most 1024 relay messages and 32 MiB per connection per one-second window | +| Legacy single request | Existing `MAX_MESSAGE_SIZE`, 10 MiB by default | + +The combined byte budget is at least `MAX_MESSAGE_SIZE` when a deployment +explicitly raises that setting. Rejected relay traffic also consumes the shared +budget. Malformed/binary/overly complex JSON is not forwarded (maximum depth 64, +16384 visited values). Crossing the shared budget, response rate, or response +size limit disconnects the abusive socket; the peer receives the normal leave +notification, with no response-to-response error loop. Limits are per connection; +production ingress must also bound connection counts and aggregate traffic. + +Chunk RPCs use module `api`: `beginChunkedTransfer({ transferId, totalBytes })`, +`sendTransferChunk({ transferId, index, data })`, and +`finishChunkedTransfer({ transferId })`. A chunk acknowledgement contains +`{ transferId, index, receivedBytes }`. The relay validates chunk shape and packet +size but does not assemble, decrypt, or maintain the transfer manifest. The App +checks total size before starting and the receiver checks it again on begin. +In a valid bridge request, invalid chunk parameters or packet size return `1001`; actual chunk throttling +returns `1100`, on `e2ee-c2c-response`. The relay performs no automatic retries. +Malformed bridge envelopes are discarded; response IDs are required. + +CORS reflects the requesting origin and retains `credentials: true` for browser +clients using credentialed HTTP polling, even though this service does not use +cookie authentication. CORS is not an authorization boundary. ### REST API Endpoints | Endpoint | Method | Description | -|----------|--------|-------------| -| `/health` | GET | Health check endpoint | -| `/stats` | GET | Server statistics | -| `/rooms` | GET | List all active rooms | -| `/rooms/:roomId` | GET | Get room details | +| --- | --- | --- | +| `/health` | GET | `{ message: "Health check OK: " }` | + +There are no `/stats`, `/rooms`, or `/rooms/:roomId` handlers. Room operations +use the RPC interface above. ## Architecture @@ -125,12 +186,12 @@ MAX_MESSAGE_SIZE=10485760 ### Built-in Security Features -1. **Message Size Limits**: Prevents DoS attacks by limiting message sizes +1. **Message and Traffic Limits**: Bounds per-connection relay size and throughput 2. **Room Timeouts**: Automatic cleanup of inactive rooms 3. **User Limits**: Configurable maximum users per room 4. **Room Membership Enforcement**: Client-to-client messages are relayed only for a sender that has actually joined the target room -5. **Input Validation**: Automatic validation of all API inputs +5. **Input Validation**: Bridge type, metadata, membership, and chunk validation ### Best Practices @@ -189,25 +250,32 @@ yarn start # Start production server ### Testing ```bash -# Run tests (when implemented) +# Build and run TCP smoke, crash logging, relay policy, and lifecycle tests yarn test -# Run tests with coverage -yarn test:coverage +# Run the relay compatibility and lifecycle suite +yarn test:compatibility ``` ## Error Handling -The server implements a comprehensive error handling system with specific error codes: +Server-generated errors omit stack traces on the wire; server logs retain them. +Peer errors are relayed as peer data. + +| Code | Meaning | +| --- | --- | +| `1001` | Invalid parameter or chunk packet size; retrying unchanged input does not help | +| `1002` | Operation failed, including a closed connection during join | +| `1100` | Per-method or chunk rate limit | +| `1700` | Missing server-side socket context | +| `1701` | Invalid room ID | +| `1702` | Room not found for room operations other than the privacy-preserving user query | +| `1703` | Connection rejected because the room has no available slot | +| `1704` | User not found | +| `1705` | Socket is not in the room for an operation requiring membership | +| `1706` | Transfer participants are not both room members | -| Error Code | Description | -|------------|-------------| -| `ROOM_NOT_FOUND` | Requested room does not exist | -| `ROOM_FULL` | Room has reached maximum capacity | -| `UNAUTHORIZED` | User not authorized for this operation | -| `INVALID_DATA` | Invalid data format or content | -| `TIMEOUT` | Operation timed out | -| `INTERNAL_ERROR` | Internal server error | +See `src/errors.ts` for the complete code list. ## Performance Optimization @@ -257,11 +325,10 @@ module.exports = { curl http://localhost:3868/health ``` -### Server Statistics +### Room Lifecycle -```bash -curl http://localhost:3868/stats -``` +Monitor the structured `room.created`, `room.joined`, `room.left`, and +`room.expiredCleaned` log events. There is no statistics HTTP endpoint. ## Troubleshooting @@ -287,7 +354,7 @@ curl http://localhost:3868/stats - Check WebSocket support in reverse proxy configuration 4. **Memory Leaks** - - Monitor room cleanup with `/stats` endpoint + - Monitor the structured `room.expiredCleaned` log events - Ensure `ROOM_TIMEOUT` is configured appropriately ## Contributing @@ -304,4 +371,4 @@ This project is part of the OneKey ecosystem. ## Support -For issues and questions, please open an issue on GitHub or contact the OneKey development team. \ No newline at end of file +For issues and questions, please open an issue on GitHub or contact the OneKey development team. diff --git a/packages/transfer-server/package.json b/packages/transfer-server/package.json index 27750e5..063f6a8 100644 --- a/packages/transfer-server/package.json +++ b/packages/transfer-server/package.json @@ -10,7 +10,7 @@ "dev": "nodemon src/server.ts", "clean": "rimraf dist", "test": "yarn build && ts-node --project test/tsconfig.json test/smoke.ts && ts-node --project test/tsconfig.json test/crash-logging.ts && yarn test:compatibility", - "test:compatibility": "TS_NODE_PROJECT=test/tsconfig.json node --require ts-node/register --test --test-force-exit test/relay-compatibility.ts", + "test:compatibility": "TS_NODE_PROJECT=test/tsconfig.json node --require ts-node/register --test --test-force-exit test/relay-compatibility.ts test/relay-policy.ts test/room-lifecycle.ts", "test:crash": "yarn build && ts-node --project test/tsconfig.json test/crash-logging.ts", "postinstall": "echo 'e2ee-server dependencies installed'", "lint": "eslint src test --max-warnings 0" diff --git a/packages/transfer-server/src/JsBridgeE2EEServer.ts b/packages/transfer-server/src/JsBridgeE2EEServer.ts index eb7aac7..14af13b 100644 --- a/packages/transfer-server/src/JsBridgeE2EEServer.ts +++ b/packages/transfer-server/src/JsBridgeE2EEServer.ts @@ -2,7 +2,8 @@ import { JsBridgeBase } from '@onekeyfe/cross-inpage-provider-core'; import { IJsBridgeMessageTypes } from '@onekeyfe/cross-inpage-provider-types'; import { E2eeError, E2eeErrorCode } from './errors'; -import { createModuleLogger } from './utils/logger'; +import { CHUNK_PACKET_BYTES, RESPONSE_PACKET_BYTES, RELAY_BYTES_PER_SECOND, RelayTrafficBudget, isValidTransferChunk, measureJsonBytes } from './relayPolicy'; +import { capForLog, createModuleLogger } from './utils/logger'; import type { IJsBridgeConfig, @@ -21,26 +22,12 @@ const RATE_LIMIT_INTERVAL_MS = 3000; // Well above the number of methods a real client calls. const RATE_LIMIT_MAX_TRACKED_METHODS = 64; -// Log lines carry client-controlled strings, which can be as large as -// maxHttpBufferSize (10MB). pino writes to fd 1 synchronously, so a verbatim -// field turns a rejected packet into disk and log-pipeline amplification - -// measured at 954MB of log output from three seconds of traffic. -const LOG_FIELD_MAX_LENGTH = 64; - // Rejecting a payload happens before rate limiting can apply (a malformed // packet may carry no method to limit on), so the log itself has to be capped // per connection or it can be triggered at socket speed. const INVALID_PAYLOAD_LOG_INTERVAL_MS = 1000; const INVALID_PAYLOAD_LOG_BURST = 5; -/** Truncate a client-controlled value before it reaches a log line. */ -function capForLog(value: unknown, max: number = LOG_FIELD_MAX_LENGTH): string | null { - if (typeof value !== 'string') { - return null; - } - return value.length > max ? `${value.slice(0, max)}...(${value.length})` : value; -} - // Rate limiting whitelist - methods that are exempt from rate limiting const RATE_LIMIT_WHITELIST = new Set([ 'changeTransferDirection', @@ -57,11 +44,6 @@ const ROOM_USERS_RATE_LIMIT_REFILL_PER_MS = 5 / 1000; type IResponseEvent = 'e2ee-response' | 'e2ee-c2c-response'; type IRequestEvent = 'e2ee-request' | 'e2ee-c2c-request'; -const SUPPORTED_MESSAGE_TYPES: ReadonlySet = new Set([ - IJsBridgeMessageTypes.REQUEST, - IJsBridgeMessageTypes.RESPONSE, -]); - type IPayloadCheckResult = | { valid: true; payload: IJsBridgeMessagePayload } | { valid: false; reason: string }; @@ -83,8 +65,19 @@ function checkBridgePayload( const payload = raw as IJsBridgeMessagePayload; - if (!payload.type || !SUPPORTED_MESSAGE_TYPES.has(payload.type)) { - return { valid: false, reason: 'payload.type is missing or unsupported' }; + const expectedType = requireMethod ? IJsBridgeMessageTypes.REQUEST : IJsBridgeMessageTypes.RESPONSE; + if (payload.type !== expectedType) { + return { valid: false, reason: 'payload.type does not match its event' }; + } + if ((!requireMethod && payload.id === undefined) || (payload.id !== undefined && + !(typeof payload.id === 'number' && Number.isSafeInteger(payload.id)))) { + return { valid: false, reason: 'payload.id is invalid' }; + } + for (const key of ['scope', 'remoteId', 'peerOrigin', 'origin'] as const) { + const value = payload[key]; + if (value !== undefined && (typeof value !== 'string' || value.length > 1024)) { + return { valid: false, reason: 'bridge metadata is invalid or too large' }; + } } if (requireMethod) { @@ -129,6 +122,8 @@ export class JsBridgeE2EEServer extends JsBridgeBase { private roomUsersRateLimitState?: { tokens: number; updatedAt: number }; + private readonly relayTraffic = new RelayTrafficBudget(); + private chunkWindowStartedAt = 0; private chunkRequestsInWindow = 0; @@ -323,27 +318,7 @@ export class JsBridgeE2EEServer extends JsBridgeBase { this.chunkRequestsInWindow = 0; } this.chunkRequestsInWindow += 1; - const params = req?.params; - const chunk = (Array.isArray(params) ? params[0] : undefined) as - | { data?: unknown; transferId?: unknown; index?: unknown } - | undefined; - if ( - this.chunkRequestsInWindow > 512 || - !Array.isArray(params) || - params.length !== 1 || - !chunk || - Object.keys(chunk).length !== 3 || - typeof chunk.transferId !== 'string' || - !/^[a-zA-Z0-9-]{1,64}$/.test(chunk.transferId) || - typeof chunk.index !== 'number' || - !Number.isSafeInteger(chunk.index) || - chunk.index < 0 || - chunk.index >= 1024 || - typeof chunk.data !== 'string' || - chunk.data.length === 0 || - chunk.data.length > 64 * 1024 || - !/^[A-Za-z0-9+/]*={0,2}$/.test(chunk.data) - ) { + if (this.chunkRequestsInWindow > 512) { sendErrorResponse(); return true; } @@ -464,6 +439,8 @@ export class JsBridgeE2EEServer extends JsBridgeBase { this.socketClient.on( 'e2ee-c2c-request', this.safeHandler('e2ee-c2c-request', (raw) => { + const bytes = this.checkRelayTraffic(raw, false); + if (bytes === undefined) return; const envelope = this.checkC2cEnvelope('e2ee-c2c-request', raw, { requireMethod: true, }); @@ -471,6 +448,14 @@ export class JsBridgeE2EEServer extends JsBridgeBase { return; } const { payload: p, roomId } = envelope; + const request = p.data as IJsonRpcRequest; + if (request.method === 'sendTransferChunk' && + (bytes > CHUNK_PACKET_BYTES || !isValidTransferChunk(request.params))) { + this.sendRequestError('e2ee-c2c-request', p, new E2eeError( + E2eeErrorCode.INVALID_PARAMETER, 'Invalid transfer chunk or packet size', + )); + return; + } const isRateLimited = this.checkIsRateLimited({ payload: p, @@ -490,7 +475,15 @@ export class JsBridgeE2EEServer extends JsBridgeBase { this.socketClient.on( 'e2ee-c2c-response', this.safeHandler('e2ee-c2c-response', (raw) => { - // a response carries a result rather than a method, so `method` is not required + const bytes = this.checkRelayTraffic(raw, true); + if (bytes === undefined) return; + if (bytes > RESPONSE_PACKET_BYTES) { + // A response cannot be answered with another response. Disconnecting + // gives peers a terminal transport signal without an error loop. + this.socketClient.disconnect(true); + return; + } + // A response carries a result rather than a method. const envelope = this.checkC2cEnvelope('e2ee-c2c-response', raw, { requireMethod: false, }); @@ -505,6 +498,20 @@ export class JsBridgeE2EEServer extends JsBridgeBase { ); } + private checkRelayTraffic(raw: unknown, response: boolean): number | undefined { + const packetLimit = this.roomManager.maxMessageSize; + const bytes = raw === undefined ? 0 : measureJsonBytes(raw, packetLimit); + if (!this.relayTraffic.consume( + bytes ?? packetLimit, response, Math.max(RELAY_BYTES_PER_SECOND, packetLimit), + )) { + // Stop parsing and responding to a sustained flood on this connection. + this.socketClient.disconnect(true); + return undefined; + } + if (bytes === undefined) this.logInvalidPayload('relay', raw, 'packet exceeds JSON limits'); + return bytes; + } + /** * Client-to-client events are wrapped in a `{ payload, roomId }` envelope. * Destructuring it blindly throws when the client emits the event with no diff --git a/packages/transfer-server/src/relayPolicy.ts b/packages/transfer-server/src/relayPolicy.ts new file mode 100644 index 0000000..6ccad6f --- /dev/null +++ b/packages/transfer-server/src/relayPolicy.ts @@ -0,0 +1,86 @@ +export const TRANSFER_CHUNK_BYTES = 64 * 1024; +export const TRANSFER_MAX_BYTES = 64 * 1024 * 1024; +export const TRANSFER_MAX_CHUNKS = Math.ceil(TRANSFER_MAX_BYTES / TRANSFER_CHUNK_BYTES); +export const CHUNK_PACKET_BYTES = TRANSFER_CHUNK_BYTES + 8 * 1024; +export const RESPONSE_PACKET_BYTES = 256 * 1024; +export const RELAY_MESSAGES_PER_SECOND = 1024; +export const RELAY_RESPONSES_PER_SECOND = 512; +export const RELAY_BYTES_PER_SECOND = 32 * 1024 * 1024; + +/** Count the entire JSON envelope without building another full packet string. */ +export function measureJsonBytes(value: unknown, limit: number): number | undefined { + let bytes = 0; + let nodes = 0; + const addString = (text: string) => { + // Avoid escaping a large string when even its unescaped form cannot fit. + if (Buffer.byteLength(text) + bytes + 2 > limit) return false; + bytes += Buffer.byteLength(JSON.stringify(text)); + return bytes <= limit; + }; + const visit = (item: unknown, depth: number): boolean => { + nodes += 1; + if (depth > 64 || nodes > 16_384 || bytes > limit) return false; + if (item === null) bytes += 4; + else if (typeof item === 'string') return addString(item); + else if (typeof item === 'boolean') bytes += item ? 4 : 5; + else if (typeof item === 'number' && Number.isFinite(item)) bytes += String(item).length; + else if (Array.isArray(item)) { + bytes += 2; + for (let i = 0; i < item.length; i += 1) { + if (i) bytes += 1; + if (!visit(item[i] ?? null, depth + 1)) return false; + } + } else if (item && typeof item === 'object' && + (Object.getPrototypeOf(item) === Object.prototype || Object.getPrototypeOf(item) === null)) { + bytes += 2; + let first = true; + for (const key in item) { + if (!Object.prototype.hasOwnProperty.call(item, key)) continue; + const entry = (item as Record)[key]; + if (entry === undefined) continue; + if (!first) bytes += 1; + first = false; + if (!addString(key)) return false; + bytes += 1; + if (!visit(entry, depth + 1)) return false; + } + } else return false; + return bytes <= limit; + }; + return visit(value, 0) ? bytes : undefined; +} + +export function isValidTransferChunk(params: unknown): boolean { + if (!Array.isArray(params) || params.length !== 1) return false; + const chunk = params[0] as { transferId?: unknown; index?: unknown; data?: unknown } | undefined; + return Boolean(chunk && typeof chunk === 'object' && !Array.isArray(chunk) && + Object.keys(chunk).length === 3 && + typeof chunk.transferId === 'string' && /^[a-zA-Z0-9-]{1,64}$/.test(chunk.transferId) && + typeof chunk.index === 'number' && Number.isSafeInteger(chunk.index) && + chunk.index >= 0 && chunk.index < TRANSFER_MAX_CHUNKS && + typeof chunk.data === 'string' && chunk.data.length > 0 && + chunk.data.length <= TRANSFER_CHUNK_BYTES && /^[A-Za-z0-9+/]*={0,2}$/.test(chunk.data)); +} + +/** One bounded budget for both relay directions, including rejected packets. */ +export class RelayTrafficBudget { + private startedAt = 0; + private messages = 0; + private responses = 0; + private bytes = 0; + + consume(bytes: number, response: boolean, byteLimit: number): boolean { + const now = Date.now(); + if (now - this.startedAt >= 1000) { + this.startedAt = now; + this.messages = 0; + this.responses = 0; + this.bytes = 0; + } + this.messages += 1; + this.responses += response ? 1 : 0; + this.bytes += bytes; + return this.messages <= RELAY_MESSAGES_PER_SECOND && + this.responses <= RELAY_RESPONSES_PER_SECOND && this.bytes <= byteLimit; + } +} diff --git a/packages/transfer-server/src/roomManager.ts b/packages/transfer-server/src/roomManager.ts index 23cb63e..e413e87 100644 --- a/packages/transfer-server/src/roomManager.ts +++ b/packages/transfer-server/src/roomManager.ts @@ -3,7 +3,7 @@ import { sortBy } from "lodash"; import { e2eeApiMethod } from "./decorators/e2eeApiMethod"; import { E2eeError, E2eeErrorCode } from "./errors"; import cryptoUtils from "./utils/cryptoUtils"; -import { createModuleLogger } from "./utils/logger"; +import { capForLog, createModuleLogger } from "./utils/logger"; import stringUtils from "./utils/stringUtils"; import timerUtils from "./utils/timerUtils"; @@ -21,6 +21,12 @@ export class RoomManager { private config: IRoomConfig; + private readonly pendingJoins = new WeakMap>(); + + get maxMessageSize(): number { + return this.config.maxMessageSize; + } + private socketServer: SocketIOServer; private cleanupInterval: NodeJS.Timeout | number; @@ -154,71 +160,74 @@ export class RoomManager { throw new E2eeError(E2eeErrorCode.ROOM_NOT_FOUND, "Room not found"); } - // Check if room is full - if (room.users.size >= room.maxUsers) { - this.socketServer.to(roomId).emit("room-full", { - roomId, - userCount: room.users.size, - }); - - throw new E2eeError( - E2eeErrorCode.CONNECTION_REJECTED, - "Connection Rejected" - ); + const socket = context.socketClient; + if (!socket.connected) { + throw new E2eeError(E2eeErrorCode.OPERATION_FAILED, "Connection closed during room join"); } - // Check if user is already in room (by socketId) + // Rejoining an existing membership is idempotent even when the room is full. for (const [userId, userInfo] of room.users) { - if (userInfo.socketId === socketId) { - return { - success: true, - userId, - roomId, - roomKey: room.encryptionKey, - chunkedTransferVersion: 1, - userCount: room.users.size, - }; - } + if (userInfo.socketId === socketId) return this.buildJoinResult(room, userId); + } + const pending = this.pendingJoins.get(room) ?? new Set(); + this.pendingJoins.set(room, pending); + if (pending.has(socket)) { + throw new E2eeError(E2eeErrorCode.OPERATION_FAILED, "Room join already in progress"); + } + if (room.users.size + pending.size >= room.maxUsers) { + this.socketServer.to(roomId).emit("room-full", { roomId, userCount: room.users.size }); + throw new E2eeError(E2eeErrorCode.CONNECTION_REJECTED, "Connection Rejected"); } - // Create new user - const userId = cryptoUtils.generateUserId(); - const userInfo: IE2EESocketUserInfo = { - id: userId, - socketId, - joinedAt: new Date(), - appPlatformName: params.appPlatformName, - appVersion: params.appVersion, - appBuildNumber: params.appBuildNumber, - appPlatform: params.appPlatform, - appDeviceName: params.appDeviceName, - }; - - room.users.set(userId, userInfo); - room.lastActivity = new Date(); - - logger.info({ userId, roomId, userCount: room.users.size }, "room.joined"); - - await context?.socketClient.join(roomId); - - // Tell the members already in the room that someone joined. The server - // never pushed this before, so a peer that needed to know had to poll - // getRoomUsers instead - which is why that method carries a high call rate. - // - // Emitted from the joining socket rather than the server, so the joiner is - // excluded (it does not need to be told about itself), and only after - // join() resolves, so the membership the event describes is already in - // effect if a receiver immediately calls getRoomUsers. - context?.socketClient.to(roomId).emit("user-joined", { - roomId, - userId, - userCount: room.users.size, - }); + // Reserve capacity without exposing a member before the adapter commits. + // A disconnect releases the reservation even if an adapter is still pending. + pending.add(socket); + const releaseReservation = () => { pending.delete(socket); }; + socket.once("disconnect", releaseReservation); + let addedUserId: string | undefined; + try { + await socket.join(roomId); + if (!socket.connected || this.rooms.get(roomId) !== room || !socket.rooms.has(roomId)) { + throw new E2eeError(E2eeErrorCode.OPERATION_FAILED, "Connection closed during room join"); + } + const userId = cryptoUtils.generateUserId(); + const userInfo: IE2EESocketUserInfo = { + id: userId, + socketId, + joinedAt: new Date(), + appPlatformName: params.appPlatformName, + appVersion: params.appVersion, + appBuildNumber: params.appBuildNumber, + appPlatform: params.appPlatform, + appDeviceName: params.appDeviceName, + }; + room.users.set(userId, userInfo); + addedUserId = userId; + room.lastActivity = new Date(); + logger.info({ userId, roomId, userCount: room.users.size }, "room.joined"); + socket.to(roomId).emit("user-joined", { roomId, userId, userCount: room.users.size }); + return this.buildJoinResult(room, userId); + } catch (error) { + if (addedUserId) room.users.delete(addedUserId); + // Some adapters can mutate their room set before rejecting join(). Do + // not leave delivery membership behind after a failed admission. + try { + await socket.leave(roomId); + } catch { + socket.disconnect(true); + } + throw error; + } finally { + releaseReservation(); + socket.off("disconnect", releaseReservation); + } + } + private buildJoinResult(room: IRoom, userId: string) { return { success: true, userId, - roomId, + roomId: room.id, userCount: room.users.size, roomKey: room.encryptionKey, chunkedTransferVersion: 1, @@ -339,34 +348,23 @@ export class RoomManager { "context is required" ); } - logger.debug({ roomId }, "room.getRoomUsers"); - - // A room the caller cannot see must be indistinguishable from a room that - // does not exist. Returning [] for a missing room while throwing for a room - // the caller is not in turned this into a room existence oracle, previously - // exposed without a rate limit. - // isUserInRoom() already reports false for a missing room, so both cases - // fail here identically, with the same error code and message. + logger.debug({ roomId: capForLog(roomId) }, "room.getRoomUsers"); + + // Preserve the legacy missing-room result without revealing whether a + // room exists to non-members. Both cases expose the same empty list. const socketValidation = this.isUserInRoom(roomId, context.socketClient.id); if (!socketValidation.isInRoom) { - logger.debug( - { roomId, roomExists: this.rooms.has(roomId) }, - "room.getRoomUsersDenied" - ); - throw new E2eeError(E2eeErrorCode.ROOM_NOT_FOUND, "Room not found"); + logger.debug({ roomId: capForLog(roomId) }, "room.getRoomUsersUnavailable"); + return []; } - - // isInRoom implies the room exists. const room = this.rooms.get(roomId); - if (!room) { - throw new E2eeError(E2eeErrorCode.ROOM_NOT_FOUND, "Room not found"); - } + if (!room) return []; const users: IE2EESocketUserInfo[] = sortBy( Array.from(room.users.values()), (item) => item.joinedAt.getTime() ); - logger.debug({ roomId, userCount: users.length }, "room.getRoomUsersResult"); + logger.debug({ roomId: capForLog(roomId), userCount: users.length }, "room.getRoomUsersResult"); return users.map((item) => ({ ...item, socketId: undefined, diff --git a/packages/transfer-server/src/server.ts b/packages/transfer-server/src/server.ts index ec1c0cd..d7c0b32 100644 --- a/packages/transfer-server/src/server.ts +++ b/packages/transfer-server/src/server.ts @@ -62,12 +62,14 @@ class E2EEServer { this.setupRoutes(); // Native clients may omit Origin and desktop clients may send "null". - // Connections carry no cookie credentials; Origin is not authorization. + // Keep credentialed polling compatible even though this service uses no + // cookie authentication. Origin is not authorization. // Preserve the previous allow-all behavior, which never enforced its // configured allowlist. Relay access is checked through room membership. this.corsOptions = { origin: true, methods: ['GET', 'POST'], + credentials: true, }; this.socketServer = new SocketIOServer< diff --git a/packages/transfer-server/src/types.ts b/packages/transfer-server/src/types.ts index fabfa10..a93b3db 100644 --- a/packages/transfer-server/src/types.ts +++ b/packages/transfer-server/src/types.ts @@ -4,46 +4,31 @@ import type { IJsBridgeMessagePayload } from '@onekeyfe/cross-inpage-provider-ty // Export error classes export { E2eeError, E2eeErrorCode } from './errors'; -// Socket.IO event type definitions +// Client relay input wraps the bridge payload; peer output is unwrapped. +export interface IRelayEnvelope { + roomId: string; + payload: IJsBridgeMessagePayload; +} + export interface IServerToClientEvents { - 'room-created': (data: { roomId: string; encryptionKey: string }) => void; - 'room-joined': (data: { roomId: string; userId: string }) => void; - // roomId is part of both payloads: a client multiplexes every room over one - // socket, so it needs to tell which room an event refers to. `user-left` has - // always been emitted with it - the declaration was simply out of date. - 'user-joined': (data: { - roomId: string; - userId: string; - userCount: number; - }) => void; - 'user-left': (data: { + 'e2ee-response': (payload: IJsBridgeMessagePayload) => void; + 'e2ee-c2c-request': (payload: IJsBridgeMessagePayload) => void; + 'e2ee-c2c-response': (payload: IJsBridgeMessagePayload) => void; + 'user-joined': (data: { roomId: string; userId: string; userCount: number }) => void; + 'user-left': (data: { roomId: string; userId: string; userCount: number }) => void; + 'room-full': (data: { roomId: string; userCount: number }) => void; + 'start-transfer': (data: { roomId: string; - userId: string; - userCount: number; + fromUserId: string; + toUserId: string; + randomNumber: string; }) => void; - 'encrypted-data': (data: { - encryptedData: string; - senderId: string; - timestamp: number; - }) => void; - 'room-error': (data: { error: string }) => void; - 'room-status': (data: { userCount: number; users: string[] }) => void; - 'room-list': (data: { rooms: IRoomListItem[] }) => void; } export interface IClientToServerEvents { - 'e2ee-request': (event: string, payload: IJsBridgeMessagePayload) => void; - 'e2ee-response': (event: string, payload: unknown) => void; - - 'create-room': () => void; - 'join-room': (data: { roomId: string; encryptionKey: string }) => void; - 'send-encrypted-data': (data: { - roomId: string; - encryptedData: string; - }) => void; - 'leave-room': (data: { roomId: string }) => void; - 'get-room-status': (data: { roomId: string }) => void; - 'get-room-list': () => void; + 'e2ee-request': (payload: IJsBridgeMessagePayload) => void; + 'e2ee-c2c-request': (envelope: IRelayEnvelope) => void; + 'e2ee-c2c-response': (envelope: IRelayEnvelope) => void; } export interface IInterServerEvents { diff --git a/packages/transfer-server/src/utils/logger.ts b/packages/transfer-server/src/utils/logger.ts index 78706eb..cc92f0a 100644 --- a/packages/transfer-server/src/utils/logger.ts +++ b/packages/transfer-server/src/utils/logger.ts @@ -6,6 +6,12 @@ import type { DestinationStream, Logger } from 'pino'; const LOG_LEVEL = process.env.LOG_LEVEL || 'info'; +/** Bound client-controlled log fields before synchronous output. */ +export function capForLog(value: unknown, max = 64): string | null { + if (typeof value !== 'string') return null; + return value.length > max ? `${value.slice(0, max)}...(${value.length})` : value; +} + // Pretty output is opt-in and only meant for local development: // `pino-pretty` is a devDependency and must never be a hard requirement at runtime. const LOG_PRETTY = diff --git a/packages/transfer-server/test/relay-compatibility.ts b/packages/transfer-server/test/relay-compatibility.ts index b0c8a6c..dcf3aca 100644 --- a/packages/transfer-server/test/relay-compatibility.ts +++ b/packages/transfer-server/test/relay-compatibility.ts @@ -60,11 +60,12 @@ test('relay compatibility over real Socket.IO with a controlled activity clock', const httpServer = createServer(); // Keep transport heartbeats beyond the simulated timeline so these tests // isolate RoomManager's idle TTL rather than the client's ping deadline. - const socketServer = new Server(httpServer, { pingInterval: 3_600_000, pingTimeout: 3_600_000 }); + const socketServer = new Server(httpServer, { pingInterval: 3_600_000, pingTimeout: 3_600_000, maxHttpBufferSize: 10 * 1024 * 1024 }); const roomConfig = { maxUsers: 2, roomTimeout: ROOM_TIMEOUT, maxMessageSize: 10 * 1024 * 1024 }; const manager = new RoomManager({ config: roomConfig, socketServer }); socketServer.on('connection', (socketClient) => { e2eeServerApiSetup({ socketClient, roomManager: manager }); + socketClient.on("disconnect", () => { void manager.leaveRoomBySocket(socketClient); }); // A test-only barrier confirms all preceding synchronous relay handlers // ran, including intentionally silent drops. It changes no production API. socketClient.on('test-barrier', (ack: () => void) => ack()); @@ -149,8 +150,10 @@ test('relay compatibility over real Socket.IO with a controlled activity clock', assert.equal((await call(a, 'getRoomUsers', [{ roomId }])).error, undefined, 'one token refills every 200ms'); const privateRoom = await call(outsider, 'getRoomUsers', [{ roomId }]); const missingRoom = await call(outsider, 'getRoomUsers', [{ roomId: 'missing-room' }]); - assert.equal(privateRoom.error?.code, E2eeErrorCode.ROOM_NOT_FOUND); - assert.deepEqual(privateRoom.error, missingRoom.error); + assert.equal(privateRoom.error, undefined); + assert.equal(missingRoom.error, undefined); + assert.deepEqual(privateRoom.data, []); + assert.deepEqual(privateRoom.data, missingRoom.data); }); await t.test('concurrent C2S and rejected C2C requests with the same id keep separate error channels', async () => { @@ -186,7 +189,7 @@ test('relay compatibility over real Socket.IO with a controlled activity clock', assert.equal(response.error?.stack, undefined); } assert.equal(replies[0].error?.code, E2eeErrorCode.INVALID_ROOM_ID); - assert.equal(replies[1].error?.code, E2eeErrorCode.RATE_LIMIT_EXCEEDED); + assert.equal(replies[1].error?.code, E2eeErrorCode.INVALID_PARAMETER); await barrier(a); assert.equal(c2sReplies.length, 1); assert.equal(c2cReplies.length, 1); @@ -211,6 +214,54 @@ test('relay compatibility over real Socket.IO with a controlled activity clock', assert.equal(rejected.error?.stack, undefined); }); + await t.test('all chunk envelope levels are bounded while valid maximum chunks and legacy payloads pass', async () => { + const padding = 'A'.repeat(9 * 1024 * 1024); + const seen: IPacket[] = []; + const collect = (packet: IPacket) => seen.push(packet); + b.on('e2ee-c2c-request', collect); + try { + for (const level of ['envelope', 'payload', 'rpc'] as const) { + advance(2000); + const packet = request('sendTransferChunk', [{ transferId: 'size-check', index: 0, data: 'AAAA' }], 'api'); + const envelope = { roomId, payload: packet }; + const target = level === 'envelope' ? envelope : level === 'payload' ? packet : packet.data; + Object.assign(target as object, { padding }); + const response = receive(a, 'e2ee-c2c-response', packet.id); + a.emit('e2ee-c2c-request', envelope); + const rejected = await response; + assert.equal(rejected.error?.code, E2eeErrorCode.INVALID_PARAMETER); + assert.equal(rejected.error?.stack, undefined); + } + await barrier(b); + assert.equal(seen.length, 0, 'padding never reaches the peer'); + advance(2000); + const maximum = request('sendTransferChunk', [{ transferId: 'size-check', index: 1023, data: 'A'.repeat(64 * 1024) }], 'api'); + await relay(a, b, roomId, 'e2ee-c2c-request', maximum); + const outOfRange = request('sendTransferChunk', [{ transferId: 'size-check', index: 1024, data: 'AAAA' }], 'api'); + const rangeReply = receive(a, 'e2ee-c2c-response', outOfRange.id); + a.emit('e2ee-c2c-request', { roomId, payload: outOfRange }); + assert.equal((await rangeReply).error?.code, E2eeErrorCode.INVALID_PARAMETER); + advance(2000); + await relay(a, b, roomId, 'e2ee-c2c-request', request('sendTransferData', [{ rawData: padding }], 'api')); + } finally { b.off('e2ee-c2c-request', collect); } + }); + + await t.test('request and response events cannot exchange payload types', async () => { + advance(2000); + const seen: IPacket[] = []; + const collect = (packet: IPacket) => seen.push(packet); + b.on('e2ee-c2c-request', collect); + b.on('e2ee-c2c-response', collect); + a.emit('e2ee-c2c-response', { roomId, payload: request('sendTransferChunk', [{ index: 1024, data: 'AAAA', transferId: 'bypass' }], 'api') }); + a.emit('e2ee-c2c-response', { roomId, payload: { type: 'RESPONSE', data: 'missing-id' } }); + a.emit('e2ee-c2c-request', { roomId, payload: { ...request('cancelTransfer', [], 'api'), type: 'RESPONSE' } }); + await barrier(a); + await barrier(b); + assert.equal(seen.length, 0); + b.off('e2ee-c2c-request', collect); + b.off('e2ee-c2c-response', collect); + }); + await t.test('legacy requests, transfer chunks, and peer responses each extend idle TTL', async () => { let lastAcceptedAt = now; const events: Array<[string, IPacket]> = [ @@ -267,7 +318,8 @@ test('relay compatibility over real Socket.IO with a controlled activity clock', : { ...limited, id: sequence++ }; const response = receive(sender, 'e2ee-c2c-response', packet.id); sender.emit('e2ee-c2c-request', { roomId, payload: packet }); - assert.equal((await response).error?.code, E2eeErrorCode.RATE_LIMIT_EXCEEDED); + assert.equal((await response).error?.code, kind === 'invalid-chunk' + ? E2eeErrorCode.INVALID_PARAMETER : E2eeErrorCode.RATE_LIMIT_EXCEEDED); } await barrier(sender); await barrier(b); @@ -282,4 +334,29 @@ test('relay compatibility over real Socket.IO with a controlled activity clock', } }); } + for (const mode of ['oversized', 'flood'] as const) { + await t.test(`response ${mode} is bounded and closes only the abusive connection`, async () => { + advance(5000); + const [sender, recipient] = await Promise.all([connect(), connect()]); + const created = await call(sender, 'createRoom'); + const isolatedRoom = (created.data as { roomId: string }).roomId; + await call(sender, 'joinRoomAfterCreate', [{ roomId: isolatedRoom, ...appInfo }]); + await call(recipient, 'joinRoom', [{ roomId: isolatedRoom, ...appInfo }]); + const received: IPacket[] = []; + recipient.on('e2ee-c2c-response', (packet: IPacket) => received.push(packet)); + const disconnected = new Promise((resolve) => sender.once('disconnect', () => resolve())); + const count = mode === 'flood' ? 520 : 1; + for (let index = 0; index < count; index += 1) { + sender.emit('e2ee-c2c-response', { roomId: isolatedRoom, payload: { + id: sequence++, type: 'RESPONSE', data: mode === 'oversized' ? 'A'.repeat(300 * 1024) : 'synthetic-ack', + } }); + } + await disconnected; + await barrier(recipient); + assert.equal(received.length, mode === 'flood' ? 512 : 0); + assert.equal(recipient.connected, true); + assert.equal((await call(recipient, 'getRoomUsers', [{ roomId: isolatedRoom }])).error, undefined); + }); + } + }); diff --git a/packages/transfer-server/test/relay-policy.ts b/packages/transfer-server/test/relay-policy.ts new file mode 100644 index 0000000..aadc9b0 --- /dev/null +++ b/packages/transfer-server/test/relay-policy.ts @@ -0,0 +1,46 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; + +import { + CHUNK_PACKET_BYTES, RELAY_BYTES_PER_SECOND, RelayTrafficBudget, + TRANSFER_MAX_BYTES, TRANSFER_CHUNK_BYTES, TRANSFER_MAX_CHUNKS, measureJsonBytes, +} from '../src/relayPolicy'; +import { capForLog } from '../src/utils/logger'; + +test('packet accounting includes JSON escaping, UTF-8, keys, arrays, and outer metadata', () => { + const packet = { roomId: 'room', payload: { origin: 'https://example.test', data: ['δΈ­ζ–‡', '\u0000"\\\n', 123, true, null, { field: 'value' }], omitted: undefined } }; + const bytes = Buffer.byteLength(JSON.stringify(packet)); + assert.equal(measureJsonBytes(packet, bytes), bytes); + assert.equal(measureJsonBytes(packet, bytes - 1), undefined); + assert.equal(measureJsonBytes({ padding: 'A'.repeat(9 * 1024 * 1024) }, CHUNK_PACKET_BYTES), undefined); + assert.equal(measureJsonBytes({ binary: Buffer.alloc(10) }, 1000), undefined); + const circular: { self?: unknown } = {}; + circular.self = circular; + assert.equal(measureJsonBytes(circular, 1000), undefined); +}); + +test('request and response bytes share one budget, with a fresh window after idle', (t) => { + t.mock.timers.enable({ apis: ['Date'], now: 1_700_000_000_000 }); + const budget = new RelayTrafficBudget(); + assert.equal(budget.consume(9 * 1024 * 1024, false, RELAY_BYTES_PER_SECOND), true); + assert.equal(budget.consume(9 * 1024 * 1024, true, RELAY_BYTES_PER_SECOND), true); + assert.equal(budget.consume(9 * 1024 * 1024, false, RELAY_BYTES_PER_SECOND), true); + assert.equal(budget.consume(9 * 1024 * 1024, true, RELAY_BYTES_PER_SECOND), false); + t.mock.timers.tick(1000); + assert.equal(budget.consume(9 * 1024 * 1024, false, RELAY_BYTES_PER_SECOND), true); +}); + +test('mixed request/response traffic cannot bypass the aggregate message budget', () => { + const budget = new RelayTrafficBudget(); + for (let index = 0; index < 1024; index += 1) { + assert.equal(budget.consume(1, index % 2 === 0, RELAY_BYTES_PER_SECOND), true); + } + assert.equal(budget.consume(1, false, RELAY_BYTES_PER_SECOND), false); +}); + +test('v1 count derives from the wire-size limits and log fields stay bounded', () => { + assert.equal(TRANSFER_MAX_CHUNKS, 1024); + assert.equal(TRANSFER_MAX_CHUNKS * TRANSFER_CHUNK_BYTES, TRANSFER_MAX_BYTES); + assert.equal(capForLog('A'.repeat(10000)), `${'A'.repeat(64)}...(10000)`); + assert.equal(capForLog({ padding: 'unsafe' }), null); +}); diff --git a/packages/transfer-server/test/room-lifecycle.ts b/packages/transfer-server/test/room-lifecycle.ts new file mode 100644 index 0000000..abf9ee2 --- /dev/null +++ b/packages/transfer-server/test/room-lifecycle.ts @@ -0,0 +1,134 @@ +import assert from 'node:assert/strict'; +import { createServer } from 'node:http'; +import { test } from 'node:test'; + +import { Server } from 'socket.io'; +import { io } from 'socket.io-client'; + +import { E2eeErrorCode } from '../src/errors'; +import { RoomManager } from '../src/roomManager'; + +import type { Socket } from 'socket.io-client'; + +function deferred() { + let resolve!: () => void; + const promise = new Promise((done) => { resolve = done; }); + return { promise, resolve }; +} + +test('room admission stays consistent across disconnects and asynchronous adapters', { timeout: 40_000 }, async (t) => { + const http = createServer(); + const server = new Server(http); + const config = { maxUsers: 2, roomTimeout: 60_000, maxMessageSize: 10 * 1024 * 1024 }; + const manager = new RoomManager({ config, socketServer: server }); + server.on('connection', (socket) => { + socket.on('disconnect', () => { void manager.leaveRoomBySocket(socket); }); + }); + await new Promise((resolve) => http.listen(0, '127.0.0.1', resolve)); + const address = http.address(); + assert(address && typeof address !== 'string'); + const clients: Socket[] = []; + const connect = async () => { + const client = io(`http://127.0.0.1:${address.port}`, { transports: ['websocket'], reconnection: false }); + clients.push(client); + await new Promise((resolve, reject) => { + client.once('connect', resolve); + client.once('connect_error', reject); + }); + const socket = server.sockets.sockets.get(client.id!); + assert(socket); + return { client, socket, context: { socketClient: socket } }; + }; + t.after(async () => { + clients.forEach((client) => client.disconnect()); + await new Promise((resolve) => server.close(() => resolve())); + manager.destroy(); + }); + const params = (roomId: string) => ({ roomId, appPlatform: 'test', appPlatformName: 'test', appVersion: '1', appBuildNumber: '1', appDeviceName: 'synthetic' }); + const owner = await connect(); + const { roomId } = await manager.createRoom(); + const ownerJoin = await manager.joinRoom(params(roomId), owner.context); + const users = () => manager.getRoomUsers({ roomId }, owner.context); + + await t.test('disconnect during the pre-join delay never adds a member or emits joined', async () => { + const peer = await connect(); + let notifications = 0; + const onJoined = () => { notifications += 1; }; + owner.client.on('user-joined', onJoined); + const joining = manager.joinRoom(params(roomId), peer.context); + const rejection = assert.rejects(joining, { code: E2eeErrorCode.OPERATION_FAILED }); + const disconnected = new Promise((resolve) => peer.socket.once('disconnect', () => resolve())); + peer.client.disconnect(); + await disconnected; + await rejection; + assert.equal((await users()).length, 1); + assert.equal(peer.socket.rooms.has(roomId), false); + assert.equal(notifications, 0); + owner.client.off('user-joined', onJoined); + }); + + await t.test('disconnect releases pending capacity before an adapter resolves', async () => { + const peer = await connect(); + const entered = deferred(); + const release = deferred(); + const originalJoin = peer.socket.join.bind(peer.socket); + peer.socket.join = async (room) => { entered.resolve(); await release.promise; await originalJoin(room); }; + const joining = manager.joinRoom(params(roomId), peer.context); + const rejection = assert.rejects(joining, { code: E2eeErrorCode.OPERATION_FAILED }); + await entered.promise; + const disconnected = new Promise((resolve) => peer.socket.once('disconnect', () => resolve())); + peer.client.disconnect(); + await disconnected; + const replacement = await connect(); + const admitted = await manager.joinRoom(params(roomId), replacement.context); + assert.equal(admitted.success, true, 'the unresolved adapter no longer reserves a slot'); + release.resolve(); + await rejection; + assert.equal(peer.socket.rooms.has(roomId), false); + assert.equal((await users()).length, 2); + await manager.leaveRoom({ roomId, userId: admitted.userId! }, replacement.context); + }); + + await t.test('an adapter rejection rolls back delivery membership and leaves no ghost', async () => { + const peer = await connect(); + const originalJoin = peer.socket.join.bind(peer.socket); + peer.socket.join = async (room) => { await originalJoin(room); throw new Error('Synthetic adapter failure'); }; + await assert.rejects(manager.joinRoom(params(roomId), peer.context), /Synthetic adapter failure/); + assert.equal(peer.socket.rooms.has(roomId), false); + assert.equal((await users()).length, 1); + }); + + await t.test('pending admissions reserve capacity; committed rejoin is idempotent in a full room', async () => { + const [peer, competitor] = await Promise.all([connect(), connect()]); + const entered = deferred(); + const release = deferred(); + const originalJoin = peer.socket.join.bind(peer.socket); + peer.socket.join = async (room) => { entered.resolve(); await release.promise; await originalJoin(room); }; + const joining = manager.joinRoom(params(roomId), peer.context); + await entered.promise; + assert.equal((await users()).length, 1, 'pending entries are not visible to clients'); + await assert.rejects(manager.joinRoom(params(roomId), competitor.context), { code: E2eeErrorCode.CONNECTION_REJECTED }); + release.resolve(); + const admitted = await joining; + const rejoined = await manager.joinRoom(params(roomId), owner.context); + assert.equal(rejoined.userId, ownerJoin.userId); + assert.equal(rejoined.userCount, 2); + await manager.leaveRoom({ roomId, userId: admitted.userId! }, peer.context); + }); + + await t.test('a room removed while the adapter is pending cannot be resurrected', async () => { + const peer = await connect(); + const entered = deferred(); + const release = deferred(); + const originalJoin = peer.socket.join.bind(peer.socket); + peer.socket.join = async (room) => { entered.resolve(); await release.promise; await originalJoin(room); }; + const joining = manager.joinRoom(params(roomId), peer.context); + const rejection = assert.rejects(joining, { code: E2eeErrorCode.OPERATION_FAILED }); + await entered.promise; + await manager.leaveRoom({ roomId, userId: ownerJoin.userId! }, owner.context); + release.resolve(); + await rejection; + assert.deepEqual(await users(), []); + assert.equal(peer.socket.rooms.has(roomId), false); + }); +}); diff --git a/packages/transfer-server/test/smoke.ts b/packages/transfer-server/test/smoke.ts index 18bfd3f..334f46f 100644 --- a/packages/transfer-server/test/smoke.ts +++ b/packages/transfer-server/test/smoke.ts @@ -471,8 +471,9 @@ async function main(): Promise { const privateRoom = await outsider.callRaw('roomManager', 'getRoomUsers', [{ roomId: room.roomId }]); const missingRoom = await outsider.callRaw('roomManager', 'getRoomUsers', [{ roomId: 'missing-room' }]); check( - Boolean(privateRoom.error) && JSON.stringify(privateRoom.error) === JSON.stringify(missingRoom.error), - 'private and missing rooms return identical errors to non-members', + !privateRoom.error && !missingRoom.error && + JSON.stringify(privateRoom.data) === "[]" && JSON.stringify(missingRoom.data) === "[]", + 'private and missing rooms return identical empty lists to non-members', ); const queryFlood = await Promise.all(Array.from({ length: 32 }, () => outsider.callRaw('roomManager', 'getRoomUsers', [{ roomId: room.roomId }]), From 7ef9de7b4f242ab6f97a67df862a1ed784ae84cd Mon Sep 17 00:00:00 2001 From: morizon Date: Thu, 17 Sep 2026 22:58:12 +0800 Subject: [PATCH 6/6] fix: align transfer budgets and complete rejection feedback --- CLAUDE.md | 3 +- packages/transfer-server/README.md | 36 ++- .../transfer-server/src/JsBridgeE2EEServer.ts | 207 ++++-------------- packages/transfer-server/src/relayPolicy.ts | 25 ++- .../transfer-server/src/requestRateLimiter.ts | 51 +++++ packages/transfer-server/src/roomManager.ts | 34 ++- packages/transfer-server/src/types.ts | 25 --- .../test/relay-compatibility.ts | 102 ++++++++- packages/transfer-server/test/relay-policy.ts | 36 ++- .../transfer-server/test/room-lifecycle.ts | 50 +++++ 10 files changed, 355 insertions(+), 214 deletions(-) create mode 100644 packages/transfer-server/src/requestRateLimiter.ts diff --git a/CLAUDE.md b/CLAUDE.md index 85ad242..a5a9715 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -79,7 +79,8 @@ A Midway.js-based component for OneKey Prime synchronization functionality. ## Testing Approach -- Both packages use Jest for testing +- transfer-server uses ts-node smoke/crash scripts and the Node.js test runner +- cloud-sync-server uses Jest - Test files are located in `test/` directories - Mock application available in `examples/mock-app/` for integration testing - Run individual package tests with `yarn workspace @onekeyhq/ test` diff --git a/packages/transfer-server/README.md b/packages/transfer-server/README.md index d0f68f6..ec5fa7c 100644 --- a/packages/transfer-server/README.md +++ b/packages/transfer-server/README.md @@ -69,8 +69,9 @@ Room methods use the bridge request event, not separate `create-room` or `join-room` events. A request payload contains `id`, `type: "REQUEST"`, and `data: { module, method, params }`. Responses preserve correlation fields and contain `type: "RESPONSE"` with `data` or `error: { name, message, code }`. -Optional bridge metadata (`origin`, `peerOrigin`, `scope`, `remoteId`) must be -strings no longer than 1024 characters. IDs, when supplied, are safe integers. +Optional string metadata (`origin`, `peerOrigin`, `scope`) is limited to 1024 characters. +`remoteId` accepts null, finite numbers, or strings up to 1024 characters. +Request IDs, when supplied, and response IDs are safe integers. | Direction | Event | Payload | | --- | --- | --- | @@ -88,7 +89,9 @@ strings no longer than 1024 characters. IDs, when supplied, are safe integers. The event and bridge type must match. Both relay directions require membership in Socket.IO and RoomManager. Authorized, accepted relay traffic renews room activity; rejected traffic does not. Rooms expire after `ROOM_TIMEOUT` of -inactivity, checked every five minutes. +inactivity, checked every five minutes. Expiry emits the existing `user-left` +event with `userCount: 0` and removes Socket.IO membership only for that room; +other rooms on the connection remain usable. Leaving an already removed room is idempotent. ### Room RPCs @@ -97,7 +100,7 @@ Use `data.module = "roomManager"`; `params` is an array of method arguments. | Method | First argument | Result | | --- | --- | --- | | `createRoom` | No arguments | `{ roomId, encryptionKey }` | -| `joinRoom`, `joinRoomAfterCreate` | `{ roomId, appPlatform, appPlatformName, appVersion, appBuildNumber, appDeviceName }` | `{ success, userId, roomId, userCount, roomKey, chunkedTransferVersion: 1 }` | +| `joinRoom`, `joinRoomAfterCreate` | `{ roomId, appPlatform, appPlatformName, appVersion, appBuildNumber, appDeviceName }` | `{ success, userId, roomId, userCount, roomKey, chunkedTransferVersion: 1, maxMessageSize }` | | `getRoomUsers` | `{ roomId }` | User records ordered by join time, without socket IDs | | `leaveRoom` | `{ roomId, userId }` | `{ success, userCount, roomDestroyed }` | | `startTransfer` | `{ roomId, fromUserId, toUserId }` | Transfer direction, or undefined when cleared | @@ -107,6 +110,9 @@ preserves legacy missing-room behavior without exposing room existence. It uses a per-connection token bucket of 10 requests, replenishing 5 requests/second. Joins reserve capacity while the adapter is pending and publish membership only after it succeeds; disconnects and failed joins release that reservation. +Supplied join metadata must be strings: platform/version/build fields allow 64 +characters, platform display name 256, and device name 512. Missing legacy fields +remain accepted. `maxMessageSize` advertises the deployment limit to new senders. ### Chunk protocol v1 and relay limits @@ -123,16 +129,20 @@ old peer does not implement the capability method. | Chunk indices | 0–1023 inclusive, derived from total bytes / chunk size | | Chunk requests | At most 512 per connection per one-second window | | Complete response envelope | 256 KiB JSON UTF-8 bytes | -| Responses | At most 512 per connection per one-second window | -| Requests + responses | At most 1024 relay messages and 32 MiB per connection per one-second window | +| Responses | At most 1024 per connection per one-second window | +| Requests + responses | At most 1600 relay messages and 48 MiB per connection per one-second window | | Legacy single request | Existing `MAX_MESSAGE_SIZE`, 10 MiB by default | -The combined byte budget is at least `MAX_MESSAGE_SIZE` when a deployment +The byte budget covers 512 complete 72 KiB chunk envelopes (36 MiB), plus +12 MiB for normal ACKs/control messages. Aggregate counts reserve control-message +headroom in addition to chunk and response counts. The combined byte budget is at least `MAX_MESSAGE_SIZE` when a deployment explicitly raises that setting. Rejected relay traffic also consumes the shared budget. Malformed/binary/overly complex JSON is not forwarded (maximum depth 64, -16384 visited values). Crossing the shared budget, response rate, or response -size limit disconnects the abusive socket; the peer receives the normal leave -notification, with no response-to-response error loop. Limits are per connection; +16384 visited values). Crossing the shared traffic or response-rate budget disconnects the abusive +socket. Within the transport packet limit, a single oversized or overly complex +response with valid metadata and membership becomes a small same-ID error for the original caller; its body is +never relayed, and the responder is not sent another response. Rejected requests +with a valid ID and membership receive `1001`; one-way requests receive no reply. Limits are per connection; production ingress must also bound connection counts and aggregate traffic. Chunk RPCs use module `api`: `beginChunkedTransfer({ transferId, totalBytes })`, @@ -145,6 +155,12 @@ In a valid bridge request, invalid chunk parameters or packet size return `1001` returns `1100`, on `e2ee-c2c-response`. The relay performs no automatic retries. Malformed bridge envelopes are discarded; response IDs are required. +New senders check the 64 MiB total before beginning a chunk transfer. Legacy +fallbacks check the complete encoded Socket.IO message before emitting wallet +data, using the advertised `maxMessageSize` or the historical 10 MiB default +when connected to an older relay. These total/message checks are independent +of throughput limits; increasing `MAX_MESSAGE_SIZE` does not raise the chunked-transfer total. + CORS reflects the requesting origin and retains `credentials: true` for browser clients using credentialed HTTP polling, even though this service does not use cookie authentication. CORS is not an authorization boundary. diff --git a/packages/transfer-server/src/JsBridgeE2EEServer.ts b/packages/transfer-server/src/JsBridgeE2EEServer.ts index 14af13b..f4aef4b 100644 --- a/packages/transfer-server/src/JsBridgeE2EEServer.ts +++ b/packages/transfer-server/src/JsBridgeE2EEServer.ts @@ -3,6 +3,7 @@ import { IJsBridgeMessageTypes } from '@onekeyfe/cross-inpage-provider-types'; import { E2eeError, E2eeErrorCode } from './errors'; import { CHUNK_PACKET_BYTES, RESPONSE_PACKET_BYTES, RELAY_BYTES_PER_SECOND, RelayTrafficBudget, isValidTransferChunk, measureJsonBytes } from './relayPolicy'; +import { RequestRateLimiter } from './requestRateLimiter'; import { capForLog, createModuleLogger } from './utils/logger'; import type { @@ -15,32 +16,12 @@ import type { Socket } from 'socket.io'; const logger = createModuleLogger('jsBridge'); -const RATE_LIMIT_INTERVAL_MS = 3000; - -// Upper bound on distinct methods tracked per connection. `method` comes from -// the client, so without a cap a single socket could grow this map forever. -// Well above the number of methods a real client calls. -const RATE_LIMIT_MAX_TRACKED_METHODS = 64; - // Rejecting a payload happens before rate limiting can apply (a malformed // packet may carry no method to limit on), so the log itself has to be capped // per connection or it can be triggered at socket speed. const INVALID_PAYLOAD_LOG_INTERVAL_MS = 1000; const INVALID_PAYLOAD_LOG_BURST = 5; -// Rate limiting whitelist - methods that are exempt from rate limiting -const RATE_LIMIT_WHITELIST = new Set([ - 'changeTransferDirection', - 'leaveRoom', - 'cancelTransfer', -]); - -// Shipped clients query during key derivation, immediately after pairing, and -// independently from a one-second CLI poll. Allow those overlapping calls while -// bounding sustained traffic; a minimum interval breaks otherwise valid pairing. -const ROOM_USERS_RATE_LIMIT_CAPACITY = 10; -const ROOM_USERS_RATE_LIMIT_REFILL_PER_MS = 5 / 1000; - type IResponseEvent = 'e2ee-response' | 'e2ee-c2c-response'; type IRequestEvent = 'e2ee-request' | 'e2ee-c2c-request'; @@ -73,13 +54,20 @@ function checkBridgePayload( !(typeof payload.id === 'number' && Number.isSafeInteger(payload.id)))) { return { valid: false, reason: 'payload.id is invalid' }; } - for (const key of ['scope', 'remoteId', 'peerOrigin', 'origin'] as const) { + for (const key of ['scope', 'peerOrigin', 'origin'] as const) { const value = payload[key]; if (value !== undefined && (typeof value !== 'string' || value.length > 1024)) { return { valid: false, reason: 'bridge metadata is invalid or too large' }; } } + const remoteId = payload.remoteId; + if (remoteId !== undefined && remoteId !== null && + !(typeof remoteId === 'string' && remoteId.length <= 1024) && + !(typeof remoteId === 'number' && Number.isFinite(remoteId))) { + return { valid: false, reason: 'payload.remoteId is invalid' }; + } + if (requireMethod) { const req = payload.data as IJsonRpcRequest | undefined; if (!req || typeof req !== 'object' || typeof req.method !== 'string') { @@ -108,26 +96,10 @@ export class JsBridgeE2EEServer extends JsBridgeBase { private roomManager: RoomManager; - /** - * Rate limit state, scoped to this connection rather than kept in a - * module-level map that lived for the lifetime of the process. - * - * Cleared explicitly on disconnect rather than left to GC: JsBridgeBase - * instances are currently retained for the lifetime of the process, so - * anything hanging off them has to be released by hand. For the same reason - * this is a plain Map - a structure that preallocates would turn into a - * fixed cost per connection. - */ - private rateLimitState = new Map(); - - private roomUsersRateLimitState?: { tokens: number; updatedAt: number }; + private readonly requestLimits = new RequestRateLimiter(); private readonly relayTraffic = new RelayTrafficBudget(); - private chunkWindowStartedAt = 0; - - private chunkRequestsInWindow = 0; - private invalidPayloadLogWindowStart = 0; private invalidPayloadLogCount = 0; @@ -158,23 +130,28 @@ export class JsBridgeE2EEServer extends JsBridgeBase { payload: IJsBridgeMessagePayload, error: E2eeError, ): void { + if (payload.id === undefined) return; // C2S and C2C bridges allocate IDs independently. Carry the response channel // alongside this request instead of inferring it from an ID or error code. // Match JsBridgeBase.responseError's wire envelope without its C2S egress. this.emitResponse( eventName === 'e2ee-c2c-request' ? 'e2ee-c2c-response' : 'e2ee-response', - { - id: payload.id, - type: IJsBridgeMessageTypes.RESPONSE, - origin: '', - scope: payload.scope, - remoteId: payload.remoteId, - peerOrigin: payload.peerOrigin, - error: error.toJSON(), - }, + this.buildErrorResponse(payload, error), ); } + private buildErrorResponse(payload: IJsBridgeMessagePayload, error: E2eeError): IJsBridgeMessagePayload { + return { + id: payload.id, + type: IJsBridgeMessageTypes.RESPONSE, + origin: '', + scope: payload.scope, + remoteId: payload.remoteId, + peerOrigin: payload.peerOrigin, + error: error.toJSON(), + }; + } + /** * Wrap a socket listener so one malformed message can never take down the process. * @@ -285,100 +262,10 @@ export class JsBridgeE2EEServer extends JsBridgeBase { eventName: string; sendErrorResponse: () => void; }) { - // Rate limiting check - const req = payload?.data as IJsonRpcRequest | undefined; - const method = typeof req?.method === 'string' ? req.method : ''; - - if (eventName === 'e2ee-request' && method === 'getRoomUsers') { - const now = Date.now(); - const state = this.roomUsersRateLimitState; - const tokens = state - ? Math.min( - ROOM_USERS_RATE_LIMIT_CAPACITY, - state.tokens + - Math.max(0, now - state.updatedAt) * - ROOM_USERS_RATE_LIMIT_REFILL_PER_MS, - ) - : ROOM_USERS_RATE_LIMIT_CAPACITY; - this.roomUsersRateLimitState = { tokens, updatedAt: now }; - if (tokens < 1) { - sendErrorResponse(); - return true; - } - this.roomUsersRateLimitState.tokens -= 1; - return false; - } - - // Chunk RPCs are bounded by both message size and connection throughput. - // Keep the existing per-method limit for all other operations. - if (eventName === 'e2ee-c2c-request' && method === 'sendTransferChunk') { - const now = Date.now(); - if (now - this.chunkWindowStartedAt >= 1000) { - this.chunkWindowStartedAt = now; - this.chunkRequestsInWindow = 0; - } - this.chunkRequestsInWindow += 1; - if (this.chunkRequestsInWindow > 512) { - sendErrorResponse(); - return true; - } - return false; - } - - // Check if method is in whitelist - if (RATE_LIMIT_WHITELIST.has(method)) { - return false; - } - - // no socket id in the key: the map already belongs to this connection - const rateLimitKey = `${eventName}:${method}`; - - const now = Date.now(); - const lastTime = this.rateLimitState.get(rateLimitKey); - if (lastTime !== undefined && now - lastTime < RATE_LIMIT_INTERVAL_MS) { - sendErrorResponse(); - return true; - } - - if ( - lastTime === undefined && - this.rateLimitState.size >= RATE_LIMIT_MAX_TRACKED_METHODS - ) { - this.pruneRateLimitState(now); - - if (this.rateLimitState.size >= RATE_LIMIT_MAX_TRACKED_METHODS) { - // Every tracked window is still live, so this connection is flooding - // distinct method names. Refuse to track a new one and treat it as - // limited: the flood throttles itself and the existing windows - the - // expensive calls it is trying to reset - stay intact. - logger.debug( - { socketId: this.socketClient.id }, - 'jsBridge.rateLimitCapacityReached', - ); - sendErrorResponse(); - return true; - } - } - - this.rateLimitState.set(rateLimitKey, now); - return false; - } - - /** - * Reclaim entries whose window has already passed - they cannot rate limit - * anything any more. - * - * This only ever drops expired entries. Live windows are never touched: since - * `method` is client-controlled, wiping the map on a flood would let the - * flooder reset the windows of the calls it was just blocked on, turning the - * bound into a rate-limit bypass. - */ - private pruneRateLimitState(now: number): void { - for (const [key, time] of this.rateLimitState) { - if (now - time >= RATE_LIMIT_INTERVAL_MS) { - this.rateLimitState.delete(key); - } - } + const req = payload.data as IJsonRpcRequest | undefined; + if (!this.requestLimits.isLimited(eventName, req?.method ?? '')) return false; + sendErrorResponse(); + return true; } private buildRateLimitResponder( @@ -405,8 +292,7 @@ export class JsBridgeE2EEServer extends JsBridgeBase { // JsBridgeBase instances outlive their socket, so per-connection state is // released explicitly rather than left for GC to reclaim this.socketClient.on('disconnect', () => { - this.rateLimitState.clear(); - this.roomUsersRateLimitState = undefined; + this.requestLimits.clear(); }); this.socketClient.on( @@ -439,8 +325,8 @@ export class JsBridgeE2EEServer extends JsBridgeBase { this.socketClient.on( 'e2ee-c2c-request', this.safeHandler('e2ee-c2c-request', (raw) => { - const bytes = this.checkRelayTraffic(raw, false); - if (bytes === undefined) return; + const traffic = this.checkRelayTraffic(raw, false); + if (traffic.disconnected) return; const envelope = this.checkC2cEnvelope('e2ee-c2c-request', raw, { requireMethod: true, }); @@ -449,10 +335,10 @@ export class JsBridgeE2EEServer extends JsBridgeBase { } const { payload: p, roomId } = envelope; const request = p.data as IJsonRpcRequest; - if (request.method === 'sendTransferChunk' && - (bytes > CHUNK_PACKET_BYTES || !isValidTransferChunk(request.params))) { + if (traffic.bytes === undefined || (request.method === 'sendTransferChunk' && + (traffic.bytes > CHUNK_PACKET_BYTES || !isValidTransferChunk(request.params)))) { this.sendRequestError('e2ee-c2c-request', p, new E2eeError( - E2eeErrorCode.INVALID_PARAMETER, 'Invalid transfer chunk or packet size', + E2eeErrorCode.INVALID_PARAMETER, 'Invalid transfer payload or packet size', )); return; } @@ -475,14 +361,8 @@ export class JsBridgeE2EEServer extends JsBridgeBase { this.socketClient.on( 'e2ee-c2c-response', this.safeHandler('e2ee-c2c-response', (raw) => { - const bytes = this.checkRelayTraffic(raw, true); - if (bytes === undefined) return; - if (bytes > RESPONSE_PACKET_BYTES) { - // A response cannot be answered with another response. Disconnecting - // gives peers a terminal transport signal without an error loop. - this.socketClient.disconnect(true); - return; - } + const traffic = this.checkRelayTraffic(raw, true); + if (traffic.disconnected) return; // A response carries a result rather than a method. const envelope = this.checkC2cEnvelope('e2ee-c2c-response', raw, { requireMethod: false, @@ -492,13 +372,22 @@ export class JsBridgeE2EEServer extends JsBridgeBase { } const { payload: p, roomId } = envelope; + if (traffic.bytes === undefined || traffic.bytes > RESPONSE_PACKET_BYTES) { + // Complete the original caller's RPC with a small error. Never send + // a response back to the responder or relay its oversized error body. + this.socketClient.to(roomId).emit('e2ee-c2c-response', this.buildErrorResponse( + p, new E2eeError(E2eeErrorCode.INVALID_PARAMETER, 'Peer response exceeds relay limits'), + )); + return; + } + this.roomManager.updateRoomActivity(roomId); this.socketClient.to(roomId).emit('e2ee-c2c-response', p); }), ); } - private checkRelayTraffic(raw: unknown, response: boolean): number | undefined { + private checkRelayTraffic(raw: unknown, response: boolean): { disconnected: boolean; bytes?: number } { const packetLimit = this.roomManager.maxMessageSize; const bytes = raw === undefined ? 0 : measureJsonBytes(raw, packetLimit); if (!this.relayTraffic.consume( @@ -506,10 +395,10 @@ export class JsBridgeE2EEServer extends JsBridgeBase { )) { // Stop parsing and responding to a sustained flood on this connection. this.socketClient.disconnect(true); - return undefined; + return { disconnected: true }; } if (bytes === undefined) this.logInvalidPayload('relay', raw, 'packet exceeds JSON limits'); - return bytes; + return { disconnected: false, bytes }; } /** diff --git a/packages/transfer-server/src/relayPolicy.ts b/packages/transfer-server/src/relayPolicy.ts index 6ccad6f..23c203c 100644 --- a/packages/transfer-server/src/relayPolicy.ts +++ b/packages/transfer-server/src/relayPolicy.ts @@ -3,18 +3,31 @@ export const TRANSFER_MAX_BYTES = 64 * 1024 * 1024; export const TRANSFER_MAX_CHUNKS = Math.ceil(TRANSFER_MAX_BYTES / TRANSFER_CHUNK_BYTES); export const CHUNK_PACKET_BYTES = TRANSFER_CHUNK_BYTES + 8 * 1024; export const RESPONSE_PACKET_BYTES = 256 * 1024; -export const RELAY_MESSAGES_PER_SECOND = 1024; -export const RELAY_RESPONSES_PER_SECOND = 512; -export const RELAY_BYTES_PER_SECOND = 32 * 1024 * 1024; +export const CHUNK_REQUESTS_PER_SECOND = 512; +export const RELAY_RESPONSES_PER_SECOND = CHUNK_REQUESTS_PER_SECOND * 2; +export const RELAY_MESSAGES_PER_SECOND = CHUNK_REQUESTS_PER_SECOND + RELAY_RESPONSES_PER_SECOND + 64; +// Full chunk envelopes need 36 MiB; reserve another 12 MiB for ACKs/control traffic. +export const RELAY_BYTES_PER_SECOND = CHUNK_PACKET_BYTES * CHUNK_REQUESTS_PER_SECOND + 12 * 1024 * 1024; /** Count the entire JSON envelope without building another full packet string. */ export function measureJsonBytes(value: unknown, limit: number): number | undefined { let bytes = 0; let nodes = 0; const addString = (text: string) => { - // Avoid escaping a large string when even its unescaped form cannot fit. - if (Buffer.byteLength(text) + bytes + 2 > limit) return false; - bytes += Buffer.byteLength(JSON.stringify(text)); + // Count JSON escapes without allocating an escaped copy of legacy payloads. + bytes += Buffer.byteLength(text) + 2; + if (bytes > limit) return false; + for (let index = 0; index < text.length; index += 1) { + const code = text.charCodeAt(index); + if (code === 34 || code === 92) bytes += 1; + else if (code < 32) bytes += [8, 9, 10, 12, 13].includes(code) ? 1 : 5; + else if (code >= 0xd800 && code <= 0xdbff) { + const next = text.charCodeAt(index + 1); + if (next >= 0xdc00 && next <= 0xdfff) index += 1; + else bytes += 3; + } else if (code >= 0xdc00 && code <= 0xdfff) bytes += 3; + if (bytes > limit) return false; + } return bytes <= limit; }; const visit = (item: unknown, depth: number): boolean => { diff --git a/packages/transfer-server/src/requestRateLimiter.ts b/packages/transfer-server/src/requestRateLimiter.ts new file mode 100644 index 0000000..bfc1537 --- /dev/null +++ b/packages/transfer-server/src/requestRateLimiter.ts @@ -0,0 +1,51 @@ +import { CHUNK_REQUESTS_PER_SECOND } from './relayPolicy'; + +const METHOD_INTERVAL_MS = 3000; +const MAX_TRACKED_METHODS = 64; +const ROOM_QUERY_CAPACITY = 10; +const ROOM_QUERY_REFILL_PER_MS = 5 / 1000; +const UNTHROTTLED_METHODS = new Set(['changeTransferDirection', 'leaveRoom', 'cancelTransfer']); + +/** Request policies are separate from the shared request/response traffic budget. */ +export class RequestRateLimiter { + private methods = new Map(); + private roomQueries?: { tokens: number; updatedAt: number }; + private chunks = { startedAt: 0, count: 0 }; + + clear(): void { + this.methods.clear(); + this.roomQueries = undefined; + this.chunks = { startedAt: 0, count: 0 }; + } + + isLimited(eventName: string, method: string): boolean { + const now = Date.now(); + if (eventName === 'e2ee-request' && method === 'getRoomUsers') { + const tokens = this.roomQueries + ? Math.min(ROOM_QUERY_CAPACITY, this.roomQueries.tokens + Math.max(0, now - this.roomQueries.updatedAt) * ROOM_QUERY_REFILL_PER_MS) + : ROOM_QUERY_CAPACITY; + this.roomQueries = { tokens, updatedAt: now }; + if (tokens < 1) return true; + this.roomQueries.tokens -= 1; + return false; + } + if (eventName === 'e2ee-c2c-request' && method === 'sendTransferChunk') { + if (now - this.chunks.startedAt >= 1000) this.chunks = { startedAt: now, count: 0 }; + this.chunks.count += 1; + return this.chunks.count > CHUNK_REQUESTS_PER_SECOND; + } + if (UNTHROTTLED_METHODS.has(method)) return false; + const key = `${eventName}:${method}`; + const previous = this.methods.get(key); + if (previous !== undefined && now - previous < METHOD_INTERVAL_MS) return true; + if (previous === undefined && this.methods.size >= MAX_TRACKED_METHODS) { + // Only expired entries can be evicted; method-name floods cannot reset live limits. + for (const [tracked, timestamp] of this.methods) { + if (now - timestamp >= METHOD_INTERVAL_MS) this.methods.delete(tracked); + } + if (this.methods.size >= MAX_TRACKED_METHODS) return true; + } + this.methods.set(key, now); + return false; + } +} diff --git a/packages/transfer-server/src/roomManager.ts b/packages/transfer-server/src/roomManager.ts index e413e87..f952c83 100644 --- a/packages/transfer-server/src/roomManager.ts +++ b/packages/transfer-server/src/roomManager.ts @@ -12,6 +12,14 @@ import type { IE2EESocketUserInfo, IRoom, IRoomConfig } from "./types"; const logger = createModuleLogger("roomManager"); +const JOIN_FIELD_LIMITS = { + appPlatform: 64, + appPlatformName: 256, + appVersion: 64, + appBuildNumber: 64, + appDeviceName: 512, +} as const; + export type IRoomManagerContext = { socketClient: Socket; }; @@ -140,7 +148,18 @@ export class RoomManager { error?: string; userCount?: number; chunkedTransferVersion?: number; + maxMessageSize?: number; }> { + if (!params || typeof params !== "object") { + throw new E2eeError(E2eeErrorCode.INVALID_PARAMETER, "Invalid room join parameters"); + } + for (const key of Object.keys(JOIN_FIELD_LIMITS) as Array) { + const value = params[key]; + // Missing optional metadata remains compatible with older clients. + if (value !== undefined && (typeof value !== "string" || value.length > JOIN_FIELD_LIMITS[key])) { + throw new E2eeError(E2eeErrorCode.INVALID_PARAMETER, "Invalid room join metadata"); + } + } await timerUtils.wait(1000); if (!context) { throw new E2eeError( @@ -231,6 +250,7 @@ export class RoomManager { userCount: room.users.size, roomKey: room.encryptionKey, chunkedTransferVersion: 1, + maxMessageSize: this.config.maxMessageSize, }; } @@ -259,7 +279,9 @@ export class RoomManager { const { roomId, userId } = params; const room = this.rooms.get(roomId); if (!room) { - throw new E2eeError(E2eeErrorCode.ROOM_NOT_FOUND, "Room not found"); + // Expiry notifications cause legacy clients to acknowledge with leaveRoom. + // Repeating an already completed departure must not reject that cleanup. + return { success: true, userCount: 0, roomDestroyed: true }; } const socketValidation = this.isUserInRoom( @@ -512,6 +534,16 @@ export class RoomManager { if (timeSinceActivity > this.config.roomTimeout) { this.rooms.delete(roomId); + if (room.users.size > 0) { + this.socketServer.to(roomId).emit("user-left", { + roomId, + userId: room.users.keys().next().value!, + userCount: 0, + }); + } + // Keep other sessions on the same connection intact; remove only this + // room's delivery membership. Existing clients understand user-left. + this.socketServer.in(roomId).socketsLeave(roomId); cleanedCount += 1; logger.info({ roomId }, "room.expiredCleaned"); } diff --git a/packages/transfer-server/src/types.ts b/packages/transfer-server/src/types.ts index a93b3db..029df43 100644 --- a/packages/transfer-server/src/types.ts +++ b/packages/transfer-server/src/types.ts @@ -63,16 +63,6 @@ export interface IRoom { maxUsers: number; } -// Room list item -export interface IRoomListItem { - roomId: string; - userCount: number; - maxUsers: number; - users: string[]; - createdAt: string; - lastActivity: string; -} - // User information export interface IE2EESocketUserInfo { id: string; @@ -85,14 +75,6 @@ export interface IE2EESocketUserInfo { appDeviceName: string; } -// Encrypted message structure -export interface IEncryptedMessage { - encryptedData: string; - senderId: string; - timestamp: number; - roomId: string; -} - // Room configuration export interface IRoomConfig { maxUsers: number; @@ -106,13 +88,6 @@ export interface IServerConfig { roomConfig: IRoomConfig; } -// API response type -export interface IApiResponse { - success: boolean; - data?: T; - error?: string; -} - export interface IE2EEServerApi { roomManager: RoomManager; } diff --git a/packages/transfer-server/test/relay-compatibility.ts b/packages/transfer-server/test/relay-compatibility.ts index dcf3aca..b7c4681 100644 --- a/packages/transfer-server/test/relay-compatibility.ts +++ b/packages/transfer-server/test/relay-compatibility.ts @@ -11,6 +11,7 @@ import { io } from 'socket.io-client'; import { e2eeServerApiSetup } from '../src/e2eeServerApi'; import { E2eeErrorCode } from '../src/errors'; import { RoomManager } from '../src/roomManager'; +import { CHUNK_PACKET_BYTES, CHUNK_REQUESTS_PER_SECOND, RELAY_RESPONSES_PER_SECOND } from '../src/relayPolicy'; import type { Socket } from 'socket.io-client'; @@ -18,7 +19,7 @@ type IPacket = { id: number; type: string; scope?: string; - remoteId?: string; + remoteId?: string | number | null; peerOrigin?: string; error?: { code: number; message: string; stack?: string }; data?: unknown; @@ -262,6 +263,91 @@ test('relay compatibility over real Socket.IO with a controlled activity clock', b.off('e2ee-c2c-response', collect); }); + await t.test('512 full chunk envelopes plus manifest, finish and ACKs fit one traffic window', async () => { + advance(5000); + const replies: IPacket[] = []; + const chunks: number[] = []; + const collect = (packet: IPacket) => replies.push(packet); + const acknowledge = (packet: IPacket) => { + const rpc = packet.data as { method: string; params: Array<{ index: number }> }; + if (rpc.method === 'sendTransferChunk') chunks.push(rpc.params[0].index); + b.emit('e2ee-c2c-response', { roomId, payload: { id: packet.id, type: 'RESPONSE', data: { accepted: true } } }); + }; + a.on('e2ee-c2c-response', collect); + b.on('e2ee-c2c-request', acknowledge); + try { + a.emit('e2ee-c2c-request', { roomId, payload: request('beginChunkedTransfer', [{ transferId: 'burst', totalBytes: 512 * 65536 }], 'api') }); + for (let index = 0; index < CHUNK_REQUESTS_PER_SECOND; index += 1) { + const envelope = { roomId, payload: request('sendTransferChunk', [{ transferId: 'burst', index, data: 'A'.repeat(65536) }], 'api'), padding: '' }; + envelope.padding = 'A'.repeat(CHUNK_PACKET_BYTES - Buffer.byteLength(JSON.stringify(envelope))); + a.emit('e2ee-c2c-request', envelope); + } + const finish = request('finishChunkedTransfer', [{ transferId: 'burst' }], 'api'); + const done = receive(a, 'e2ee-c2c-response', finish.id); + a.emit('e2ee-c2c-request', { roomId, payload: finish }); + await done; + assert.equal(chunks.length, CHUNK_REQUESTS_PER_SECOND); + assert.equal(replies.length, CHUNK_REQUESTS_PER_SECOND + 2); + assert.equal(replies.some((packet) => packet.error), false); + assert.equal(a.connected, true); + assert.equal(b.connected, true); + } finally { + a.off('e2ee-c2c-response', collect); + b.off('e2ee-c2c-request', acknowledge); + } + }); + + await t.test('string, numeric and null remote IDs preserve both RPC and relay envelopes', async () => { + advance(5000); + for (const remoteId of ['remote', 42, 1.5, null]) { + const packet = { ...request('getRoomUsers', [{ roomId }]), remoteId }; + const response = receive(a, 'e2ee-response', packet.id); + a.emit('e2ee-request', packet); + assert.equal((await response).remoteId, remoteId); + await relay(a, b, roomId, 'e2ee-c2c-request', { ...request('cancelTransfer', [], 'api'), remoteId }); + await relay(b, a, roomId, 'e2ee-c2c-response', { id: sequence++, type: 'RESPONSE', remoteId, data: true }); + } + }); + + await t.test('one-way rejection never emits an uncorrelated response', async () => { + advance(5000); + const replies: IPacket[] = []; + const collect = (packet: IPacket) => replies.push(packet); + a.on('e2ee-response', collect); + a.on('e2ee-c2c-response', collect); + try { + a.emit('e2ee-c2c-request', { roomId, payload: { type: 'REQUEST', data: { module: 'api', method: 'sendTransferChunk', params: [] } } }); + for (let index = 0; index < 11; index += 1) { + a.emit('e2ee-request', { type: 'REQUEST', data: { module: 'roomManager', method: 'getRoomUsers', params: [{ roomId }] } }); + } + await barrier(a); + assert.equal(replies.length, 0); + } finally { a.off('e2ee-response', collect); a.off('e2ee-c2c-response', collect); } + }); + + await t.test('complex but correlatable packets return small errors only to the authorized caller', async () => { + advance(5000); + let extra: unknown = 'small'; + for (let index = 0; index < 70; index += 1) extra = { nested: extra }; + const packet = request('cancelTransfer', [], 'api'); + const reply = receive(a, 'e2ee-c2c-response', packet.id); + a.emit('e2ee-c2c-request', { roomId, payload: packet, extra }); + assert.equal((await reply).error?.code, E2eeErrorCode.INVALID_PARAMETER); + const responseId = sequence++; + const rejectedResponse = receive(a, 'e2ee-c2c-response', responseId); + b.emit('e2ee-c2c-response', { roomId, payload: { id: responseId, type: 'RESPONSE', data: extra } }); + assert.equal((await rejectedResponse).error?.code, E2eeErrorCode.INVALID_PARAMETER); + const unauthorized: IPacket[] = []; + const collect = (value: IPacket) => unauthorized.push(value); + outsider.on('e2ee-c2c-response', collect); + outsider.emit('e2ee-c2c-request', { roomId, payload: request('cancelTransfer', [], 'api'), extra }); + await barrier(outsider); + assert.equal(unauthorized.length, 0); + outsider.off('e2ee-c2c-response', collect); + assert.equal(a.connected, true); + assert.equal(b.connected, true); + }); + await t.test('legacy requests, transfer chunks, and peer responses each extend idle TTL', async () => { let lastAcceptedAt = now; const events: Array<[string, IPacket]> = [ @@ -335,7 +421,7 @@ test('relay compatibility over real Socket.IO with a controlled activity clock', }); } for (const mode of ['oversized', 'flood'] as const) { - await t.test(`response ${mode} is bounded and closes only the abusive connection`, async () => { + await t.test(`response ${mode} is bounded without losing a recoverable RPC`, async () => { advance(5000); const [sender, recipient] = await Promise.all([connect(), connect()]); const created = await call(sender, 'createRoom'); @@ -345,15 +431,21 @@ test('relay compatibility over real Socket.IO with a controlled activity clock', const received: IPacket[] = []; recipient.on('e2ee-c2c-response', (packet: IPacket) => received.push(packet)); const disconnected = new Promise((resolve) => sender.once('disconnect', () => resolve())); - const count = mode === 'flood' ? 520 : 1; + const count = mode === 'flood' ? RELAY_RESPONSES_PER_SECOND + 8 : 1; for (let index = 0; index < count; index += 1) { sender.emit('e2ee-c2c-response', { roomId: isolatedRoom, payload: { id: sequence++, type: 'RESPONSE', data: mode === 'oversized' ? 'A'.repeat(300 * 1024) : 'synthetic-ack', } }); } - await disconnected; + if (mode === 'flood') await disconnected; + else await barrier(sender); await barrier(recipient); - assert.equal(received.length, mode === 'flood' ? 512 : 0); + assert.equal(received.length, mode === 'flood' ? RELAY_RESPONSES_PER_SECOND : 1); + if (mode === 'oversized') { + assert.equal(sender.connected, true); + assert.equal(received[0].error?.code, E2eeErrorCode.INVALID_PARAMETER); + assert.ok(Buffer.byteLength(JSON.stringify(received[0])) < 1024); + } assert.equal(recipient.connected, true); assert.equal((await call(recipient, 'getRoomUsers', [{ roomId: isolatedRoom }])).error, undefined); }); diff --git a/packages/transfer-server/test/relay-policy.ts b/packages/transfer-server/test/relay-policy.ts index aadc9b0..b4f9ada 100644 --- a/packages/transfer-server/test/relay-policy.ts +++ b/packages/transfer-server/test/relay-policy.ts @@ -2,7 +2,7 @@ import assert from 'node:assert/strict'; import { test } from 'node:test'; import { - CHUNK_PACKET_BYTES, RELAY_BYTES_PER_SECOND, RelayTrafficBudget, + CHUNK_PACKET_BYTES, RELAY_BYTES_PER_SECOND, RELAY_MESSAGES_PER_SECOND, RelayTrafficBudget, TRANSFER_MAX_BYTES, TRANSFER_CHUNK_BYTES, TRANSFER_MAX_CHUNKS, measureJsonBytes, } from '../src/relayPolicy'; import { capForLog } from '../src/utils/logger'; @@ -22,17 +22,17 @@ test('packet accounting includes JSON escaping, UTF-8, keys, arrays, and outer m test('request and response bytes share one budget, with a fresh window after idle', (t) => { t.mock.timers.enable({ apis: ['Date'], now: 1_700_000_000_000 }); const budget = new RelayTrafficBudget(); - assert.equal(budget.consume(9 * 1024 * 1024, false, RELAY_BYTES_PER_SECOND), true); - assert.equal(budget.consume(9 * 1024 * 1024, true, RELAY_BYTES_PER_SECOND), true); - assert.equal(budget.consume(9 * 1024 * 1024, false, RELAY_BYTES_PER_SECOND), true); - assert.equal(budget.consume(9 * 1024 * 1024, true, RELAY_BYTES_PER_SECOND), false); + assert.equal(budget.consume(RELAY_BYTES_PER_SECOND / 3, false, RELAY_BYTES_PER_SECOND), true); + assert.equal(budget.consume(RELAY_BYTES_PER_SECOND / 3, true, RELAY_BYTES_PER_SECOND), true); + assert.equal(budget.consume(RELAY_BYTES_PER_SECOND / 3, false, RELAY_BYTES_PER_SECOND), true); + assert.equal(budget.consume(RELAY_BYTES_PER_SECOND / 3, true, RELAY_BYTES_PER_SECOND), false); t.mock.timers.tick(1000); - assert.equal(budget.consume(9 * 1024 * 1024, false, RELAY_BYTES_PER_SECOND), true); + assert.equal(budget.consume(RELAY_BYTES_PER_SECOND / 3, false, RELAY_BYTES_PER_SECOND), true); }); test('mixed request/response traffic cannot bypass the aggregate message budget', () => { const budget = new RelayTrafficBudget(); - for (let index = 0; index < 1024; index += 1) { + for (let index = 0; index < RELAY_MESSAGES_PER_SECOND; index += 1) { assert.equal(budget.consume(1, index % 2 === 0, RELAY_BYTES_PER_SECOND), true); } assert.equal(budget.consume(1, false, RELAY_BYTES_PER_SECOND), false); @@ -44,3 +44,25 @@ test('v1 count derives from the wire-size limits and log fields stay bounded', ( assert.equal(capForLog('A'.repeat(10000)), `${'A'.repeat(64)}...(10000)`); assert.equal(capForLog({ padding: 'unsafe' }), null); }); + + +test('JSON string accounting matches escaping and Unicode without copying the payload', () => { + const strings = ['"\\\n', String.fromCharCode(0, 1, 8, 9, 10, 12, 13, 31), 'δΈ­ζ–‡πŸ˜€', + String.fromCharCode(0xd800), String.fromCharCode(0xdc00), String.fromCharCode(0xd800, 0xd800, 0xdc00), + 'A'.repeat(9 * 1024 * 1024)]; + let seed = 3431; + for (let sample = 0; sample < 1000; sample += 1) { + let value = ''; + for (let index = 0; index < 32; index += 1) { + seed = (Math.imul(seed, 1664525) + 1013904223) >>> 0; + value += String.fromCharCode(seed & 0xffff); + } + strings.push(value); + } + for (const value of strings) { + const packet = { value }; + const bytes = Buffer.byteLength(JSON.stringify(packet)); + assert.equal(measureJsonBytes(packet, bytes), bytes); + assert.equal(measureJsonBytes(packet, bytes - 1), undefined); + } +}); diff --git a/packages/transfer-server/test/room-lifecycle.ts b/packages/transfer-server/test/room-lifecycle.ts index abf9ee2..4e244d7 100644 --- a/packages/transfer-server/test/room-lifecycle.ts +++ b/packages/transfer-server/test/room-lifecycle.ts @@ -9,6 +9,7 @@ import { E2eeErrorCode } from '../src/errors'; import { RoomManager } from '../src/roomManager'; import type { Socket } from 'socket.io-client'; +import type { IRoom } from '../src/types'; function deferred() { let resolve!: () => void; @@ -48,8 +49,29 @@ test('room admission stays consistent across disconnects and asynchronous adapte const owner = await connect(); const { roomId } = await manager.createRoom(); const ownerJoin = await manager.joinRoom(params(roomId), owner.context); + assert.equal(ownerJoin.maxMessageSize, config.maxMessageSize); const users = () => manager.getRoomUsers({ roomId }, owner.context); + await t.test('join metadata is bounded before admission without rejecting absent legacy fields', async () => { + const peer = await connect(); + for (const invalid of [ + { appPlatform: 'A'.repeat(65) }, + { appPlatformName: 'A'.repeat(257) }, + { appVersion: 'A'.repeat(65) }, + { appBuildNumber: 'A'.repeat(65) }, + { appDeviceName: 'A'.repeat(513) }, + { appDeviceName: 42 }, + ]) { + await assert.rejects(manager.joinRoom(Object.assign(params(roomId), invalid), peer.context), { code: E2eeErrorCode.INVALID_PARAMETER }); + } + assert.equal(peer.socket.rooms.has(roomId), false); + const legacyParams = params(roomId); + Reflect.deleteProperty(legacyParams, 'appDeviceName'); + const joined = await manager.joinRoom(legacyParams, peer.context); + assert.equal(joined.success, true); + await manager.leaveRoom({ roomId, userId: joined.userId! }, peer.context); + }); + await t.test('disconnect during the pre-join delay never adds a member or emits joined', async () => { const peer = await connect(); let notifications = 0; @@ -131,4 +153,32 @@ test('room admission stays consistent across disconnects and asynchronous adapte assert.deepEqual(await users(), []); assert.equal(peer.socket.rooms.has(roomId), false); }); + + await t.test('expiry notifies legacy clients and removes only expired room membership', async () => { + const peer = await connect(); + const expired = await manager.createRoom(); + const [a, b] = await Promise.all([ + manager.joinRoom(params(expired.roomId), owner.context), + manager.joinRoom(params(expired.roomId), peer.context), + ]); + const active = await manager.createRoom(); + await manager.joinRoom(params(active.roomId), owner.context); + const rooms = Reflect.get(manager, 'rooms') as Map; + rooms.get(expired.roomId)!.lastActivity = new Date(0); + const left = (client: Socket) => new Promise<{ roomId: string; userCount: number }>((resolve) => client.once('user-left', resolve)); + const notices = Promise.all([left(owner.client), left(peer.client)]); + const cleanup = Reflect.get(manager, 'cleanupExpiredRooms') as () => void; + cleanup.call(manager); + (await notices).forEach((notice) => assert.deepEqual( + { roomId: notice.roomId, userCount: notice.userCount }, { roomId: expired.roomId, userCount: 0 }, + )); + assert.equal(owner.client.connected, true); + assert.equal(peer.client.connected, true); + assert.equal(owner.socket.rooms.has(expired.roomId), false); + assert.equal(peer.socket.rooms.has(expired.roomId), false); + assert.equal(owner.socket.rooms.has(active.roomId), true); + assert.equal(manager.isUserInRoom(active.roomId, owner.socket.id).isInRoom, true); + assert.deepEqual(await manager.leaveRoom({ roomId: expired.roomId, userId: a.userId! }, owner.context), { success: true, userCount: 0, roomDestroyed: true }); + assert.equal((await manager.leaveRoom({ roomId: expired.roomId, userId: b.userId! }, peer.context)).success, true); + }); });