From 0c7312b093d775154f1c770be548d193f8f2d8fc Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sat, 10 Oct 2026 09:08:43 +0100 Subject: [PATCH 1/7] build(deps): bump @exadev/eslint-config to 3.0.0 exadevConfig() takes its options object alone in 3.0.0, so every eslint.config.ts spreads its result inside defineConfig and lists its own blocks after it. The conformance rule block is scoped to TypeScript files: unscoped, it applied to JSON files, where the typescript-eslint plugin is not registered. --- conformance/eslint.config.ts | 9 +- conformance/package.json | 40 +- conformance/pnpm-lock.yaml | 489 +++++++++++--------- ts/eslint.config.ts | 48 +- ts/package.json | 2 +- ts/packages/cloudflare-hub/eslint.config.ts | 56 ++- ts/packages/cloudflare-hub/package.json | 2 +- ts/packages/core/eslint.config.ts | 54 ++- ts/packages/core/package.json | 2 +- ts/packages/node/eslint.config.ts | 54 ++- ts/packages/node/package.json | 2 +- ts/packages/web-console/eslint.config.ts | 54 ++- ts/packages/web-console/package.json | 2 +- ts/packages/wire-mesh-sfu/eslint.config.ts | 52 +-- ts/packages/wire-mesh-sfu/package.json | 2 +- ts/pnpm-lock.yaml | 285 +++++++----- 16 files changed, 617 insertions(+), 536 deletions(-) diff --git a/conformance/eslint.config.ts b/conformance/eslint.config.ts index dd96b6d1..e45b5dab 100644 --- a/conformance/eslint.config.ts +++ b/conformance/eslint.config.ts @@ -1,11 +1,13 @@ import { exadevConfig } from "@exadev/eslint-config"; +import { defineConfig } from "eslint/config"; import eslintPluginPrettierRecommended from "eslint-plugin-prettier/recommended"; import globals from "globals"; -export default exadevConfig( - {}, +export default defineConfig( + ...exadevConfig(), { - ignores: ["dist", "coverage", "node_modules", ".turbo"], + // The golden vector files are written by generate.ts and CI diffs them against a fresh generation, so their key order and layout belong to the generator, not to a formatter or key-sorting rule. + ignores: ["dist", "coverage", "node_modules", ".turbo", "*.v1.json"], }, { languageOptions: { @@ -17,6 +19,7 @@ export default exadevConfig( }, }, { + files: ["**/*.ts", "**/*.mts", "**/*.cts"], rules: { "@typescript-eslint/consistent-type-imports": [ "error", diff --git a/conformance/package.json b/conformance/package.json index 1f9afd07..bd4c1f5e 100644 --- a/conformance/package.json +++ b/conformance/package.json @@ -1,27 +1,12 @@ { "name": "@exadev/wire-mesh-conformance", "version": "0.0.0", - "private": true, - "type": "module", - "packageManager": "pnpm@12.4.1+sha512.2e81e399d73fe8390dab25e06aa788ab7a5908248d2f5a370f82b481147a6a7a367bf8048f9a6fdb6460f21a66f0542dedb8b94ca2c8723596741920b1656d4c", - "scripts": { - "build": "turbo run _build", - "_build": "tsdown", - "generate": "turbo run _generate", - "_generate": "node generate.ts", - "test": "turbo run _test", - "_test": "vitest run", - "typecheck": "turbo run _typecheck", - "_typecheck": "tsc -p tsconfig.json && tsc -p tsconfig.node.json", - "lint": "turbo run _lint", - "_lint": "eslint . --fix --cache --max-warnings 0" - }, "dependencies": { "cbor2": "2.3.0" }, "devDependencies": { "@arethetypeswrong/cli": "0.18.5", - "@exadev/eslint-config": "2.12.1", + "@exadev/eslint-config": "3.0.0", "@types/node": "26.4.1", "eslint": "10.10.0", "eslint-config-prettier": "10.1.8", @@ -34,14 +19,29 @@ "typescript": "6.0.3", "vitest": "5.0.0" }, - "main": "./dist/codec.cjs", - "module": "./dist/codec.mjs", - "types": "./dist/codec.d.cts", "exports": { ".": { "import": "./dist/codec.mjs", "require": "./dist/codec.cjs" }, "./package.json": "./package.json" - } + }, + "main": "./dist/codec.cjs", + "module": "./dist/codec.mjs", + "packageManager": "pnpm@12.4.1+sha512.2e81e399d73fe8390dab25e06aa788ab7a5908248d2f5a370f82b481147a6a7a367bf8048f9a6fdb6460f21a66f0542dedb8b94ca2c8723596741920b1656d4c", + "private": true, + "scripts": { + "_build": "tsdown", + "_generate": "node generate.ts", + "_lint": "eslint . --fix --cache --max-warnings 0", + "_test": "vitest run", + "_typecheck": "tsc -p tsconfig.json && tsc -p tsconfig.node.json", + "build": "turbo run _build", + "generate": "turbo run _generate", + "lint": "turbo run _lint", + "test": "turbo run _test", + "typecheck": "turbo run _typecheck" + }, + "type": "module", + "types": "./dist/codec.d.cts" } diff --git a/conformance/pnpm-lock.yaml b/conformance/pnpm-lock.yaml index 0f75c6e3..58b7e0c1 100644 --- a/conformance/pnpm-lock.yaml +++ b/conformance/pnpm-lock.yaml @@ -165,7 +165,6 @@ settings: importers: .: - configDependencies: {} dependencies: cbor2: specifier: 2.3.0 @@ -175,20 +174,20 @@ importers: specifier: 0.18.5 version: 0.18.5 '@exadev/eslint-config': - specifier: 2.12.1 - version: 2.12.1(eslint@10.10.0(jiti@2.7.0))(typescript-eslint@8.69.0(eslint@10.10.0(jiti@2.7.0))(typescript@6.0.3))(typescript@6.0.3) + specifier: 3.0.0 + version: 3.0.0(@eslint/json@2.1.1)(eslint@10.10.0(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript-eslint@8.69.0(eslint@10.10.0(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3))(typescript@6.0.3) '@types/node': specifier: 26.4.1 version: 26.4.1 eslint: specifier: 10.10.0 - version: 10.10.0(jiti@2.7.0) + version: 10.10.0(jiti@2.7.0)(supports-color@7.2.0) eslint-config-prettier: specifier: 10.1.8 - version: 10.1.8(eslint@10.10.0(jiti@2.7.0)) + version: 10.1.8(eslint@10.10.0(jiti@2.7.0)(supports-color@7.2.0)) eslint-plugin-prettier: specifier: 5.5.6 - version: 5.5.6(eslint-config-prettier@10.1.8(eslint@10.10.0(jiti@2.7.0)))(eslint@10.10.0(jiti@2.7.0))(prettier@3.9.6) + version: 5.5.6(eslint-config-prettier@10.1.8(eslint@10.10.0(jiti@2.7.0)(supports-color@7.2.0)))(eslint@10.10.0(jiti@2.7.0)(supports-color@7.2.0))(prettier@3.9.6) globals: specifier: 17.12.0 version: 17.12.0 @@ -209,11 +208,7 @@ importers: version: 6.0.3 vitest: specifier: 5.0.0 - version: 5.0.0(@types/node@26.4.1)(vite@8.2.2(@types/node@26.4.1)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)) - packageManagerDependencies: - pnpm: - specifier: 12.4.1 - version: 12.4.1 + version: 5.0.0(@types/node@26.4.1)(vite@8.2.2(@types/node@26.4.1)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.1)) packages: @@ -441,6 +436,10 @@ packages: eslint: optional: true + '@eslint/json@2.1.1': + resolution: {integrity: sha512-ffVzPZALaJzxuo46ggBtJ9c8YBWKLMh/R35aFBHk/dRobmFNortJlHJfBWbHGuydoFeVZbvPy0PLX2wvCI9T9w==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + '@eslint/object-schema@3.0.5': resolution: {integrity: sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw==} engines: {node: ^20.19.0 || ^22.13.0 || >=24} @@ -449,26 +448,38 @@ packages: resolution: {integrity: sha512-IkO+/KEUvwbVpiURZg+P7zF74z5Jxe0UgJxVni+RtoHQ6IZieXaO02kmadomap/q+l6bc/jdPGGqTjhuZnuz1Q==} engines: {node: ^20.19.0 || ^22.13.0 || >=24} - '@exadev/eslint-config@2.12.1': - resolution: {integrity: sha512-b5JKCX7l5onZy0i0kHdIeuiocxDoiuEwnrG6+j+EV8ZWaLpi18BDzNi1iT3tc647DFRUYJtHWDiJrkWT6E2JjQ==} - engines: {node: '>=20'} + '@exadev/eslint-config@3.0.0': + resolution: {integrity: sha512-nvyOjjM1sGKjQo6buZt69vJ5ND0CuE/lxoDvR2oRgv74qj4gJBZr10d6Y/QeTd6LQIPDkPPnH78YYfX/HSzNJA==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} peerDependencies: + '@eslint/json': ^2.1.0 + '@eslint/markdown': ^8.0.3 '@next/eslint-plugin-next': ^16.3.2 + '@vitest/eslint-plugin': ^1.6.27 eslint: '>=10.0.0' eslint-plugin-jsx-a11y: ^6.10.2 eslint-plugin-react: ^7.37.5 eslint-plugin-react-hooks: ^7.1.1 + eslint-plugin-turbo: ^2.3.1 typescript: '>=4.8.4' typescript-eslint: '>=8.0.0' peerDependenciesMeta: + '@eslint/json': + optional: true + '@eslint/markdown': + optional: true '@next/eslint-plugin-next': optional: true + '@vitest/eslint-plugin': + optional: true eslint-plugin-jsx-a11y: optional: true eslint-plugin-react: optional: true eslint-plugin-react-hooks: optional: true + eslint-plugin-turbo: + optional: true '@humanfs/core@0.19.2': resolution: {integrity: sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==} @@ -486,6 +497,10 @@ packages: resolution: {integrity: sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==} engines: {node: '>=12.22'} + '@humanwhocodes/momoa@3.3.13': + resolution: {integrity: sha512-AbKq3EW41+HY6pNMJgAOrz68iyiTTf0Nr1JRHHfuG5zwniJ9Dw8J3U7oa1I2ptpqYwmEf1sXKOGK6lbdZ9eqmg==} + engines: {node: '>=18'} + '@humanwhocodes/retry@0.4.3': resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==} engines: {node: '>=18.18'} @@ -518,6 +533,9 @@ packages: '@microsoft/tsdoc@0.16.0': resolution: {integrity: sha512-xgAyonlVVS+q7Vc7qLW0UrJU7rSFcETRWsqdXZtjzRU8dF+6CkozTK4V4y1LwOX7j8r/vHphjDeMeGI4tNGeGA==} + '@microsoft/tsdoc@0.17.1': + resolution: {integrity: sha512-VHyYyyoH63jFoO3shpw9Zfijk5v/9p/VrClW35z0/q6ooK7sSmxpzOkS4iZPxs3vO4XtBW4H38HRAKkAMIK7DA==} + '@oxc-project/types@0.148.0': resolution: {integrity: sha512-Nm4s/jB+4FpFsPhWGEC4h7rzksesmtnMXomo6rCMcg/b8zLQuOziRgkCS1fxDCXOlJB/6Q8oABOZ/OP6RIPj9A==} @@ -525,83 +543,6 @@ packages: resolution: {integrity: sha512-SEeaJLb3qBNF/OaXnaR1NmmBbFYk1zC0ZH/52fATcRPLFg/p791YrcyFFy44Bo9sLaGuSuLp5Q6axbb/O+v/RA==} engines: {node: ^14.18.0 || >=16.0.0} - '@pnpm/exe.android-arm64@12.4.1': - resolution: {integrity: sha512-/HwsqXMSmlOfgtV9+O0ratzjV6Vd/8n1hh4rGHpCGmDURvt52MwZxdbhyxP4K03ZfvgSDvotFPr8O+RzE5Eu8A==} - cpu: [arm64] - os: [android] - - '@pnpm/exe.android-x64@12.4.1': - resolution: {integrity: sha512-+l74Qb4c2YjOzNKHXJLg+1wr8xHM1ckkUhnU5KRUK9TJqiiczt6yeTZqqzHIlJ8i6pAoj5V0OJevDRwnQKLgrQ==} - cpu: [x64] - os: [android] - - '@pnpm/exe.darwin-arm64@12.4.1': - resolution: {integrity: sha512-6rkZkT3iGfaxknUdGHraqSWFvTa6N0ajAHluv9Ax0GRWs0sIcGNiFhDopv6xSZCsJZmG483aNS/b6UEDy3blfw==} - cpu: [arm64] - os: [darwin] - - '@pnpm/exe.darwin-x64@12.4.1': - resolution: {integrity: sha512-Vb1CHlR88HghC1qUxjxjs82zQSXnXacPD+btG2CmG8Q/hBU7Q0/b2YWJKSQqZXicunV5khFtfTlAwJddV9RkYA==} - cpu: [x64] - os: [darwin] - - '@pnpm/exe.freebsd-x64@12.4.1': - resolution: {integrity: sha512-iT3iHz3Nl0Sxxj7UPOtZ/aQ81AFsQhjoeWMAlPkSRO04gsgDGAXv3UYxOFaesMWsfNxaGn0A+CITbuCHFF66FA==} - cpu: [x64] - os: [freebsd] - - '@pnpm/exe.linux-arm64-musl@12.4.1': - resolution: {integrity: sha512-aBooZfNXM5f+OGUgCAMFWpE/kAhsWfvmqIyMtHy6zl3aNxyInWrcY/Saln/UElzo7lZWMC0Yktroxd/2J26lNQ==} - cpu: [arm64] - os: [linux] - libc: [musl] - - '@pnpm/exe.linux-arm64@12.4.1': - resolution: {integrity: sha512-TlOdacTTP09BgcMvwWBFRsu8VAjfwqwnslBj+XSq1JFM3ck4f3k+1O/747EuctxZxs3/o785b6Q3s7Pd92Ptsg==} - cpu: [arm64] - os: [linux] - libc: [glibc] - - '@pnpm/exe.linux-ppc64@12.4.1': - resolution: {integrity: sha512-r/ab/MlIBo75oizUP5ITiziCCrnXz4SJwfErLQ+603AshB+Yq7xTMCoMtzTSCAMu6aaymIKkAFtZvd2J75Wq0w==} - cpu: [ppc64] - os: [linux] - libc: [glibc] - - '@pnpm/exe.linux-riscv64@12.4.1': - resolution: {integrity: sha512-C/D1QWdKMiB8+wv/spl1rGITFUuqC+aI/fb6h8NB5sqxsOaGXeYc/g891oCuzggHn6SODk9p+I09hI76x/cMNw==} - cpu: [riscv64] - os: [linux] - libc: [glibc] - - '@pnpm/exe.linux-s390x@12.4.1': - resolution: {integrity: sha512-nxz5zD4yXt94uzbStDk0QTPKW+aE92hH1b5tFXK9ctB1HE9Xcq1vjTiA2LsZMFC6p4gdu5OwQeFqYNAVGa6QlA==} - cpu: [s390x] - os: [linux] - libc: [glibc] - - '@pnpm/exe.linux-x64-musl@12.4.1': - resolution: {integrity: sha512-5AwgFdGhVUg2kIweYGfxzSLEHiIG77PQhZAkXC3TwofQHsu1Wr+TrV5/rNX2PopFnHRzuE581zoB8F6Wle32yg==} - cpu: [x64] - os: [linux] - libc: [musl] - - '@pnpm/exe.linux-x64@12.4.1': - resolution: {integrity: sha512-FJOZuuuQMhp0oLzBtcKkLXknBI92hfkmSnlKc47vfin4HrmfID5khY2lGekL9tCzk1cpR+HShEw/meFl+nHtzQ==} - cpu: [x64] - os: [linux] - libc: [glibc] - - '@pnpm/exe.win32-arm64@12.4.1': - resolution: {integrity: sha512-OO7eKBL9S+xk5hRy+JUUZSNJknGuGe3GEUffsrlC2LbqKF02g+VX1anGIzSbJDvkkdnpJhSlxF5AUz2JzJu2Jw==} - cpu: [arm64] - os: [win32] - - '@pnpm/exe.win32-x64@12.4.1': - resolution: {integrity: sha512-x7gJHZgHo6hp354xCYA2NvoFzYJkHwovt2kVsUBK5EmXULLcP51JGMq09CX+5FRFJUZFKoXjLu3mZWmfP7o6PQ==} - cpu: [x64] - os: [win32] - '@quansync/fs@1.0.0': resolution: {integrity: sha512-4TJ3DFtlf1L5LDMaM6CanJ/0lckGNtJcMjQ1NAV6zDmA0tEHKZtxNKin8EgPaVX1YzljbxckyT2tJrpQKAtngQ==} @@ -712,6 +653,18 @@ packages: resolution: {integrity: sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw==} engines: {node: '>=10'} + '@stylistic/eslint-plugin@6.0.0-beta.6': + resolution: {integrity: sha512-DqDQZSrV/0AZ/4qLxaQUtNxafk3qWfeG5xOHx64ELjHWSHt2GtELZR6ClrNVvQwo/d13Atf8Hrh/s/ZnuJk57w==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + peerDependencies: + eslint: ^9.0.0 || ^10.0.0 + oxlint: ^1.55.0 + peerDependenciesMeta: + eslint: + optional: true + oxlint: + optional: true + '@turbo/darwin-64@2.10.12': resolution: {integrity: sha512-9nKgKoF6ZOUsM+or0OtNf+TTJSfGvDNP7ZFv/ZGWVwOSCkumyctQiTeHwB4UNljHTnC41AqylgbunLDHoccNrA==} cpu: [x64] @@ -787,6 +740,12 @@ packages: peerDependencies: typescript: '>=4.8.4 <6.1.0' + '@typescript-eslint/project-service@8.71.0': + resolution: {integrity: sha512-aABjw5rjBacYONVPaPiWOCjJu0vEF4a25iQuodlmQYL1trtLZ0X/y+2Vzl3BKI1odM4LnwLE1oUDXYp1wzx1TQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + '@typescript-eslint/scope-manager@8.56.1': resolution: {integrity: sha512-YAi4VDKcIZp0O4tz/haYKhmIDZFEUPOreKbfdAN3SzUDMcPhJ8QI99xQXqX+HoUVq8cs85eRKnD+rne2UAnj2w==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -795,6 +754,10 @@ packages: resolution: {integrity: sha512-ewfspqWvSxKSOaplqAUNbaSFO0eB6w1EtQ+esfYFRm3614Ty4uNtExkcbgd6nWsXphbqKyf9ZYdbZdv2xEoWEQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@typescript-eslint/scope-manager@8.71.0': + resolution: {integrity: sha512-gWF0BhUcnjZxSpLE8ngS/59n2SB0J3YqRxvX1+2aoRJk9hNtHSLOV+TcarFiOr5ipXm3yc1QrI4c9YZc8zyCxw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@typescript-eslint/tsconfig-utils@8.56.1': resolution: {integrity: sha512-qOtCYzKEeyr3aR9f28mPJqBty7+DBqsdd63eO0yyDwc6vgThj2UjWfJIcsFeSucYydqcuudMOprZ+x1SpF3ZuQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -807,6 +770,12 @@ packages: peerDependencies: typescript: '>=4.8.4 <6.1.0' + '@typescript-eslint/tsconfig-utils@8.71.0': + resolution: {integrity: sha512-Z1UlWHADEK2Mlb9NpWfDeSjqoZ5EyrOv4R3eQpbkzqn/EwaIdOpXXupEA1+0ZIOSJSZZDBHG0BrQyN8zUG6Pwg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + '@typescript-eslint/type-utils@8.69.0': resolution: {integrity: sha512-ZfoJAVg3JZndQEpEl9petVlxau3lRuElc4HRMuAlLCf8to04/iHz692RUSNmXKDjEuJmIL+KZ2/BsOcBc16dsA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -822,6 +791,10 @@ packages: resolution: {integrity: sha512-K3VrubUPhlo9VDBS6QdI8YB5j7ClpqLRdefcz6PFrhnwicehBweqQ9Evhl4l+FYz0HdDmMqIiSX0aldGRYtDCA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@typescript-eslint/types@8.71.0': + resolution: {integrity: sha512-cJ4OoxPGWvFnBTnSZyaU+qJzGTqPTGJY+gDchj6cRyLRdmIdt4rcsE4twj+zPfrNiWuVi38wijHzShL++Z9atQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@typescript-eslint/typescript-estree@8.56.1': resolution: {integrity: sha512-qzUL1qgalIvKWAf9C1HpvBjif+Vm6rcT5wZd4VoMb9+Km3iS3Cv9DY6dMRMDtPnwRAFyAi7YXJpTIEXLvdfPxg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -834,6 +807,12 @@ packages: peerDependencies: typescript: '>=4.8.4 <6.1.0' + '@typescript-eslint/typescript-estree@8.71.0': + resolution: {integrity: sha512-PEEF4G5sLLWAS5BpPrUvms4ySZkiBQQZM4z+3ReI46axK5Vqr/vXBQatJQIZZOYdGyPUAKTtsrWzpqKuU+3DEw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + '@typescript-eslint/utils@8.56.1': resolution: {integrity: sha512-HPAVNIME3tABJ61siYlHzSWCGtOoeP2RTIaHXFMPqjrQKCGB9OgUVdiNgH7TJS2JNIQ5qQ4RsAUDuGaGme/KOA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -848,6 +827,13 @@ packages: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: '>=4.8.4 <6.1.0' + '@typescript-eslint/utils@8.71.0': + resolution: {integrity: sha512-pKR/tEMVrXZG23UFKUn5BQf3zfmfk7KQceI2cGzywZ5nxM5Eu3hEJU1utjWzydtzBbcJAQhHN8iPCxobHpPcZQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + '@typescript-eslint/visitor-keys@8.56.1': resolution: {integrity: sha512-KiROIzYdEV85YygXw6BI/Dx4fnBlFQu6Mq4QE4MOH9fFnhohw6wX/OAvDY2/C+ut0I3RSPKenvZJIVYqJNkhEw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -856,6 +842,10 @@ packages: resolution: {integrity: sha512-+rmdgPA+EXkNgKYvHvFfhrs35utXbwaC5PGpDquSXcoXQDKUA5UjV0LmTucG/4JXkM31BTu4TilHtrN8IVBe8w==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@typescript-eslint/visitor-keys@8.71.0': + resolution: {integrity: sha512-8eQ9R218XORK+KLosnf4bu/QsUXvUyVwTbArg7/0NMB1Pu87OJKvj4nhFblkYE8gQV73mW1dx1ptlPCkwRGa7A==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@vitest/mocker@5.0.0': resolution: {integrity: sha512-66PGTMIiVJP3t4a5yxU9qPtf7MdTBs8jmToMvy+HVflB3Yy13WJZTtPePdvU+wjRV02SKK5doLbSA6o9pwOmiA==} peerDependencies: @@ -1195,6 +1185,13 @@ packages: peerDependencies: eslint: ^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0 + eslint-plugin-json-canonical@2.1.3: + resolution: {integrity: sha512-qAcRVGkXGZvgduOIEBxHLOZhqUf0NdVY+bye/KXbd0oPH5VvoBBBETFEaJ1qa6efLSCH17C4sss7uvr80TfNTQ==} + engines: {node: '>=20'} + peerDependencies: + '@eslint/json': ^2.1.0 + eslint: '>=10.0.0' + eslint-plugin-prettier@5.5.6: resolution: {integrity: sha512-ifetmTcxWfz+4qRW3pH/ujdTq2jQIj59AxJMIN26K5avYgU8dxycUETQonWiW+wPrYXA0j3Try0l1CnwVQtDqQ==} engines: {node: ^14.18.0 || >=16.0.0} @@ -1588,12 +1585,6 @@ packages: resolution: {integrity: sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==} engines: {node: '>=12'} - pnpm@12.4.1: - resolution: {integrity: sha512-LoHjmdc/6DkNqyXgaqeIq3pZCCSNL1o3D4K0gRR6ano2e/gEj5pv22Rg8hpm8FQt7bi5TKLIcjWWdBkgsWVtTA==} - engines: {node: '>=18.*'} - hasBin: true - - postcss@8.5.28: resolution: {integrity: sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==} engines: {node: ^10 || ^12 || >=14} @@ -1957,6 +1948,11 @@ packages: resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} engines: {node: '>=10'} + yaml@2.9.1: + resolution: {integrity: sha512-3NxN8+78OdzbT7C/WjGsyfPAtJaN3FNDsWxv7Y7mcDsT/oOmgW8BpyQQFFBnvZE3j9Y2Sdz1ULFLezL7Eb2yFw==} + engines: {node: '>= 14.6'} + hasBin: true + yargs-parser@20.2.9: resolution: {integrity: sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w==} engines: {node: '>=10'} @@ -2110,17 +2106,17 @@ snapshots: '@esbuild/win32-x64@0.28.2': optional: true - '@eslint-community/eslint-utils@4.10.1(eslint@10.10.0(jiti@2.7.0))': + '@eslint-community/eslint-utils@4.10.1(eslint@10.10.0(jiti@2.7.0)(supports-color@7.2.0))': dependencies: - eslint: 10.10.0(jiti@2.7.0) + eslint: 10.10.0(jiti@2.7.0)(supports-color@7.2.0) eslint-visitor-keys: 3.4.3 '@eslint-community/regexpp@4.12.2': {} - '@eslint/config-array@0.23.5': + '@eslint/config-array@0.23.5(supports-color@7.2.0)': dependencies: '@eslint/object-schema': 3.0.5 - debug: 4.4.3 + debug: 4.4.3(supports-color@7.2.0) minimatch: 10.2.6 transitivePeerDependencies: - supports-color @@ -2133,9 +2129,16 @@ snapshots: dependencies: '@types/json-schema': 7.0.15 - '@eslint/js@10.0.1(eslint@10.10.0(jiti@2.7.0))': + '@eslint/js@10.0.1(eslint@10.10.0(jiti@2.7.0)(supports-color@7.2.0))': optionalDependencies: - eslint: 10.10.0(jiti@2.7.0) + eslint: 10.10.0(jiti@2.7.0)(supports-color@7.2.0) + + '@eslint/json@2.1.1': + dependencies: + '@eslint/core': 1.2.1 + '@eslint/plugin-kit': 0.7.3 + '@humanwhocodes/momoa': 3.3.13 + natural-compare: 1.4.0 '@eslint/object-schema@3.0.5': {} @@ -2144,17 +2147,26 @@ snapshots: '@eslint/core': 1.2.1 levn: 0.4.1 - '@exadev/eslint-config@2.12.1(eslint@10.10.0(jiti@2.7.0))(typescript-eslint@8.69.0(eslint@10.10.0(jiti@2.7.0))(typescript@6.0.3))(typescript@6.0.3)': - dependencies: - '@eslint/js': 10.0.1(eslint@10.10.0(jiti@2.7.0)) - '@typescript-eslint/utils': 8.69.0(eslint@10.10.0(jiti@2.7.0))(typescript@6.0.3) - eslint: 10.10.0(jiti@2.7.0) - eslint-plugin-jsdoc: 64.3.6(eslint@10.10.0(jiti@2.7.0))(typescript@6.0.3) - eslint-plugin-tsdoc: 0.5.2(eslint@10.10.0(jiti@2.7.0))(typescript@6.0.3) + '@exadev/eslint-config@3.0.0(@eslint/json@2.1.1)(eslint@10.10.0(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript-eslint@8.69.0(eslint@10.10.0(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3))(typescript@6.0.3)': + dependencies: + '@eslint/js': 10.0.1(eslint@10.10.0(jiti@2.7.0)(supports-color@7.2.0)) + '@microsoft/tsdoc': 0.17.1 + '@stylistic/eslint-plugin': 6.0.0-beta.6(eslint@10.10.0(jiti@2.7.0)(supports-color@7.2.0)) + '@typescript-eslint/typescript-estree': 8.71.0(supports-color@7.2.0)(typescript@6.0.3) + '@typescript-eslint/utils': 8.71.0(eslint@10.10.0(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3) + eslint: 10.10.0(jiti@2.7.0)(supports-color@7.2.0) + eslint-plugin-jsdoc: 64.3.6(eslint@10.10.0(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3) + eslint-plugin-json-canonical: 2.1.3(@eslint/json@2.1.1)(eslint@10.10.0(jiti@2.7.0)(supports-color@7.2.0)) + eslint-plugin-tsdoc: 0.5.2(eslint@10.10.0(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3) + minimatch: 10.2.6 ts-api-utils: 2.5.0(typescript@6.0.3) typescript: 6.0.3 - typescript-eslint: 8.69.0(eslint@10.10.0(jiti@2.7.0))(typescript@6.0.3) + typescript-eslint: 8.69.0(eslint@10.10.0(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3) + yaml: 2.9.1 + optionalDependencies: + '@eslint/json': 2.1.1 transitivePeerDependencies: + - oxlint - supports-color '@humanfs/core@0.19.2': @@ -2171,6 +2183,8 @@ snapshots: '@humanwhocodes/module-importer@1.0.1': {} + '@humanwhocodes/momoa@3.3.13': {} + '@humanwhocodes/retry@0.4.3': {} '@jridgewell/resolve-uri@3.1.2': {} @@ -2203,52 +2217,12 @@ snapshots: '@microsoft/tsdoc@0.16.0': {} + '@microsoft/tsdoc@0.17.1': {} + '@oxc-project/types@0.148.0': {} '@pkgr/core@0.3.6': {} - '@pnpm/exe.android-arm64@12.4.1': - optional: true - - '@pnpm/exe.android-x64@12.4.1': - optional: true - - '@pnpm/exe.darwin-arm64@12.4.1': - optional: true - - '@pnpm/exe.darwin-x64@12.4.1': - optional: true - - '@pnpm/exe.freebsd-x64@12.4.1': - optional: true - - '@pnpm/exe.linux-arm64-musl@12.4.1': - optional: true - - '@pnpm/exe.linux-arm64@12.4.1': - optional: true - - '@pnpm/exe.linux-ppc64@12.4.1': - optional: true - - '@pnpm/exe.linux-riscv64@12.4.1': - optional: true - - '@pnpm/exe.linux-s390x@12.4.1': - optional: true - - '@pnpm/exe.linux-x64-musl@12.4.1': - optional: true - - '@pnpm/exe.linux-x64@12.4.1': - optional: true - - '@pnpm/exe.win32-arm64@12.4.1': - optional: true - - '@pnpm/exe.win32-x64@12.4.1': - optional: true - '@quansync/fs@1.0.0': dependencies: quansync: 1.0.0 @@ -2304,6 +2278,16 @@ snapshots: '@sindresorhus/is@4.6.0': {} + '@stylistic/eslint-plugin@6.0.0-beta.6(eslint@10.10.0(jiti@2.7.0)(supports-color@7.2.0))': + dependencies: + '@eslint-community/eslint-utils': 4.10.1(eslint@10.10.0(jiti@2.7.0)(supports-color@7.2.0)) + '@typescript-eslint/types': 8.69.0 + '@typescript-eslint/visitor-keys': 8.69.0 + espree: 11.2.0 + picomatch: 4.0.7 + optionalDependencies: + eslint: 10.10.0(jiti@2.7.0)(supports-color@7.2.0) + '@turbo/darwin-64@2.10.12': optional: true @@ -2339,15 +2323,15 @@ snapshots: dependencies: undici-types: 8.3.0 - '@typescript-eslint/eslint-plugin@8.69.0(@typescript-eslint/parser@8.69.0(eslint@10.10.0(jiti@2.7.0))(typescript@6.0.3))(eslint@10.10.0(jiti@2.7.0))(typescript@6.0.3)': + '@typescript-eslint/eslint-plugin@8.69.0(@typescript-eslint/parser@8.69.0(eslint@10.10.0(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3))(eslint@10.10.0(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3)': dependencies: '@eslint-community/regexpp': 4.12.2 - '@typescript-eslint/parser': 8.69.0(eslint@10.10.0(jiti@2.7.0))(typescript@6.0.3) + '@typescript-eslint/parser': 8.69.0(eslint@10.10.0(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3) '@typescript-eslint/scope-manager': 8.69.0 - '@typescript-eslint/type-utils': 8.69.0(eslint@10.10.0(jiti@2.7.0))(typescript@6.0.3) - '@typescript-eslint/utils': 8.69.0(eslint@10.10.0(jiti@2.7.0))(typescript@6.0.3) + '@typescript-eslint/type-utils': 8.69.0(eslint@10.10.0(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3) + '@typescript-eslint/utils': 8.69.0(eslint@10.10.0(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3) '@typescript-eslint/visitor-keys': 8.69.0 - eslint: 10.10.0(jiti@2.7.0) + eslint: 10.10.0(jiti@2.7.0)(supports-color@7.2.0) ignore: 7.0.8 natural-compare: 1.4.0 ts-api-utils: 2.5.0(typescript@6.0.3) @@ -2355,32 +2339,41 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/parser@8.69.0(eslint@10.10.0(jiti@2.7.0))(typescript@6.0.3)': + '@typescript-eslint/parser@8.69.0(eslint@10.10.0(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3)': dependencies: '@typescript-eslint/scope-manager': 8.69.0 '@typescript-eslint/types': 8.69.0 - '@typescript-eslint/typescript-estree': 8.69.0(typescript@6.0.3) + '@typescript-eslint/typescript-estree': 8.69.0(supports-color@7.2.0)(typescript@6.0.3) '@typescript-eslint/visitor-keys': 8.69.0 - debug: 4.4.3 - eslint: 10.10.0(jiti@2.7.0) + debug: 4.4.3(supports-color@7.2.0) + eslint: 10.10.0(jiti@2.7.0)(supports-color@7.2.0) typescript: 6.0.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/project-service@8.56.1(typescript@6.0.3)': + '@typescript-eslint/project-service@8.56.1(supports-color@7.2.0)(typescript@6.0.3)': dependencies: '@typescript-eslint/tsconfig-utils': 8.69.0(typescript@6.0.3) '@typescript-eslint/types': 8.69.0 - debug: 4.4.3 + debug: 4.4.3(supports-color@7.2.0) typescript: 6.0.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/project-service@8.69.0(typescript@6.0.3)': + '@typescript-eslint/project-service@8.69.0(supports-color@7.2.0)(typescript@6.0.3)': dependencies: '@typescript-eslint/tsconfig-utils': 8.69.0(typescript@6.0.3) '@typescript-eslint/types': 8.69.0 - debug: 4.4.3 + debug: 4.4.3(supports-color@7.2.0) + typescript: 6.0.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/project-service@8.71.0(supports-color@7.2.0)(typescript@6.0.3)': + dependencies: + '@typescript-eslint/tsconfig-utils': 8.71.0(typescript@6.0.3) + '@typescript-eslint/types': 8.71.0 + debug: 4.4.3(supports-color@7.2.0) typescript: 6.0.3 transitivePeerDependencies: - supports-color @@ -2395,6 +2388,11 @@ snapshots: '@typescript-eslint/types': 8.69.0 '@typescript-eslint/visitor-keys': 8.69.0 + '@typescript-eslint/scope-manager@8.71.0': + dependencies: + '@typescript-eslint/types': 8.71.0 + '@typescript-eslint/visitor-keys': 8.71.0 + '@typescript-eslint/tsconfig-utils@8.56.1(typescript@6.0.3)': dependencies: typescript: 6.0.3 @@ -2403,13 +2401,17 @@ snapshots: dependencies: typescript: 6.0.3 - '@typescript-eslint/type-utils@8.69.0(eslint@10.10.0(jiti@2.7.0))(typescript@6.0.3)': + '@typescript-eslint/tsconfig-utils@8.71.0(typescript@6.0.3)': + dependencies: + typescript: 6.0.3 + + '@typescript-eslint/type-utils@8.69.0(eslint@10.10.0(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3)': dependencies: '@typescript-eslint/types': 8.69.0 - '@typescript-eslint/typescript-estree': 8.69.0(typescript@6.0.3) - '@typescript-eslint/utils': 8.69.0(eslint@10.10.0(jiti@2.7.0))(typescript@6.0.3) - debug: 4.4.3 - eslint: 10.10.0(jiti@2.7.0) + '@typescript-eslint/typescript-estree': 8.69.0(supports-color@7.2.0)(typescript@6.0.3) + '@typescript-eslint/utils': 8.69.0(eslint@10.10.0(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3) + debug: 4.4.3(supports-color@7.2.0) + eslint: 10.10.0(jiti@2.7.0)(supports-color@7.2.0) ts-api-utils: 2.5.0(typescript@6.0.3) typescript: 6.0.3 transitivePeerDependencies: @@ -2419,13 +2421,15 @@ snapshots: '@typescript-eslint/types@8.69.0': {} - '@typescript-eslint/typescript-estree@8.56.1(typescript@6.0.3)': + '@typescript-eslint/types@8.71.0': {} + + '@typescript-eslint/typescript-estree@8.56.1(supports-color@7.2.0)(typescript@6.0.3)': dependencies: - '@typescript-eslint/project-service': 8.56.1(typescript@6.0.3) + '@typescript-eslint/project-service': 8.56.1(supports-color@7.2.0)(typescript@6.0.3) '@typescript-eslint/tsconfig-utils': 8.56.1(typescript@6.0.3) '@typescript-eslint/types': 8.56.1 '@typescript-eslint/visitor-keys': 8.56.1 - debug: 4.4.3 + debug: 4.4.3(supports-color@7.2.0) minimatch: 10.2.6 semver: 7.8.5 tinyglobby: 0.2.17 @@ -2434,13 +2438,13 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/typescript-estree@8.69.0(typescript@6.0.3)': + '@typescript-eslint/typescript-estree@8.69.0(supports-color@7.2.0)(typescript@6.0.3)': dependencies: - '@typescript-eslint/project-service': 8.69.0(typescript@6.0.3) + '@typescript-eslint/project-service': 8.69.0(supports-color@7.2.0)(typescript@6.0.3) '@typescript-eslint/tsconfig-utils': 8.69.0(typescript@6.0.3) '@typescript-eslint/types': 8.69.0 '@typescript-eslint/visitor-keys': 8.69.0 - debug: 4.4.3 + debug: 4.4.3(supports-color@7.2.0) minimatch: 10.2.6 semver: 7.8.5 tinyglobby: 0.2.17 @@ -2449,24 +2453,50 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/utils@8.56.1(eslint@10.10.0(jiti@2.7.0))(typescript@6.0.3)': + '@typescript-eslint/typescript-estree@8.71.0(supports-color@7.2.0)(typescript@6.0.3)': dependencies: - '@eslint-community/eslint-utils': 4.10.1(eslint@10.10.0(jiti@2.7.0)) + '@typescript-eslint/project-service': 8.71.0(supports-color@7.2.0)(typescript@6.0.3) + '@typescript-eslint/tsconfig-utils': 8.71.0(typescript@6.0.3) + '@typescript-eslint/types': 8.71.0 + '@typescript-eslint/visitor-keys': 8.71.0 + debug: 4.4.3(supports-color@7.2.0) + minimatch: 10.2.6 + semver: 7.8.5 + tinyglobby: 0.2.17 + ts-api-utils: 2.5.0(typescript@6.0.3) + typescript: 6.0.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/utils@8.56.1(eslint@10.10.0(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3)': + dependencies: + '@eslint-community/eslint-utils': 4.10.1(eslint@10.10.0(jiti@2.7.0)(supports-color@7.2.0)) '@typescript-eslint/scope-manager': 8.56.1 '@typescript-eslint/types': 8.56.1 - '@typescript-eslint/typescript-estree': 8.56.1(typescript@6.0.3) - eslint: 10.10.0(jiti@2.7.0) + '@typescript-eslint/typescript-estree': 8.56.1(supports-color@7.2.0)(typescript@6.0.3) + eslint: 10.10.0(jiti@2.7.0)(supports-color@7.2.0) typescript: 6.0.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/utils@8.69.0(eslint@10.10.0(jiti@2.7.0))(typescript@6.0.3)': + '@typescript-eslint/utils@8.69.0(eslint@10.10.0(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3)': dependencies: - '@eslint-community/eslint-utils': 4.10.1(eslint@10.10.0(jiti@2.7.0)) + '@eslint-community/eslint-utils': 4.10.1(eslint@10.10.0(jiti@2.7.0)(supports-color@7.2.0)) '@typescript-eslint/scope-manager': 8.69.0 '@typescript-eslint/types': 8.69.0 - '@typescript-eslint/typescript-estree': 8.69.0(typescript@6.0.3) - eslint: 10.10.0(jiti@2.7.0) + '@typescript-eslint/typescript-estree': 8.69.0(supports-color@7.2.0)(typescript@6.0.3) + eslint: 10.10.0(jiti@2.7.0)(supports-color@7.2.0) + typescript: 6.0.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/utils@8.71.0(eslint@10.10.0(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3)': + dependencies: + '@eslint-community/eslint-utils': 4.10.1(eslint@10.10.0(jiti@2.7.0)(supports-color@7.2.0)) + '@typescript-eslint/scope-manager': 8.71.0 + '@typescript-eslint/types': 8.71.0 + '@typescript-eslint/typescript-estree': 8.71.0(supports-color@7.2.0)(typescript@6.0.3) + eslint: 10.10.0(jiti@2.7.0)(supports-color@7.2.0) typescript: 6.0.3 transitivePeerDependencies: - supports-color @@ -2481,14 +2511,19 @@ snapshots: '@typescript-eslint/types': 8.69.0 eslint-visitor-keys: 5.0.1 - '@vitest/mocker@5.0.0(vite@8.2.2(@types/node@26.4.1)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13))': + '@typescript-eslint/visitor-keys@8.71.0': + dependencies: + '@typescript-eslint/types': 8.71.0 + eslint-visitor-keys: 5.0.1 + + '@vitest/mocker@5.0.0(vite@8.2.2(@types/node@26.4.1)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.1))': dependencies: '@jridgewell/trace-mapping': 0.3.31 '@vitest/spy': 5.0.0 estree-walker: 3.0.3 magic-string: 1.2.3 optionalDependencies: - vite: 8.2.2(@types/node@26.4.1)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13) + vite: 8.2.2(@types/node@26.4.1)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.1) '@vitest/spy@5.0.0': {} @@ -2674,9 +2709,11 @@ snapshots: shebang-command: 2.0.0 which: 2.0.2 - debug@4.4.3: + debug@4.4.3(supports-color@7.2.0): dependencies: ms: 2.1.3 + optionalDependencies: + supports-color: 7.2.0 deep-is@0.1.4: {} @@ -2734,20 +2771,20 @@ snapshots: escape-string-regexp@5.0.0: {} - eslint-config-prettier@10.1.8(eslint@10.10.0(jiti@2.7.0)): + eslint-config-prettier@10.1.8(eslint@10.10.0(jiti@2.7.0)(supports-color@7.2.0)): dependencies: - eslint: 10.10.0(jiti@2.7.0) + eslint: 10.10.0(jiti@2.7.0)(supports-color@7.2.0) - eslint-plugin-jsdoc@64.3.6(eslint@10.10.0(jiti@2.7.0))(typescript@6.0.3): + eslint-plugin-jsdoc@64.3.6(eslint@10.10.0(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3): dependencies: '@es-joy/jsdoccomment': 0.97.0 '@es-joy/resolve.exports': 1.2.0 - '@typescript-eslint/utils': 8.69.0(eslint@10.10.0(jiti@2.7.0))(typescript@6.0.3) + '@typescript-eslint/utils': 8.69.0(eslint@10.10.0(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3) are-docs-informative: 0.1.1 comment-parser: 1.4.8 - debug: 4.4.3 + debug: 4.4.3(supports-color@7.2.0) escape-string-regexp: 5.0.0 - eslint: 10.10.0(jiti@2.7.0) + eslint: 10.10.0(jiti@2.7.0)(supports-color@7.2.0) espree: 11.2.0 esquery: 1.7.0 html-entities: 2.6.0 @@ -2760,20 +2797,25 @@ snapshots: - supports-color - typescript - eslint-plugin-prettier@5.5.6(eslint-config-prettier@10.1.8(eslint@10.10.0(jiti@2.7.0)))(eslint@10.10.0(jiti@2.7.0))(prettier@3.9.6): + eslint-plugin-json-canonical@2.1.3(@eslint/json@2.1.1)(eslint@10.10.0(jiti@2.7.0)(supports-color@7.2.0)): + dependencies: + '@eslint/json': 2.1.1 + eslint: 10.10.0(jiti@2.7.0)(supports-color@7.2.0) + + eslint-plugin-prettier@5.5.6(eslint-config-prettier@10.1.8(eslint@10.10.0(jiti@2.7.0)(supports-color@7.2.0)))(eslint@10.10.0(jiti@2.7.0)(supports-color@7.2.0))(prettier@3.9.6): dependencies: - eslint: 10.10.0(jiti@2.7.0) + eslint: 10.10.0(jiti@2.7.0)(supports-color@7.2.0) prettier: 3.9.6 prettier-linter-helpers: 1.0.1 synckit: 0.11.13 optionalDependencies: - eslint-config-prettier: 10.1.8(eslint@10.10.0(jiti@2.7.0)) + eslint-config-prettier: 10.1.8(eslint@10.10.0(jiti@2.7.0)(supports-color@7.2.0)) - eslint-plugin-tsdoc@0.5.2(eslint@10.10.0(jiti@2.7.0))(typescript@6.0.3): + eslint-plugin-tsdoc@0.5.2(eslint@10.10.0(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3): dependencies: '@microsoft/tsdoc': 0.16.0 '@microsoft/tsdoc-config': 0.18.1 - '@typescript-eslint/utils': 8.56.1(eslint@10.10.0(jiti@2.7.0))(typescript@6.0.3) + '@typescript-eslint/utils': 8.56.1(eslint@10.10.0(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3) transitivePeerDependencies: - eslint - supports-color @@ -2790,11 +2832,11 @@ snapshots: eslint-visitor-keys@5.0.1: {} - eslint@10.10.0(jiti@2.7.0): + eslint@10.10.0(jiti@2.7.0)(supports-color@7.2.0): dependencies: - '@eslint-community/eslint-utils': 4.10.1(eslint@10.10.0(jiti@2.7.0)) + '@eslint-community/eslint-utils': 4.10.1(eslint@10.10.0(jiti@2.7.0)(supports-color@7.2.0)) '@eslint-community/regexpp': 4.12.2 - '@eslint/config-array': 0.23.5 + '@eslint/config-array': 0.23.5(supports-color@7.2.0) '@eslint/config-helpers': 0.7.0 '@eslint/core': 1.2.1 '@eslint/plugin-kit': 0.7.3 @@ -2804,7 +2846,7 @@ snapshots: '@types/estree': 1.0.9 ajv: 6.15.0 cross-spawn: 7.0.6 - debug: 4.4.3 + debug: 4.4.3(supports-color@7.2.0) escape-string-regexp: 4.0.0 eslint-scope: 9.1.2 eslint-visitor-keys: 5.0.1 @@ -3109,24 +3151,6 @@ snapshots: picomatch@4.0.7: {} - pnpm@12.4.1: - optionalDependencies: - '@pnpm/exe.android-arm64': 12.4.1 - '@pnpm/exe.android-x64': 12.4.1 - '@pnpm/exe.darwin-arm64': 12.4.1 - '@pnpm/exe.darwin-x64': 12.4.1 - '@pnpm/exe.freebsd-x64': 12.4.1 - '@pnpm/exe.linux-arm64': 12.4.1 - '@pnpm/exe.linux-arm64-musl': 12.4.1 - '@pnpm/exe.linux-ppc64': 12.4.1 - '@pnpm/exe.linux-riscv64': 12.4.1 - '@pnpm/exe.linux-s390x': 12.4.1 - '@pnpm/exe.linux-x64': 12.4.1 - '@pnpm/exe.linux-x64-musl': 12.4.1 - '@pnpm/exe.win32-arm64': 12.4.1 - '@pnpm/exe.win32-x64': 12.4.1 - - postcss@8.5.28: dependencies: nanoid: 3.3.18 @@ -3329,13 +3353,13 @@ snapshots: dependencies: prelude-ls: 1.2.1 - typescript-eslint@8.69.0(eslint@10.10.0(jiti@2.7.0))(typescript@6.0.3): + typescript-eslint@8.69.0(eslint@10.10.0(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3): dependencies: - '@typescript-eslint/eslint-plugin': 8.69.0(@typescript-eslint/parser@8.69.0(eslint@10.10.0(jiti@2.7.0))(typescript@6.0.3))(eslint@10.10.0(jiti@2.7.0))(typescript@6.0.3) - '@typescript-eslint/parser': 8.69.0(eslint@10.10.0(jiti@2.7.0))(typescript@6.0.3) - '@typescript-eslint/typescript-estree': 8.69.0(typescript@6.0.3) - '@typescript-eslint/utils': 8.69.0(eslint@10.10.0(jiti@2.7.0))(typescript@6.0.3) - eslint: 10.10.0(jiti@2.7.0) + '@typescript-eslint/eslint-plugin': 8.69.0(@typescript-eslint/parser@8.69.0(eslint@10.10.0(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3))(eslint@10.10.0(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3) + '@typescript-eslint/parser': 8.69.0(eslint@10.10.0(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3) + '@typescript-eslint/typescript-estree': 8.69.0(supports-color@7.2.0)(typescript@6.0.3) + '@typescript-eslint/utils': 8.69.0(eslint@10.10.0(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3) + eslint: 10.10.0(jiti@2.7.0)(supports-color@7.2.0) typescript: 6.0.3 transitivePeerDependencies: - supports-color @@ -3361,7 +3385,7 @@ snapshots: verkit@0.4.0: {} - vite@8.2.2(@types/node@26.4.1)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13): + vite@8.2.2(@types/node@26.4.1)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.1): dependencies: lightningcss: 1.33.0 picomatch: 4.0.7 @@ -3374,11 +3398,12 @@ snapshots: fsevents: 2.3.3 jiti: 2.7.0 tsx: 4.23.13 + yaml: 2.9.1 - vitest@5.0.0(@types/node@26.4.1)(vite@8.2.2(@types/node@26.4.1)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)): + vitest@5.0.0(@types/node@26.4.1)(vite@8.2.2(@types/node@26.4.1)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.1)): dependencies: '@types/chai': 5.2.3 - '@vitest/mocker': 5.0.0(vite@8.2.2(@types/node@26.4.1)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)) + '@vitest/mocker': 5.0.0(vite@8.2.2(@types/node@26.4.1)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.1)) chai: 6.2.2 es-module-lexer: 2.3.2 expect-type: 1.4.0 @@ -3389,7 +3414,7 @@ snapshots: tinybench: 6.1.4 tinyexec: 1.3.0 tinyglobby: 0.2.17 - vite: 8.2.2(@types/node@26.4.1)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13) + vite: 8.2.2(@types/node@26.4.1)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.1) why-is-node-running: 2.3.0 optionalDependencies: '@types/node': 26.4.1 @@ -3415,6 +3440,8 @@ snapshots: y18n@5.0.8: {} + yaml@2.9.1: {} + yargs-parser@20.2.9: {} yargs@16.2.2: diff --git a/ts/eslint.config.ts b/ts/eslint.config.ts index b0b16a17..1f347815 100644 --- a/ts/eslint.config.ts +++ b/ts/eslint.config.ts @@ -3,33 +3,33 @@ import { defineConfig } from "eslint/config"; import eslintPluginPrettierRecommended from "eslint-plugin-prettier/recommended"; import globals from "globals"; -// Lints the workspace root's own tooling configs, and nothing else. -// -// packages/** stays ignored: each package keeps its own eslint.config.ts because file scoping and tsconfig wiring are genuinely per-package concerns, and a root run that also walked them would apply this program's tsconfig to source files it does not contain. +/** + * Lints the workspace root's own tooling configs, and nothing else. + * + * packages/** stays ignored: each package keeps its own eslint.config.ts because file scoping and tsconfig wiring are genuinely per-package concerns, and a root run that also walked them would apply this program's tsconfig to source files it does not contain. + */ export default defineConfig( - exadevConfig( - {}, - { - ignores: ["packages/**", "node_modules", ".turbo"], - }, - { - languageOptions: { - parserOptions: { - project: ["./tsconfig.json"], - tsconfigRootDir: import.meta.dirname, - }, - globals: { ...globals.node }, + ...exadevConfig(), + { + ignores: ["packages/**", "node_modules", ".turbo"], + }, + { + languageOptions: { + parserOptions: { + project: ["./tsconfig.json"], + tsconfigRootDir: import.meta.dirname, }, + globals: { ...globals.node }, }, - { - files: ["**/*.ts", "**/*.mts", "**/*.cts"], - rules: { - "@typescript-eslint/consistent-type-imports": [ - "error", - { fixStyle: "inline-type-imports" }, - ], - }, + }, + { + files: ["**/*.ts", "**/*.mts", "**/*.cts"], + rules: { + "@typescript-eslint/consistent-type-imports": [ + "error", + { fixStyle: "inline-type-imports" }, + ], }, - ), + }, eslintPluginPrettierRecommended, ); diff --git a/ts/package.json b/ts/package.json index 61db4f49..b1c3963c 100644 --- a/ts/package.json +++ b/ts/package.json @@ -2,7 +2,7 @@ "name": "wire-mesh-ts", "version": "0.0.0", "devDependencies": { - "@exadev/eslint-config": "2.18.0", + "@exadev/eslint-config": "3.0.0", "@exadev/semantic-release-workspace": "3.0.2", "@semantic-release/changelog": "7.0.0", "@semantic-release/commit-analyzer": "13.0.1", diff --git a/ts/packages/cloudflare-hub/eslint.config.ts b/ts/packages/cloudflare-hub/eslint.config.ts index bb074da8..f7f5f5a8 100644 --- a/ts/packages/cloudflare-hub/eslint.config.ts +++ b/ts/packages/cloudflare-hub/eslint.config.ts @@ -4,37 +4,35 @@ import eslintPluginPrettierRecommended from "eslint-plugin-prettier/recommended" import globals from "globals"; export default defineConfig( - exadevConfig( - {}, - { - // scripts/ holds plain-JS operational scripts (the live-runtime check) with no TS project to type them against. - ignores: [ - "dist", - "coverage", - "node_modules", - ".turbo", - ".wrangler", - "scripts", - ], - }, - { - languageOptions: { - parserOptions: { - project: ["./tsconfig.json", "./tsconfig.node.json"], - tsconfigRootDir: import.meta.dirname, - }, - globals: { ...globals.node }, + ...exadevConfig(), + { + // scripts/ holds plain-JS operational scripts (the live-runtime check) with no TS project to type them against. + ignores: [ + "dist", + "coverage", + "node_modules", + ".turbo", + ".wrangler", + "scripts", + ], + }, + { + languageOptions: { + parserOptions: { + project: ["./tsconfig.json", "./tsconfig.node.json"], + tsconfigRootDir: import.meta.dirname, }, + globals: { ...globals.node }, }, - { - files: ["**/*.ts", "**/*.tsx", "**/*.mts", "**/*.cts"], - rules: { - "@typescript-eslint/consistent-type-imports": [ - "error", - { fixStyle: "inline-type-imports" }, - ], - }, + }, + { + files: ["**/*.ts", "**/*.tsx", "**/*.mts", "**/*.cts"], + rules: { + "@typescript-eslint/consistent-type-imports": [ + "error", + { fixStyle: "inline-type-imports" }, + ], }, - ), + }, eslintPluginPrettierRecommended, ); diff --git a/ts/packages/cloudflare-hub/package.json b/ts/packages/cloudflare-hub/package.json index 49c0f700..c0fbca1b 100644 --- a/ts/packages/cloudflare-hub/package.json +++ b/ts/packages/cloudflare-hub/package.json @@ -8,7 +8,7 @@ }, "devDependencies": { "@cloudflare/workers-types": "5.20260905.1", - "@exadev/eslint-config": "2.18.0", + "@exadev/eslint-config": "3.0.0", "@types/node": "26.4.1", "eslint": "10.10.0", "eslint-config-prettier": "10.1.8", diff --git a/ts/packages/core/eslint.config.ts b/ts/packages/core/eslint.config.ts index 3f59ec87..595dad44 100644 --- a/ts/packages/core/eslint.config.ts +++ b/ts/packages/core/eslint.config.ts @@ -4,36 +4,34 @@ import eslintPluginPrettierRecommended from "eslint-plugin-prettier/recommended" import globals from "globals"; export default defineConfig( - exadevConfig( - {}, - { - ignores: [ - "dist", - "wasm-dist", - "coverage", - "node_modules", - ".turbo", - "src/generated", - ], - }, - { - languageOptions: { - parserOptions: { - project: ["./tsconfig.json", "./tsconfig.node.json"], - tsconfigRootDir: import.meta.dirname, - }, - globals: { ...globals.node }, + ...exadevConfig(), + { + ignores: [ + "dist", + "wasm-dist", + "coverage", + "node_modules", + ".turbo", + "src/generated", + ], + }, + { + languageOptions: { + parserOptions: { + project: ["./tsconfig.json", "./tsconfig.node.json"], + tsconfigRootDir: import.meta.dirname, }, + globals: { ...globals.node }, }, - { - files: ["**/*.ts", "**/*.tsx", "**/*.mts", "**/*.cts"], - rules: { - "@typescript-eslint/consistent-type-imports": [ - "error", - { fixStyle: "inline-type-imports" }, - ], - }, + }, + { + files: ["**/*.ts", "**/*.tsx", "**/*.mts", "**/*.cts"], + rules: { + "@typescript-eslint/consistent-type-imports": [ + "error", + { fixStyle: "inline-type-imports" }, + ], }, - ), + }, eslintPluginPrettierRecommended, ); diff --git a/ts/packages/core/package.json b/ts/packages/core/package.json index 08740bd8..b693fde7 100644 --- a/ts/packages/core/package.json +++ b/ts/packages/core/package.json @@ -9,7 +9,7 @@ }, "devDependencies": { "@arethetypeswrong/cli": "0.18.5", - "@exadev/eslint-config": "2.18.0", + "@exadev/eslint-config": "3.0.0", "@stryker-mutator/api": "10.0.0", "@stryker-mutator/core": "10.0.0", "@stryker-mutator/typescript-checker": "10.0.0", diff --git a/ts/packages/node/eslint.config.ts b/ts/packages/node/eslint.config.ts index 60e2cdef..9707cbf4 100644 --- a/ts/packages/node/eslint.config.ts +++ b/ts/packages/node/eslint.config.ts @@ -4,37 +4,35 @@ import eslintPluginPrettierRecommended from "eslint-plugin-prettier/recommended" import globals from "globals"; export default defineConfig( - exadevConfig( - {}, - { - // scripts/ holds a plain-JS operational script (the web-console dist copy step) with no TS project to type it against, the same carve-out cloudflare-hub/eslint.config.ts uses for its own scripts/live-check.mjs. - ignores: ["dist", "coverage", "node_modules", ".turbo", "scripts"], - }, - { - languageOptions: { - parserOptions: { - project: ["./tsconfig.json", "./tsconfig.node.json"], - tsconfigRootDir: import.meta.dirname, - }, - globals: { ...globals.node }, + ...exadevConfig(), + { + // scripts/ holds a plain-JS operational script (the web-console dist copy step) with no TS project to type it against, the same carve-out cloudflare-hub/eslint.config.ts uses for its own scripts/live-check.mjs. + ignores: ["dist", "coverage", "node_modules", ".turbo", "scripts"], + }, + { + languageOptions: { + parserOptions: { + project: ["./tsconfig.json", "./tsconfig.node.json"], + tsconfigRootDir: import.meta.dirname, }, + globals: { ...globals.node }, }, - { - files: ["**/*.ts", "**/*.tsx", "**/*.mts", "**/*.cts"], - rules: { - "@typescript-eslint/consistent-type-imports": [ - "error", - { fixStyle: "inline-type-imports" }, - ], - }, + }, + { + files: ["**/*.ts", "**/*.tsx", "**/*.mts", "**/*.cts"], + rules: { + "@typescript-eslint/consistent-type-imports": [ + "error", + { fixStyle: "inline-type-imports" }, + ], }, - { - // The CLI entrypoint's own startup message is genuine console output, not library logging -- no other file in this package (or the workspace) has a precedent for console use, so this narrow, file-scoped override (rather than an inline eslint-disable, which noInlineConfig above blocks anyway) is the clearest way to permit it. - files: ["src/server.ts"], - rules: { - "no-console": "off", - }, + }, + { + // The CLI entrypoint's own startup message is genuine console output, not library logging -- no other file in this package (or the workspace) has a precedent for console use, so this narrow, file-scoped override (rather than an inline eslint-disable, which noInlineConfig above blocks anyway) is the clearest way to permit it. + files: ["src/server.ts"], + rules: { + "no-console": "off", }, - ), + }, eslintPluginPrettierRecommended, ); diff --git a/ts/packages/node/package.json b/ts/packages/node/package.json index 32ca0524..8a3686b9 100644 --- a/ts/packages/node/package.json +++ b/ts/packages/node/package.json @@ -13,7 +13,7 @@ }, "devDependencies": { "@arethetypeswrong/cli": "0.18.5", - "@exadev/eslint-config": "2.18.0", + "@exadev/eslint-config": "3.0.0", "@exadev/wire-mesh-web-console": "2.17.4", "@types/node": "26.4.1", "@types/ws": "8.18.1", diff --git a/ts/packages/web-console/eslint.config.ts b/ts/packages/web-console/eslint.config.ts index 50cca89c..690cfdfa 100644 --- a/ts/packages/web-console/eslint.config.ts +++ b/ts/packages/web-console/eslint.config.ts @@ -4,37 +4,35 @@ import eslintPluginPrettierRecommended from "eslint-plugin-prettier/recommended" import globals from "globals"; export default defineConfig( - exadevConfig( - {}, - { - ignores: ["dist", "coverage", "node_modules", ".turbo"], - }, - { - languageOptions: { - parserOptions: { - project: ["./tsconfig.json", "./tsconfig.node.json"], - tsconfigRootDir: import.meta.dirname, - }, - // Browser globals for the app source, node globals for vite/vitest config and tests -- the tests drive the browser-shaped adapter under Node, which provides the same WebSocket global natively. - globals: { ...globals.browser, ...globals.node }, + ...exadevConfig(), + { + ignores: ["dist", "coverage", "node_modules", ".turbo"], + }, + { + languageOptions: { + parserOptions: { + project: ["./tsconfig.json", "./tsconfig.node.json"], + tsconfigRootDir: import.meta.dirname, }, + // Browser globals for the app source, node globals for vite/vitest config and tests -- the tests drive the browser-shaped adapter under Node, which provides the same WebSocket global natively. + globals: { ...globals.browser, ...globals.node }, }, - { - files: ["**/*.ts", "**/*.tsx", "**/*.mts", "**/*.cts"], - rules: { - "@typescript-eslint/consistent-type-imports": [ - "error", - { fixStyle: "inline-type-imports" }, - ], - }, + }, + { + files: ["**/*.ts", "**/*.tsx", "**/*.mts", "**/*.cts"], + rules: { + "@typescript-eslint/consistent-type-imports": [ + "error", + { fixStyle: "inline-type-imports" }, + ], }, - { - // A frame-log entry has no identity of its own -- two identical frames sent back to back are indistinguishable, so position in this append-only log is the only key available. Scoped to this one file rather than disabled project-wide, since every other list this package renders (the peer directory, room messages) does have a real key (device-id, message-id). - files: ["src/components/ConnectionPanel.tsx"], - rules: { - "react/no-array-index-key": "off", - }, + }, + { + // A frame-log entry has no identity of its own -- two identical frames sent back to back are indistinguishable, so position in this append-only log is the only key available. Scoped to this one file rather than disabled project-wide, since every other list this package renders (the peer directory, room messages) does have a real key (device-id, message-id). + files: ["src/components/ConnectionPanel.tsx"], + rules: { + "react/no-array-index-key": "off", }, - ), + }, eslintPluginPrettierRecommended, ); diff --git a/ts/packages/web-console/package.json b/ts/packages/web-console/package.json index 720b8821..f6d3f81f 100644 --- a/ts/packages/web-console/package.json +++ b/ts/packages/web-console/package.json @@ -12,7 +12,7 @@ "wire-mesh-core": "4.0.3" }, "devDependencies": { - "@exadev/eslint-config": "2.18.0", + "@exadev/eslint-config": "3.0.0", "@playwright/test": "1.63.0", "@testing-library/jest-dom": "7.0.1", "@testing-library/react": "16.3.3", diff --git a/ts/packages/wire-mesh-sfu/eslint.config.ts b/ts/packages/wire-mesh-sfu/eslint.config.ts index dc9fdbaf..ea8b6b22 100644 --- a/ts/packages/wire-mesh-sfu/eslint.config.ts +++ b/ts/packages/wire-mesh-sfu/eslint.config.ts @@ -4,36 +4,34 @@ import eslintPluginPrettierRecommended from "eslint-plugin-prettier/recommended" import globals from "globals"; export default defineConfig( - exadevConfig( - {}, - { - ignores: ["dist", "coverage", "node_modules", ".turbo"], - }, - { - languageOptions: { - parserOptions: { - project: ["./tsconfig.json", "./tsconfig.node.json"], - tsconfigRootDir: import.meta.dirname, - }, - globals: { ...globals.node }, + ...exadevConfig(), + { + ignores: ["dist", "coverage", "node_modules", ".turbo"], + }, + { + languageOptions: { + parserOptions: { + project: ["./tsconfig.json", "./tsconfig.node.json"], + tsconfigRootDir: import.meta.dirname, }, + globals: { ...globals.node }, }, - { - files: ["**/*.ts", "**/*.tsx", "**/*.mts", "**/*.cts"], - rules: { - "@typescript-eslint/consistent-type-imports": [ - "error", - { fixStyle: "inline-type-imports" }, - ], - }, + }, + { + files: ["**/*.ts", "**/*.tsx", "**/*.mts", "**/*.cts"], + rules: { + "@typescript-eslint/consistent-type-imports": [ + "error", + { fixStyle: "inline-type-imports" }, + ], }, - { - // The CLI entrypoint's own startup message is genuine console output, not library logging, matching wire-mesh's own identical narrow, file-scoped override. - files: ["src/server.ts"], - rules: { - "no-console": "off", - }, + }, + { + // The CLI entrypoint's own startup message is genuine console output, not library logging, matching wire-mesh's own identical narrow, file-scoped override. + files: ["src/server.ts"], + rules: { + "no-console": "off", }, - ), + }, eslintPluginPrettierRecommended, ); diff --git a/ts/packages/wire-mesh-sfu/package.json b/ts/packages/wire-mesh-sfu/package.json index 3a31b09e..f7914a59 100644 --- a/ts/packages/wire-mesh-sfu/package.json +++ b/ts/packages/wire-mesh-sfu/package.json @@ -13,7 +13,7 @@ }, "devDependencies": { "@arethetypeswrong/cli": "0.18.5", - "@exadev/eslint-config": "2.18.0", + "@exadev/eslint-config": "3.0.0", "@types/node": "26.4.1", "@types/sdp-transform": "2.4.10", "@types/ws": "8.18.1", diff --git a/ts/pnpm-lock.yaml b/ts/pnpm-lock.yaml index 6244919e..67dfb05a 100644 --- a/ts/pnpm-lock.yaml +++ b/ts/pnpm-lock.yaml @@ -170,8 +170,8 @@ importers: .: devDependencies: '@exadev/eslint-config': - specifier: 2.18.0 - version: 2.18.0(@eslint/json@2.1.0)(eslint-plugin-jsx-a11y@6.10.2(eslint@10.10.0(jiti@2.7.0)(supports-color@11.0.0)))(eslint-plugin-react-hooks@7.1.1(eslint@10.10.0(jiti@2.7.0)(supports-color@11.0.0))(supports-color@11.0.0))(eslint-plugin-react@7.37.5(eslint@10.10.0(jiti@2.7.0)(supports-color@11.0.0)))(eslint@10.10.0(jiti@2.7.0)(supports-color@11.0.0))(supports-color@11.0.0)(typescript-eslint@8.69.0(eslint@10.10.0(jiti@2.7.0)(supports-color@11.0.0))(supports-color@11.0.0)(typescript@6.0.3))(typescript@6.0.3) + specifier: 3.0.0 + version: 3.0.0(@eslint/json@2.1.0)(eslint-plugin-jsx-a11y@6.10.2(eslint@10.10.0(jiti@2.7.0)(supports-color@11.0.0)))(eslint-plugin-react-hooks@7.1.1(eslint@10.10.0(jiti@2.7.0)(supports-color@11.0.0))(supports-color@11.0.0))(eslint-plugin-react@7.37.5(eslint@10.10.0(jiti@2.7.0)(supports-color@11.0.0)))(eslint@10.10.0(jiti@2.7.0)(supports-color@11.0.0))(supports-color@11.0.0)(typescript-eslint@8.69.0(eslint@10.10.0(jiti@2.7.0)(supports-color@11.0.0))(supports-color@11.0.0)(typescript@6.0.3))(typescript@6.0.3) '@exadev/semantic-release-workspace': specifier: 3.0.2 version: 3.0.2(@semantic-release/changelog@7.0.0(semantic-release@25.0.9(supports-color@11.0.0)(typescript@6.0.3)))(@semantic-release/commit-analyzer@13.0.1(semantic-release@25.0.9(supports-color@11.0.0)(typescript@6.0.3))(supports-color@11.0.0))(@semantic-release/git@11.0.1(semantic-release@25.0.9(supports-color@11.0.0)(typescript@6.0.3))(supports-color@11.0.0))(@semantic-release/github@12.0.9(semantic-release@25.0.9(supports-color@11.0.0)(typescript@6.0.3))(supports-color@11.0.0))(@semantic-release/npm@13.1.5(semantic-release@25.0.9(supports-color@11.0.0)(typescript@6.0.3)))(@semantic-release/release-notes-generator@14.1.1(semantic-release@25.0.9(supports-color@11.0.0)(typescript@6.0.3))(supports-color@11.0.0))(semantic-release@25.0.9(supports-color@11.0.0)(typescript@6.0.3))(supports-color@11.0.0)(typescript@6.0.3) @@ -243,8 +243,8 @@ importers: specifier: 5.20260905.1 version: 5.20260905.1 '@exadev/eslint-config': - specifier: 2.18.0 - version: 2.18.0(@eslint/json@2.1.0)(eslint-plugin-jsx-a11y@6.10.2(eslint@10.10.0(jiti@2.7.0)(supports-color@10.2.2)))(eslint-plugin-react-hooks@7.1.1(eslint@10.10.0(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2))(eslint-plugin-react@7.37.5(eslint@10.10.0(jiti@2.7.0)(supports-color@10.2.2)))(eslint@10.10.0(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript-eslint@8.69.0(eslint@10.10.0(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3))(typescript@6.0.3) + specifier: 3.0.0 + version: 3.0.0(@eslint/json@2.1.0)(eslint-plugin-jsx-a11y@6.10.2(eslint@10.10.0(jiti@2.7.0)(supports-color@10.2.2)))(eslint-plugin-react-hooks@7.1.1(eslint@10.10.0(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2))(eslint-plugin-react@7.37.5(eslint@10.10.0(jiti@2.7.0)(supports-color@10.2.2)))(eslint@10.10.0(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript-eslint@8.69.0(eslint@10.10.0(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3))(typescript@6.0.3) '@types/node': specifier: 26.4.1 version: 26.4.1 @@ -298,8 +298,8 @@ importers: specifier: 0.18.5 version: 0.18.5 '@exadev/eslint-config': - specifier: 2.18.0 - version: 2.18.0(@eslint/json@2.1.0)(eslint-plugin-jsx-a11y@6.10.2(eslint@10.10.0(jiti@2.7.0)(supports-color@10.2.2)))(eslint-plugin-react-hooks@7.1.1(eslint@10.10.0(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2))(eslint-plugin-react@7.37.5(eslint@10.10.0(jiti@2.7.0)(supports-color@10.2.2)))(eslint@10.10.0(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript-eslint@8.69.0(eslint@10.10.0(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3))(typescript@6.0.3) + specifier: 3.0.0 + version: 3.0.0(@eslint/json@2.1.0)(eslint-plugin-jsx-a11y@6.10.2(eslint@10.10.0(jiti@2.7.0)(supports-color@10.2.2)))(eslint-plugin-react-hooks@7.1.1(eslint@10.10.0(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2))(eslint-plugin-react@7.37.5(eslint@10.10.0(jiti@2.7.0)(supports-color@10.2.2)))(eslint@10.10.0(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript-eslint@8.69.0(eslint@10.10.0(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3))(typescript@6.0.3) '@stryker-mutator/api': specifier: 10.0.0 version: 10.0.0 @@ -365,8 +365,8 @@ importers: specifier: 0.18.5 version: 0.18.5 '@exadev/eslint-config': - specifier: 2.18.0 - version: 2.18.0(@eslint/json@2.1.0)(eslint-plugin-jsx-a11y@6.10.2(eslint@10.10.0(jiti@2.7.0)(supports-color@10.2.2)))(eslint-plugin-react-hooks@7.1.1(eslint@10.10.0(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2))(eslint-plugin-react@7.37.5(eslint@10.10.0(jiti@2.7.0)(supports-color@10.2.2)))(eslint@10.10.0(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript-eslint@8.69.0(eslint@10.10.0(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3))(typescript@6.0.3) + specifier: 3.0.0 + version: 3.0.0(@eslint/json@2.1.0)(eslint-plugin-jsx-a11y@6.10.2(eslint@10.10.0(jiti@2.7.0)(supports-color@10.2.2)))(eslint-plugin-react-hooks@7.1.1(eslint@10.10.0(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2))(eslint-plugin-react@7.37.5(eslint@10.10.0(jiti@2.7.0)(supports-color@10.2.2)))(eslint@10.10.0(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript-eslint@8.69.0(eslint@10.10.0(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3))(typescript@6.0.3) '@exadev/wire-mesh-web-console': specifier: 2.17.4 version: link:../web-console @@ -442,8 +442,8 @@ importers: version: link:../core devDependencies: '@exadev/eslint-config': - specifier: 2.18.0 - version: 2.18.0(@eslint/json@2.1.0)(eslint-plugin-jsx-a11y@6.10.2(eslint@10.10.0(jiti@2.7.0)(supports-color@10.2.2)))(eslint-plugin-react-hooks@7.1.1(eslint@10.10.0(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2))(eslint-plugin-react@7.37.5(eslint@10.10.0(jiti@2.7.0)(supports-color@10.2.2)))(eslint@10.10.0(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript-eslint@8.69.0(eslint@10.10.0(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3))(typescript@6.0.3) + specifier: 3.0.0 + version: 3.0.0(@eslint/json@2.1.0)(eslint-plugin-jsx-a11y@6.10.2(eslint@10.10.0(jiti@2.7.0)(supports-color@10.2.2)))(eslint-plugin-react-hooks@7.1.1(eslint@10.10.0(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2))(eslint-plugin-react@7.37.5(eslint@10.10.0(jiti@2.7.0)(supports-color@10.2.2)))(eslint@10.10.0(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript-eslint@8.69.0(eslint@10.10.0(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3))(typescript@6.0.3) '@playwright/test': specifier: 1.63.0 version: 1.63.0 @@ -560,8 +560,8 @@ importers: specifier: 0.18.5 version: 0.18.5 '@exadev/eslint-config': - specifier: 2.18.0 - version: 2.18.0(@eslint/json@2.1.0)(eslint-plugin-jsx-a11y@6.10.2(eslint@10.10.0(jiti@2.7.0)(supports-color@11.0.0)))(eslint-plugin-react-hooks@7.1.1(eslint@10.10.0(jiti@2.7.0)(supports-color@11.0.0))(supports-color@11.0.0))(eslint-plugin-react@7.37.5(eslint@10.10.0(jiti@2.7.0)(supports-color@11.0.0)))(eslint@10.10.0(jiti@2.7.0)(supports-color@11.0.0))(supports-color@11.0.0)(typescript-eslint@8.69.0(eslint@10.10.0(jiti@2.7.0)(supports-color@11.0.0))(supports-color@11.0.0)(typescript@6.0.3))(typescript@6.0.3) + specifier: 3.0.0 + version: 3.0.0(@eslint/json@2.1.0)(eslint-plugin-jsx-a11y@6.10.2(eslint@10.10.0(jiti@2.7.0)(supports-color@11.0.0)))(eslint-plugin-react-hooks@7.1.1(eslint@10.10.0(jiti@2.7.0)(supports-color@11.0.0))(supports-color@11.0.0))(eslint-plugin-react@7.37.5(eslint@10.10.0(jiti@2.7.0)(supports-color@11.0.0)))(eslint@10.10.0(jiti@2.7.0)(supports-color@11.0.0))(supports-color@11.0.0)(typescript-eslint@8.69.0(eslint@10.10.0(jiti@2.7.0)(supports-color@11.0.0))(supports-color@11.0.0)(typescript@6.0.3))(typescript@6.0.3) '@types/node': specifier: 26.4.1 version: 26.4.1 @@ -1814,26 +1814,38 @@ packages: resolution: {integrity: sha512-IkO+/KEUvwbVpiURZg+P7zF74z5Jxe0UgJxVni+RtoHQ6IZieXaO02kmadomap/q+l6bc/jdPGGqTjhuZnuz1Q==} engines: {node: ^20.19.0 || ^22.13.0 || >=24} - '@exadev/eslint-config@2.18.0': - resolution: {integrity: sha512-wPRWdM78lRqHRc0Vjc2MTwwg8qU3mK7Gur3mq2XEqyVf/UoEWQPIOAQvAAPKHxuCdFlHIaYtDdi4LR9Smwk2hA==} - engines: {node: '>=20'} + '@exadev/eslint-config@3.0.0': + resolution: {integrity: sha512-nvyOjjM1sGKjQo6buZt69vJ5ND0CuE/lxoDvR2oRgv74qj4gJBZr10d6Y/QeTd6LQIPDkPPnH78YYfX/HSzNJA==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} peerDependencies: + '@eslint/json': ^2.1.0 + '@eslint/markdown': ^8.0.3 '@next/eslint-plugin-next': ^16.3.2 + '@vitest/eslint-plugin': ^1.6.27 eslint: '>=10.0.0' eslint-plugin-jsx-a11y: ^6.10.2 eslint-plugin-react: ^7.37.5 eslint-plugin-react-hooks: ^7.1.1 + eslint-plugin-turbo: ^2.3.1 typescript: '>=4.8.4' typescript-eslint: '>=8.0.0' peerDependenciesMeta: + '@eslint/json': + optional: true + '@eslint/markdown': + optional: true '@next/eslint-plugin-next': optional: true + '@vitest/eslint-plugin': + optional: true eslint-plugin-jsx-a11y: optional: true eslint-plugin-react: optional: true eslint-plugin-react-hooks: optional: true + eslint-plugin-turbo: + optional: true '@exadev/release-gate@1.0.1': resolution: {integrity: sha512-VKU6qZlvl6C+xOLC12mYvv1ygcLm4Gt28rcd2K20fMJB4jz/2IeK5Nn+Ivk4a4xv7TmzvoJCmHUfo7nmwnrVYw==} @@ -2264,6 +2276,9 @@ packages: '@microsoft/tsdoc@0.17.0': resolution: {integrity: sha512-p68VexhnH7ojf3U27RdryUDqnKJgKaWUbCL96vJR7N0mqDuhKG5OIECk3sCPPdynxjk+yoVhRkScqtv5KQjzsw==} + '@microsoft/tsdoc@0.17.1': + resolution: {integrity: sha512-VHyYyyoH63jFoO3shpw9Zfijk5v/9p/VrClW35z0/q6ooK7sSmxpzOkS4iZPxs3vO4XtBW4H38HRAKkAMIK7DA==} + '@napi-rs/lzma-linux-x64-gnu@1.5.1': resolution: {integrity: sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==} engines: {node: ^22.20 || ^24.12 || >=25} @@ -2801,6 +2816,18 @@ packages: '@stryker-mutator/util@10.0.0': resolution: {integrity: sha512-LzOpHiJaCp2ABQgnPMlrQQcsK43bd5Vo/2FGL78aN62yDoeRQ+4j3tzeuXxK5OAHdC3fUz6TDoy4IsoAuLAd3w==} + '@stylistic/eslint-plugin@6.0.0-beta.6': + resolution: {integrity: sha512-DqDQZSrV/0AZ/4qLxaQUtNxafk3qWfeG5xOHx64ELjHWSHt2GtELZR6ClrNVvQwo/d13Atf8Hrh/s/ZnuJk57w==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + peerDependencies: + eslint: ^9.0.0 || ^10.0.0 + oxlint: ^1.55.0 + peerDependenciesMeta: + eslint: + optional: true + oxlint: + optional: true + '@testing-library/dom@10.4.2': resolution: {integrity: sha512-yzr2S9HyAIdhz2/6qHgbs665Q7PKVcDF05vsOlHPxG1mo36gKVesdYVeDLnXgfjJ03CrKRk08knc6+E/9m8v2Q==} engines: {node: '>=18'} @@ -2947,14 +2974,14 @@ packages: peerDependencies: typescript: '>=4.8.4 <6.0.0' - '@typescript-eslint/project-service@8.67.0': - resolution: {integrity: sha512-cvE8c7ulYeXN9fYuszhCeCsbzyVEXuhrRCybnBre7TUmqb5nRmBfQAwCj0O3WJFDeyAZt4VYv51vMCC9LHSdYw==} + '@typescript-eslint/project-service@8.69.0': + resolution: {integrity: sha512-yi4obFrHMmnsesWehHbkg9zMA7Jt8cXT+mKM08G999pH1yT6nqgsHx7MYm0uY1wAj8CqiBXYRJ7WAT0QdQHQXg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/project-service@8.69.0': - resolution: {integrity: sha512-yi4obFrHMmnsesWehHbkg9zMA7Jt8cXT+mKM08G999pH1yT6nqgsHx7MYm0uY1wAj8CqiBXYRJ7WAT0QdQHQXg==} + '@typescript-eslint/project-service@8.71.0': + resolution: {integrity: sha512-aABjw5rjBacYONVPaPiWOCjJu0vEF4a25iQuodlmQYL1trtLZ0X/y+2Vzl3BKI1odM4LnwLE1oUDXYp1wzx1TQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: typescript: '>=4.8.4 <6.1.0' @@ -2963,28 +2990,28 @@ packages: resolution: {integrity: sha512-YAi4VDKcIZp0O4tz/haYKhmIDZFEUPOreKbfdAN3SzUDMcPhJ8QI99xQXqX+HoUVq8cs85eRKnD+rne2UAnj2w==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@typescript-eslint/scope-manager@8.67.0': - resolution: {integrity: sha512-EgvsleTwS4E+WzzSvem8fAUubLwatMNF1B5hHSLQxcvs7q2dtRhGyujHwLJSYlG41niJ7GP24Aha2+0mb1b2kg==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@typescript-eslint/scope-manager@8.69.0': resolution: {integrity: sha512-ewfspqWvSxKSOaplqAUNbaSFO0eB6w1EtQ+esfYFRm3614Ty4uNtExkcbgd6nWsXphbqKyf9ZYdbZdv2xEoWEQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@typescript-eslint/scope-manager@8.71.0': + resolution: {integrity: sha512-gWF0BhUcnjZxSpLE8ngS/59n2SB0J3YqRxvX1+2aoRJk9hNtHSLOV+TcarFiOr5ipXm3yc1QrI4c9YZc8zyCxw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@typescript-eslint/tsconfig-utils@8.56.1': resolution: {integrity: sha512-qOtCYzKEeyr3aR9f28mPJqBty7+DBqsdd63eO0yyDwc6vgThj2UjWfJIcsFeSucYydqcuudMOprZ+x1SpF3ZuQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: typescript: '>=4.8.4 <6.0.0' - '@typescript-eslint/tsconfig-utils@8.67.0': - resolution: {integrity: sha512-vV+LUSv5njUWsknE71fqKTlXUva+R76SaeORd6Zojcunk/6DvKFXONU3BrAs2H49mbygUXt6gbYunzwqNwlhdg==} + '@typescript-eslint/tsconfig-utils@8.69.0': + resolution: {integrity: sha512-xNqK7YTDZsLniQMV/4rpFR8Z5JlqeRvVjuG1YgF/mdPVH84HSD19L8CczMA0qg2RfwEV231GHH3VnToJDo4MfQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/tsconfig-utils@8.69.0': - resolution: {integrity: sha512-xNqK7YTDZsLniQMV/4rpFR8Z5JlqeRvVjuG1YgF/mdPVH84HSD19L8CczMA0qg2RfwEV231GHH3VnToJDo4MfQ==} + '@typescript-eslint/tsconfig-utils@8.71.0': + resolution: {integrity: sha512-Z1UlWHADEK2Mlb9NpWfDeSjqoZ5EyrOv4R3eQpbkzqn/EwaIdOpXXupEA1+0ZIOSJSZZDBHG0BrQyN8zUG6Pwg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: typescript: '>=4.8.4 <6.1.0' @@ -3000,28 +3027,28 @@ packages: resolution: {integrity: sha512-dbMkdIUkIkchgGDIv7KLUpa0Mda4IYjo4IAMJUZ+3xNoUXxMsk9YtKpTHSChRS85o+H9ftm51gsK1dZReY9CVw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@typescript-eslint/types@8.67.0': - resolution: {integrity: sha512-sBtgslww8nsMYUjhdPBiSyUqSzT8uR6g93A2QXnQC8+cGdjz0CyaOdqHDRJb1AtORbZCNUJBBeFA/tNR2uQmww==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@typescript-eslint/types@8.69.0': resolution: {integrity: sha512-K3VrubUPhlo9VDBS6QdI8YB5j7ClpqLRdefcz6PFrhnwicehBweqQ9Evhl4l+FYz0HdDmMqIiSX0aldGRYtDCA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@typescript-eslint/types@8.71.0': + resolution: {integrity: sha512-cJ4OoxPGWvFnBTnSZyaU+qJzGTqPTGJY+gDchj6cRyLRdmIdt4rcsE4twj+zPfrNiWuVi38wijHzShL++Z9atQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@typescript-eslint/typescript-estree@8.56.1': resolution: {integrity: sha512-qzUL1qgalIvKWAf9C1HpvBjif+Vm6rcT5wZd4VoMb9+Km3iS3Cv9DY6dMRMDtPnwRAFyAi7YXJpTIEXLvdfPxg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: typescript: '>=4.8.4 <6.0.0' - '@typescript-eslint/typescript-estree@8.67.0': - resolution: {integrity: sha512-EKQBCE9yNlRJYm7jdTW5AhDacDUmSwQb0FAJAmK2EKYrNXIsa2vxcSZx6PvJ/dEdI6lS+Y9W+EXckLj0iPFGcw==} + '@typescript-eslint/typescript-estree@8.69.0': + resolution: {integrity: sha512-AdFkgqck3Vudb/kWnxlyafU/4aBhHrbQ9locP2N4psXTy5mOBg0SHJumnLvx7r6g1gV4DKvUFwV2nJZBoqOD8w==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/typescript-estree@8.69.0': - resolution: {integrity: sha512-AdFkgqck3Vudb/kWnxlyafU/4aBhHrbQ9locP2N4psXTy5mOBg0SHJumnLvx7r6g1gV4DKvUFwV2nJZBoqOD8w==} + '@typescript-eslint/typescript-estree@8.71.0': + resolution: {integrity: sha512-PEEF4G5sLLWAS5BpPrUvms4ySZkiBQQZM4z+3ReI46axK5Vqr/vXBQatJQIZZOYdGyPUAKTtsrWzpqKuU+3DEw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: typescript: '>=4.8.4 <6.1.0' @@ -3033,15 +3060,15 @@ packages: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: '>=4.8.4 <6.0.0' - '@typescript-eslint/utils@8.67.0': - resolution: {integrity: sha512-U9D1FdwEWBwok3hxxSdhclMb0twvt9QnjIQ0VfQ1AiX2epnpSgv2ubVDsayOFyY8K6FX+AQ7E0FKWVG3iKsj1A==} + '@typescript-eslint/utils@8.69.0': + resolution: {integrity: sha512-tUbx60BBqQa31kXF5MCsOOLL5E/WzUuxIn7YpAvq+eaUlqvk8/NXnXMBNAdLCr0icjkzem7iUA5QqWHe/hJ1aw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/utils@8.69.0': - resolution: {integrity: sha512-tUbx60BBqQa31kXF5MCsOOLL5E/WzUuxIn7YpAvq+eaUlqvk8/NXnXMBNAdLCr0icjkzem7iUA5QqWHe/hJ1aw==} + '@typescript-eslint/utils@8.71.0': + resolution: {integrity: sha512-pKR/tEMVrXZG23UFKUn5BQf3zfmfk7KQceI2cGzywZ5nxM5Eu3hEJU1utjWzydtzBbcJAQhHN8iPCxobHpPcZQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 @@ -3051,14 +3078,14 @@ packages: resolution: {integrity: sha512-KiROIzYdEV85YygXw6BI/Dx4fnBlFQu6Mq4QE4MOH9fFnhohw6wX/OAvDY2/C+ut0I3RSPKenvZJIVYqJNkhEw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@typescript-eslint/visitor-keys@8.67.0': - resolution: {integrity: sha512-fkv8dHRDqfGtTHuJeebdrQ7cX6Ad4WAS00rgHh9UGvMycF1mjBfsxry1XsLIFhWZ6Judlh6UdzK+TYlbpCXgnA==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@typescript-eslint/visitor-keys@8.69.0': resolution: {integrity: sha512-+rmdgPA+EXkNgKYvHvFfhrs35utXbwaC5PGpDquSXcoXQDKUA5UjV0LmTucG/4JXkM31BTu4TilHtrN8IVBe8w==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@typescript-eslint/visitor-keys@8.71.0': + resolution: {integrity: sha512-8eQ9R218XORK+KLosnf4bu/QsUXvUyVwTbArg7/0NMB1Pu87OJKvj4nhFblkYE8gQV73mW1dx1ptlPCkwRGa7A==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@vanilla-extract/babel-plugin-debug-ids@1.2.2': resolution: {integrity: sha512-MeDWGICAF9zA/OZLOKwhoRlsUW+fiMwnfuOAqFVohL31Agj7Q/RBWAYweqjHLgFBCsdnr6XIfwjJnmb2znEWxw==} @@ -3979,8 +4006,8 @@ packages: peerDependencies: eslint: ^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0 - eslint-plugin-json-canonical@2.1.0: - resolution: {integrity: sha512-sjT8FdqSvpEiB0yzCOGBozbEJ64SLxDp8CYlV9wHEgL0HFLj3kJdfWl5UuzMe72OoOXbwzo418VeUlxX5iNfgg==} + eslint-plugin-json-canonical@2.1.3: + resolution: {integrity: sha512-qAcRVGkXGZvgduOIEBxHLOZhqUf0NdVY+bye/KXbd0oPH5VvoBBBETFEaJ1qa6efLSCH17C4sss7uvr80TfNTQ==} engines: {node: '>=20'} peerDependencies: '@eslint/json': ^2.1.0 @@ -8029,42 +8056,54 @@ snapshots: '@eslint/core': 1.2.1 levn: 0.4.1 - '@exadev/eslint-config@2.18.0(@eslint/json@2.1.0)(eslint-plugin-jsx-a11y@6.10.2(eslint@10.10.0(jiti@2.7.0)(supports-color@10.2.2)))(eslint-plugin-react-hooks@7.1.1(eslint@10.10.0(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2))(eslint-plugin-react@7.37.5(eslint@10.10.0(jiti@2.7.0)(supports-color@10.2.2)))(eslint@10.10.0(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript-eslint@8.69.0(eslint@10.10.0(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3))(typescript@6.0.3)': + '@exadev/eslint-config@3.0.0(@eslint/json@2.1.0)(eslint-plugin-jsx-a11y@6.10.2(eslint@10.10.0(jiti@2.7.0)(supports-color@10.2.2)))(eslint-plugin-react-hooks@7.1.1(eslint@10.10.0(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2))(eslint-plugin-react@7.37.5(eslint@10.10.0(jiti@2.7.0)(supports-color@10.2.2)))(eslint@10.10.0(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript-eslint@8.69.0(eslint@10.10.0(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3))(typescript@6.0.3)': dependencies: '@eslint/js': 10.0.1(eslint@10.10.0(jiti@2.7.0)(supports-color@10.2.2)) - '@typescript-eslint/utils': 8.67.0(eslint@10.10.0(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3) + '@microsoft/tsdoc': 0.17.1 + '@stylistic/eslint-plugin': 6.0.0-beta.6(eslint@10.10.0(jiti@2.7.0)(supports-color@10.2.2)) + '@typescript-eslint/typescript-estree': 8.71.0(supports-color@10.2.2)(typescript@6.0.3) + '@typescript-eslint/utils': 8.71.0(eslint@10.10.0(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3) eslint: 10.10.0(jiti@2.7.0)(supports-color@10.2.2) eslint-plugin-jsdoc: 64.3.6(eslint@10.10.0(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3) - eslint-plugin-json-canonical: 2.1.0(@eslint/json@2.1.0)(eslint@10.10.0(jiti@2.7.0)(supports-color@10.2.2)) + eslint-plugin-json-canonical: 2.1.3(@eslint/json@2.1.0)(eslint@10.10.0(jiti@2.7.0)(supports-color@10.2.2)) eslint-plugin-tsdoc: 0.5.3(eslint@10.10.0(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3) + minimatch: 10.2.6 ts-api-utils: 2.5.0(typescript@6.0.3) typescript: 6.0.3 typescript-eslint: 8.69.0(eslint@10.10.0(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3) + yaml: 2.9.1 optionalDependencies: + '@eslint/json': 2.1.0 eslint-plugin-jsx-a11y: 6.10.2(eslint@10.10.0(jiti@2.7.0)(supports-color@10.2.2)) eslint-plugin-react: 7.37.5(eslint@10.10.0(jiti@2.7.0)(supports-color@10.2.2)) eslint-plugin-react-hooks: 7.1.1(eslint@10.10.0(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2) transitivePeerDependencies: - - '@eslint/json' + - oxlint - supports-color - '@exadev/eslint-config@2.18.0(@eslint/json@2.1.0)(eslint-plugin-jsx-a11y@6.10.2(eslint@10.10.0(jiti@2.7.0)(supports-color@11.0.0)))(eslint-plugin-react-hooks@7.1.1(eslint@10.10.0(jiti@2.7.0)(supports-color@11.0.0))(supports-color@11.0.0))(eslint-plugin-react@7.37.5(eslint@10.10.0(jiti@2.7.0)(supports-color@11.0.0)))(eslint@10.10.0(jiti@2.7.0)(supports-color@11.0.0))(supports-color@11.0.0)(typescript-eslint@8.69.0(eslint@10.10.0(jiti@2.7.0)(supports-color@11.0.0))(supports-color@11.0.0)(typescript@6.0.3))(typescript@6.0.3)': + '@exadev/eslint-config@3.0.0(@eslint/json@2.1.0)(eslint-plugin-jsx-a11y@6.10.2(eslint@10.10.0(jiti@2.7.0)(supports-color@11.0.0)))(eslint-plugin-react-hooks@7.1.1(eslint@10.10.0(jiti@2.7.0)(supports-color@11.0.0))(supports-color@11.0.0))(eslint-plugin-react@7.37.5(eslint@10.10.0(jiti@2.7.0)(supports-color@11.0.0)))(eslint@10.10.0(jiti@2.7.0)(supports-color@11.0.0))(supports-color@11.0.0)(typescript-eslint@8.69.0(eslint@10.10.0(jiti@2.7.0)(supports-color@11.0.0))(supports-color@11.0.0)(typescript@6.0.3))(typescript@6.0.3)': dependencies: '@eslint/js': 10.0.1(eslint@10.10.0(jiti@2.7.0)(supports-color@11.0.0)) - '@typescript-eslint/utils': 8.67.0(eslint@10.10.0(jiti@2.7.0)(supports-color@11.0.0))(supports-color@11.0.0)(typescript@6.0.3) + '@microsoft/tsdoc': 0.17.1 + '@stylistic/eslint-plugin': 6.0.0-beta.6(eslint@10.10.0(jiti@2.7.0)(supports-color@11.0.0)) + '@typescript-eslint/typescript-estree': 8.71.0(supports-color@11.0.0)(typescript@6.0.3) + '@typescript-eslint/utils': 8.71.0(eslint@10.10.0(jiti@2.7.0)(supports-color@11.0.0))(supports-color@11.0.0)(typescript@6.0.3) eslint: 10.10.0(jiti@2.7.0)(supports-color@11.0.0) eslint-plugin-jsdoc: 64.3.6(eslint@10.10.0(jiti@2.7.0)(supports-color@11.0.0))(supports-color@11.0.0)(typescript@6.0.3) - eslint-plugin-json-canonical: 2.1.0(@eslint/json@2.1.0)(eslint@10.10.0(jiti@2.7.0)(supports-color@11.0.0)) + eslint-plugin-json-canonical: 2.1.3(@eslint/json@2.1.0)(eslint@10.10.0(jiti@2.7.0)(supports-color@11.0.0)) eslint-plugin-tsdoc: 0.5.3(eslint@10.10.0(jiti@2.7.0)(supports-color@11.0.0))(supports-color@11.0.0)(typescript@6.0.3) + minimatch: 10.2.6 ts-api-utils: 2.5.0(typescript@6.0.3) typescript: 6.0.3 typescript-eslint: 8.69.0(eslint@10.10.0(jiti@2.7.0)(supports-color@11.0.0))(supports-color@11.0.0)(typescript@6.0.3) + yaml: 2.9.1 optionalDependencies: + '@eslint/json': 2.1.0 eslint-plugin-jsx-a11y: 6.10.2(eslint@10.10.0(jiti@2.7.0)(supports-color@11.0.0)) eslint-plugin-react: 7.37.5(eslint@10.10.0(jiti@2.7.0)(supports-color@11.0.0)) eslint-plugin-react-hooks: 7.1.1(eslint@10.10.0(jiti@2.7.0)(supports-color@11.0.0))(supports-color@11.0.0) transitivePeerDependencies: - - '@eslint/json' + - oxlint - supports-color '@exadev/release-gate@1.0.1(supports-color@11.0.0)(typescript@6.0.3)': @@ -8456,6 +8495,8 @@ snapshots: '@microsoft/tsdoc@0.17.0': {} + '@microsoft/tsdoc@0.17.1': {} + '@napi-rs/lzma-linux-x64-gnu@1.5.1': optional: true @@ -9017,6 +9058,26 @@ snapshots: '@stryker-mutator/util@10.0.0': {} + '@stylistic/eslint-plugin@6.0.0-beta.6(eslint@10.10.0(jiti@2.7.0)(supports-color@10.2.2))': + dependencies: + '@eslint-community/eslint-utils': 4.10.1(eslint@10.10.0(jiti@2.7.0)(supports-color@10.2.2)) + '@typescript-eslint/types': 8.69.0 + '@typescript-eslint/visitor-keys': 8.69.0 + espree: 11.2.0 + picomatch: 4.0.7 + optionalDependencies: + eslint: 10.10.0(jiti@2.7.0)(supports-color@10.2.2) + + '@stylistic/eslint-plugin@6.0.0-beta.6(eslint@10.10.0(jiti@2.7.0)(supports-color@11.0.0))': + dependencies: + '@eslint-community/eslint-utils': 4.10.1(eslint@10.10.0(jiti@2.7.0)(supports-color@11.0.0)) + '@typescript-eslint/types': 8.69.0 + '@typescript-eslint/visitor-keys': 8.69.0 + espree: 11.2.0 + picomatch: 4.0.7 + optionalDependencies: + eslint: 10.10.0(jiti@2.7.0)(supports-color@11.0.0) + '@testing-library/dom@10.4.2': dependencies: '@babel/code-frame': 7.29.7 @@ -9204,7 +9265,7 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/project-service@8.67.0(supports-color@10.2.2)(typescript@6.0.3)': + '@typescript-eslint/project-service@8.69.0(supports-color@10.2.2)(typescript@6.0.3)': dependencies: '@typescript-eslint/tsconfig-utils': 8.69.0(typescript@6.0.3) '@typescript-eslint/types': 8.69.0 @@ -9213,7 +9274,7 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/project-service@8.67.0(supports-color@11.0.0)(typescript@6.0.3)': + '@typescript-eslint/project-service@8.69.0(supports-color@11.0.0)(typescript@6.0.3)': dependencies: '@typescript-eslint/tsconfig-utils': 8.69.0(typescript@6.0.3) '@typescript-eslint/types': 8.69.0 @@ -9222,19 +9283,19 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/project-service@8.69.0(supports-color@10.2.2)(typescript@6.0.3)': + '@typescript-eslint/project-service@8.71.0(supports-color@10.2.2)(typescript@6.0.3)': dependencies: - '@typescript-eslint/tsconfig-utils': 8.69.0(typescript@6.0.3) - '@typescript-eslint/types': 8.69.0 + '@typescript-eslint/tsconfig-utils': 8.71.0(typescript@6.0.3) + '@typescript-eslint/types': 8.71.0 debug: 4.4.3(supports-color@10.2.2) typescript: 6.0.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/project-service@8.69.0(supports-color@11.0.0)(typescript@6.0.3)': + '@typescript-eslint/project-service@8.71.0(supports-color@11.0.0)(typescript@6.0.3)': dependencies: - '@typescript-eslint/tsconfig-utils': 8.69.0(typescript@6.0.3) - '@typescript-eslint/types': 8.69.0 + '@typescript-eslint/tsconfig-utils': 8.71.0(typescript@6.0.3) + '@typescript-eslint/types': 8.71.0 debug: 4.4.3(supports-color@11.0.0) typescript: 6.0.3 transitivePeerDependencies: @@ -9245,25 +9306,25 @@ snapshots: '@typescript-eslint/types': 8.56.1 '@typescript-eslint/visitor-keys': 8.56.1 - '@typescript-eslint/scope-manager@8.67.0': - dependencies: - '@typescript-eslint/types': 8.67.0 - '@typescript-eslint/visitor-keys': 8.67.0 - '@typescript-eslint/scope-manager@8.69.0': dependencies: '@typescript-eslint/types': 8.69.0 '@typescript-eslint/visitor-keys': 8.69.0 + '@typescript-eslint/scope-manager@8.71.0': + dependencies: + '@typescript-eslint/types': 8.71.0 + '@typescript-eslint/visitor-keys': 8.71.0 + '@typescript-eslint/tsconfig-utils@8.56.1(typescript@6.0.3)': dependencies: typescript: 6.0.3 - '@typescript-eslint/tsconfig-utils@8.67.0(typescript@6.0.3)': + '@typescript-eslint/tsconfig-utils@8.69.0(typescript@6.0.3)': dependencies: typescript: 6.0.3 - '@typescript-eslint/tsconfig-utils@8.69.0(typescript@6.0.3)': + '@typescript-eslint/tsconfig-utils@8.71.0(typescript@6.0.3)': dependencies: typescript: 6.0.3 @@ -9293,10 +9354,10 @@ snapshots: '@typescript-eslint/types@8.56.1': {} - '@typescript-eslint/types@8.67.0': {} - '@typescript-eslint/types@8.69.0': {} + '@typescript-eslint/types@8.71.0': {} + '@typescript-eslint/typescript-estree@8.56.1(supports-color@10.2.2)(typescript@6.0.3)': dependencies: '@typescript-eslint/project-service': 8.56.1(supports-color@10.2.2)(typescript@6.0.3) @@ -9327,12 +9388,12 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/typescript-estree@8.67.0(supports-color@10.2.2)(typescript@6.0.3)': + '@typescript-eslint/typescript-estree@8.69.0(supports-color@10.2.2)(typescript@6.0.3)': dependencies: - '@typescript-eslint/project-service': 8.67.0(supports-color@10.2.2)(typescript@6.0.3) - '@typescript-eslint/tsconfig-utils': 8.67.0(typescript@6.0.3) - '@typescript-eslint/types': 8.67.0 - '@typescript-eslint/visitor-keys': 8.67.0 + '@typescript-eslint/project-service': 8.69.0(supports-color@10.2.2)(typescript@6.0.3) + '@typescript-eslint/tsconfig-utils': 8.69.0(typescript@6.0.3) + '@typescript-eslint/types': 8.69.0 + '@typescript-eslint/visitor-keys': 8.69.0 debug: 4.4.3(supports-color@10.2.2) minimatch: 10.2.6 semver: 7.8.5 @@ -9342,12 +9403,12 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/typescript-estree@8.67.0(supports-color@11.0.0)(typescript@6.0.3)': + '@typescript-eslint/typescript-estree@8.69.0(supports-color@11.0.0)(typescript@6.0.3)': dependencies: - '@typescript-eslint/project-service': 8.67.0(supports-color@11.0.0)(typescript@6.0.3) - '@typescript-eslint/tsconfig-utils': 8.67.0(typescript@6.0.3) - '@typescript-eslint/types': 8.67.0 - '@typescript-eslint/visitor-keys': 8.67.0 + '@typescript-eslint/project-service': 8.69.0(supports-color@11.0.0)(typescript@6.0.3) + '@typescript-eslint/tsconfig-utils': 8.69.0(typescript@6.0.3) + '@typescript-eslint/types': 8.69.0 + '@typescript-eslint/visitor-keys': 8.69.0 debug: 4.4.3(supports-color@11.0.0) minimatch: 10.2.6 semver: 7.8.5 @@ -9357,12 +9418,12 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/typescript-estree@8.69.0(supports-color@10.2.2)(typescript@6.0.3)': + '@typescript-eslint/typescript-estree@8.71.0(supports-color@10.2.2)(typescript@6.0.3)': dependencies: - '@typescript-eslint/project-service': 8.69.0(supports-color@10.2.2)(typescript@6.0.3) - '@typescript-eslint/tsconfig-utils': 8.69.0(typescript@6.0.3) - '@typescript-eslint/types': 8.69.0 - '@typescript-eslint/visitor-keys': 8.69.0 + '@typescript-eslint/project-service': 8.71.0(supports-color@10.2.2)(typescript@6.0.3) + '@typescript-eslint/tsconfig-utils': 8.71.0(typescript@6.0.3) + '@typescript-eslint/types': 8.71.0 + '@typescript-eslint/visitor-keys': 8.71.0 debug: 4.4.3(supports-color@10.2.2) minimatch: 10.2.6 semver: 7.8.5 @@ -9372,12 +9433,12 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/typescript-estree@8.69.0(supports-color@11.0.0)(typescript@6.0.3)': + '@typescript-eslint/typescript-estree@8.71.0(supports-color@11.0.0)(typescript@6.0.3)': dependencies: - '@typescript-eslint/project-service': 8.69.0(supports-color@11.0.0)(typescript@6.0.3) - '@typescript-eslint/tsconfig-utils': 8.69.0(typescript@6.0.3) - '@typescript-eslint/types': 8.69.0 - '@typescript-eslint/visitor-keys': 8.69.0 + '@typescript-eslint/project-service': 8.71.0(supports-color@11.0.0)(typescript@6.0.3) + '@typescript-eslint/tsconfig-utils': 8.71.0(typescript@6.0.3) + '@typescript-eslint/types': 8.71.0 + '@typescript-eslint/visitor-keys': 8.71.0 debug: 4.4.3(supports-color@11.0.0) minimatch: 10.2.6 semver: 7.8.5 @@ -9409,45 +9470,45 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/utils@8.67.0(eslint@10.10.0(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3)': + '@typescript-eslint/utils@8.69.0(eslint@10.10.0(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3)': dependencies: '@eslint-community/eslint-utils': 4.10.1(eslint@10.10.0(jiti@2.7.0)(supports-color@10.2.2)) - '@typescript-eslint/scope-manager': 8.67.0 - '@typescript-eslint/types': 8.67.0 - '@typescript-eslint/typescript-estree': 8.67.0(supports-color@10.2.2)(typescript@6.0.3) + '@typescript-eslint/scope-manager': 8.69.0 + '@typescript-eslint/types': 8.69.0 + '@typescript-eslint/typescript-estree': 8.69.0(supports-color@10.2.2)(typescript@6.0.3) eslint: 10.10.0(jiti@2.7.0)(supports-color@10.2.2) typescript: 6.0.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/utils@8.67.0(eslint@10.10.0(jiti@2.7.0)(supports-color@11.0.0))(supports-color@11.0.0)(typescript@6.0.3)': + '@typescript-eslint/utils@8.69.0(eslint@10.10.0(jiti@2.7.0)(supports-color@11.0.0))(supports-color@11.0.0)(typescript@6.0.3)': dependencies: '@eslint-community/eslint-utils': 4.10.1(eslint@10.10.0(jiti@2.7.0)(supports-color@11.0.0)) - '@typescript-eslint/scope-manager': 8.67.0 - '@typescript-eslint/types': 8.67.0 - '@typescript-eslint/typescript-estree': 8.67.0(supports-color@11.0.0)(typescript@6.0.3) + '@typescript-eslint/scope-manager': 8.69.0 + '@typescript-eslint/types': 8.69.0 + '@typescript-eslint/typescript-estree': 8.69.0(supports-color@11.0.0)(typescript@6.0.3) eslint: 10.10.0(jiti@2.7.0)(supports-color@11.0.0) typescript: 6.0.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/utils@8.69.0(eslint@10.10.0(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3)': + '@typescript-eslint/utils@8.71.0(eslint@10.10.0(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3)': dependencies: '@eslint-community/eslint-utils': 4.10.1(eslint@10.10.0(jiti@2.7.0)(supports-color@10.2.2)) - '@typescript-eslint/scope-manager': 8.69.0 - '@typescript-eslint/types': 8.69.0 - '@typescript-eslint/typescript-estree': 8.69.0(supports-color@10.2.2)(typescript@6.0.3) + '@typescript-eslint/scope-manager': 8.71.0 + '@typescript-eslint/types': 8.71.0 + '@typescript-eslint/typescript-estree': 8.71.0(supports-color@10.2.2)(typescript@6.0.3) eslint: 10.10.0(jiti@2.7.0)(supports-color@10.2.2) typescript: 6.0.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/utils@8.69.0(eslint@10.10.0(jiti@2.7.0)(supports-color@11.0.0))(supports-color@11.0.0)(typescript@6.0.3)': + '@typescript-eslint/utils@8.71.0(eslint@10.10.0(jiti@2.7.0)(supports-color@11.0.0))(supports-color@11.0.0)(typescript@6.0.3)': dependencies: '@eslint-community/eslint-utils': 4.10.1(eslint@10.10.0(jiti@2.7.0)(supports-color@11.0.0)) - '@typescript-eslint/scope-manager': 8.69.0 - '@typescript-eslint/types': 8.69.0 - '@typescript-eslint/typescript-estree': 8.69.0(supports-color@11.0.0)(typescript@6.0.3) + '@typescript-eslint/scope-manager': 8.71.0 + '@typescript-eslint/types': 8.71.0 + '@typescript-eslint/typescript-estree': 8.71.0(supports-color@11.0.0)(typescript@6.0.3) eslint: 10.10.0(jiti@2.7.0)(supports-color@11.0.0) typescript: 6.0.3 transitivePeerDependencies: @@ -9458,14 +9519,14 @@ snapshots: '@typescript-eslint/types': 8.56.1 eslint-visitor-keys: 5.0.1 - '@typescript-eslint/visitor-keys@8.67.0': + '@typescript-eslint/visitor-keys@8.69.0': dependencies: - '@typescript-eslint/types': 8.67.0 + '@typescript-eslint/types': 8.69.0 eslint-visitor-keys: 5.0.1 - '@typescript-eslint/visitor-keys@8.69.0': + '@typescript-eslint/visitor-keys@8.71.0': dependencies: - '@typescript-eslint/types': 8.69.0 + '@typescript-eslint/types': 8.71.0 eslint-visitor-keys: 5.0.1 '@vanilla-extract/babel-plugin-debug-ids@1.2.2(supports-color@10.2.2)': @@ -10521,12 +10582,12 @@ snapshots: - supports-color - typescript - eslint-plugin-json-canonical@2.1.0(@eslint/json@2.1.0)(eslint@10.10.0(jiti@2.7.0)(supports-color@10.2.2)): + eslint-plugin-json-canonical@2.1.3(@eslint/json@2.1.0)(eslint@10.10.0(jiti@2.7.0)(supports-color@10.2.2)): dependencies: '@eslint/json': 2.1.0 eslint: 10.10.0(jiti@2.7.0)(supports-color@10.2.2) - eslint-plugin-json-canonical@2.1.0(@eslint/json@2.1.0)(eslint@10.10.0(jiti@2.7.0)(supports-color@11.0.0)): + eslint-plugin-json-canonical@2.1.3(@eslint/json@2.1.0)(eslint@10.10.0(jiti@2.7.0)(supports-color@11.0.0)): dependencies: '@eslint/json': 2.1.0 eslint: 10.10.0(jiti@2.7.0)(supports-color@11.0.0) From 88973d4adb7dc959c2181b6dd61813fda0c0cb63 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sat, 10 Oct 2026 10:13:08 +0100 Subject: [PATCH 2/7] refactor(conformance): satisfy the eslint-config 3 rules The golden vector files are ignored by eslint: generate.ts writes them and CI diffs them against a fresh generation, so their key order and layout belong to the generator. The tampered advert vectors return an altered copy instead of mutating the signed one, the verify suite is renamed verify.unit.test.ts for the test-kind rule, tsconfig options are sorted, and trailing comments move above the code they describe. Concurrency: none introduced. --- conformance/README.md | 6 +- conformance/adverts.ts | 44 ++++---- conformance/codec.ts | 16 ++- conformance/generate.ts | 105 +++++++++++------- conformance/relay-vectors.ts | 1 + conformance/token-vectors.ts | 49 ++++---- conformance/tsconfig.json | 16 +-- conformance/tsconfig.node.json | 2 +- conformance/tsdown.config.ts | 4 +- conformance/turbo.json | 12 +- .../{verify.test.ts => verify.unit.test.ts} | 0 rust/crates/wire-mesh-conformance/src/main.rs | 2 +- 12 files changed, 153 insertions(+), 104 deletions(-) rename conformance/{verify.test.ts => verify.unit.test.ts} (100%) diff --git a/conformance/README.md b/conformance/README.md index edcea392..caac5c02 100644 --- a/conformance/README.md +++ b/conformance/README.md @@ -6,7 +6,7 @@ Golden test vectors: every implementation's CI must decode each vector's `wire_h `adverts.v1.json` is the one file built on real cryptographic material rather than structural filler, and the one whose vectors carry more than a round trip. A peer-advert's signature is the only thing binding a gossiped device-id to the device that owns it (`spec/transport.cddl`), so each vector also records `signing_input_hex`, the exact bytes that signature covers, and `verifies`, the verdict a conformant verifier must reach. Two implementations that reconstruct the signing input even one byte differently reject each other's every advert while each passes its own tests, which is precisely the failure a frozen byte string turns into a failing vector. Ed25519 rather than ES256, from a fixed seed, because its signatures are deterministic (RFC 8032) and CI regenerates this file and diffs it against the committed copy. -`verify.test.ts` checks only the round trip every vector file shares: this package holds no protocol implementation of its own, so `signing_input_hex` and `verifies` are checked by the implementations themselves, in `ts/packages/core`'s own conformance test and in `rust/crates/wire-mesh-conformance`. +`verify.unit.test.ts` checks only the round trip every vector file shares: this package holds no protocol implementation of its own, so `signing_input_hex` and `verifies` are checked by the implementations themselves, in `ts/packages/core`'s own conformance test and in `rust/crates/wire-mesh-conformance`. ## Regenerating @@ -28,9 +28,9 @@ Linting uses [`@exadev/eslint-config`](https://www.npmjs.com/package/@exadev/esl `codec.ts` defines the one JSON convention every vector's `message` needs: since JSON has no byte-string type, a CDDL `bstr` field is written as `{ "hex": "" }` rather than a raw string or number array. `toWire`/`fromWire` convert between that marker shape and the real bytes CBOR needs on the way in and out; `isVectorFile` validates a parsed vector file at the JSON boundary rather than trusting an `as` cast. -`generate.ts` and `verify.test.ts` both import it as `@exadev/wire-mesh-conformance` (self-referencing the package by its own name), not via a relative path -- `pnpm run build` (`tsdown`) compiles `codec.ts` into dual ESM/CJS output plus `.d.mts`/`.d.cts` declarations under `dist/`, and `package.json`'s `exports` map is what makes the self-reference resolve to that built output rather than the source file. This means the same artifact every consumer would actually get is what runs here, not a stand-in. [`@arethetypeswrong/cli`](https://github.com/arethetypeswrong/arethetypeswrong.github.io) (wired into the `build` script via `tsdown`'s own `attw` option) checks that dual-package surface resolves correctly under Node's `node16` module resolution -- catching the class of "works in this repo, broken for a real consumer" bug that a bare `tsc` build can't see, before it ever has to matter. +`generate.ts` and `verify.unit.test.ts` both import it as `@exadev/wire-mesh-conformance` (self-referencing the package by its own name), not via a relative path -- `pnpm run build` (`tsdown`) compiles `codec.ts` into dual ESM/CJS output plus `.d.mts`/`.d.cts` declarations under `dist/`, and `package.json`'s `exports` map is what makes the self-reference resolve to that built output rather than the source file. This means the same artifact every consumer would actually get is what runs here, not a stand-in. [`@arethetypeswrong/cli`](https://github.com/arethetypeswrong/arethetypeswrong.github.io) (wired into the `build` script via `tsdown`'s own `attw` option) checks that dual-package surface resolves correctly under Node's `node16` module resolution -- catching the class of "works in this repo, broken for a real consumer" bug that a bare `tsc` build can't see, before it ever has to matter. -Node (26+) runs every `.ts` file here directly via its own native TypeScript support -- no `tsx`/`ts-node` needed. `tsconfig.json` sets `moduleResolution: "nodenext"` to match that reality, and is scoped to `codec.ts` alone (`"include": ["codec.ts"]`) since that's the one file `tsdown` actually builds -- anything else in this scope (a script's own top-level await, a config file's own type shape) would otherwise leak into what gets type-checked as part of the *build*. Everything else (`generate.ts`, `verify.test.ts`, `tsdown.config.ts`, `eslint.config.ts`) is covered by `tsconfig.node.json` instead, which extends `tsconfig.json`. `pnpm run typecheck` runs both. +Node (26+) runs every `.ts` file here directly via its own native TypeScript support -- no `tsx`/`ts-node` needed. `tsconfig.json` sets `moduleResolution: "nodenext"` to match that reality, and is scoped to `codec.ts` alone (`"include": ["codec.ts"]`) since that's the one file `tsdown` actually builds -- anything else in this scope (a script's own top-level await, a config file's own type shape) would otherwise leak into what gets type-checked as part of the *build*. Everything else (`generate.ts`, `verify.unit.test.ts`, `tsdown.config.ts`, `eslint.config.ts`) is covered by `tsconfig.node.json` instead, which extends `tsconfig.json`. `pnpm run typecheck` runs both. Signature and public-key bytes throughout are clearly-synthetic filler (`aa`/`bb`/`ee`/`ff`-repeated hex), not real cryptographic material -- these vectors freeze the wire-exact envelope shape (map key ordering, field presence, the recursive delegation-chain nesting), not a working signature, the same scope Cascade's own frozen vectors commit to for fields with no real crypto behind them. diff --git a/conformance/adverts.ts b/conformance/adverts.ts index 38b2a1f5..eb6a3dbe 100644 --- a/conformance/adverts.ts +++ b/conformance/adverts.ts @@ -1,8 +1,8 @@ -// adverts.v1.json's own vectors, kept out of generate.ts because they are the one set here built on real cryptographic material rather than structural filler, and because generate.ts is already at this repo's own max-lines cap. -// -// A peer-advert's signature is the only thing tying a gossiped device-id to the device that owns it (spec/transport.cddl, wire-mesh#225), and two implementations that disagree by a single byte about what is signed will reject each other's every advert while each passes its own tests. Freezing the signing input alongside the wire bytes is what turns that disagreement into a failing vector rather than a field mystery. The construction below is written from the spec text, deliberately not imported from either implementation: a vector generated by the thing it is meant to check would prove nothing. -// -// Ed25519 rather than ES256 because its signatures are deterministic (RFC 8032): CI regenerates this file's output and diffs it against the committed copy, which an ECDSA signature's fresh per-signing nonce would fail on every run. The keys come from fixed seeds for the same reason. +/* adverts.v1.json's own vectors, kept out of generate.ts because they are the one set here built on real cryptographic material rather than structural filler, and because generate.ts is already at this repo's own max-lines cap. + + A peer-advert's signature is the only thing tying a gossiped device-id to the device that owns it (spec/transport.cddl, wire-mesh#225), and two implementations that disagree by a single byte about what is signed will reject each other's every advert while each passes its own tests. Freezing the signing input alongside the wire bytes is what turns that disagreement into a failing vector rather than a field mystery. The construction below is written from the spec text, deliberately not imported from either implementation: a vector generated by the thing it is meant to check would prove nothing. + + Ed25519 rather than ES256 because its signatures are deterministic (RFC 8032): CI regenerates this file's output and diffs it against the committed copy, which an ECDSA signature's fresh per-signing nonce would fail on every run. The keys come from fixed seeds for the same reason. */ import { createHash, @@ -20,9 +20,12 @@ import { } from "@exadev/wire-mesh-conformance"; const ED25519_SEED_BYTE_LENGTH = 32; -const EDDSA = -8; // COSE algorithm identifier for pure Ed25519 (RFC 9053) -const LOW_BYTE_MASK = 0xff; // XOR operand keeping a corrupted byte within one octet -const SNAPSHOT_SECONDS = 1861833600; // an arbitrary fixed Unix-seconds instant, so regenerating reproduces the committed bytes +// COSE algorithm identifier for pure Ed25519 (RFC 9053) +const EDDSA = -8; +// XOR operand keeping a corrupted byte within one octet +const LOW_BYTE_MASK = 0xff; +// an arbitrary fixed Unix-seconds instant, so regenerating reproduces the committed bytes +const SNAPSHOT_SECONDS = 1861833600; /** RFC 8410 section 7's PKCS#8 prefix for an Ed25519 private key, sitting ahead of the 32 raw seed bytes and encoding the version, the 1.3.101.112 algorithm identifier, and the octet-string wrapper around the seed. Node accepts no rawer form for an Ed25519 private key, so the seed is wrapped rather than imported directly. */ const PKCS8_ED25519_PREFIX = Buffer.from( @@ -57,6 +60,7 @@ function identityFromSeed(seedFillHex: string): AdvertIdentity { throw new Error("Ed25519 public JWK is missing its raw x coordinate"); } const publicKeyBytes = Buffer.from(publicJwk.x, "base64url"); + return { privateKey, publicKeyBytes, @@ -79,6 +83,7 @@ function advertSigningInput( } content[key] = value; } + return Buffer.concat([ PEER_ADVERT_SIGNING_CONTEXT, Buffer.from(encode(toWire(content), cdeEncodeOptions)), @@ -92,7 +97,7 @@ function wireHex(message: JsonWire): string { /** * Builds one advert vector, signing the content with `signer` and recording the verdict a conformant verifier must reach for it. * - * `tamper` runs after signing, which is how the negative vectors are produced: the advert really was signed, and then something the signature covers was changed, exactly as a hostile relay or peer would do it. + * `tamper` runs after signing, which is how the negative vectors are produced: the advert really was signed, and then something the signature covers was changed, exactly as a hostile relay or peer would do it. It returns the altered copy rather than mutating the signed advert. */ function advertVector( name: string, @@ -100,15 +105,19 @@ function advertVector( options: Readonly<{ signer: KeyObject; verifies: boolean; - tamper?: (signed: Record) => void; + tamper?: ( + signed: Readonly>, + ) => Record; }>, ): AdvertVector { const signature = nodeSign(null, advertSigningInput(advert), options.signer); - const signed: Record = { + const genuine: Record = { ...advert, signature: hex(signature.toString("hex")), }; - options.tamper?.(signed); + const signed = + options.tamper === undefined ? genuine : options.tamper(genuine); + return { name, message: signed, @@ -146,18 +155,14 @@ export const advertVectors: AdvertVector[] = [ { signer: advertiser.privateKey, verifies: false, - tamper: (signed) => { - signed["presence/status"] = "active"; - }, + tamper: (signed) => ({ ...signed, "presence/status": "active" }), }, ), // An extension key added after signing, which the tail's openness would otherwise make free: a verifier that encoded only the fields it recognised would accept this. advertVector("peer_advert_v1_added_extension", advertBase, { signer: advertiser.privateKey, verifies: false, - tamper: (signed) => { - signed["room/hosted"] = ["general"]; - }, + tamper: (signed) => ({ ...signed, "room/hosted": ["general"] }), }), // A genuine signature by a real key, over an advert naming somebody else's device: refused by self-certification, since sha256 of the embedded public-key is not the claimed device. advertVector( @@ -192,7 +197,8 @@ export const advertVectors: AdvertVector[] = [ throw new Error("signature has no bytes to corrupt"); } bytes[lastIndex] = lastByte ^ LOW_BYTE_MASK; - signed.signature = hex(bytes.toString("hex")); + + return { ...signed, signature: hex(bytes.toString("hex")) }; }, }), ]; diff --git a/conformance/codec.ts b/conformance/codec.ts index 0451e1b2..eddc74e8 100644 --- a/conformance/codec.ts +++ b/conformance/codec.ts @@ -1,6 +1,6 @@ -// Shared JSON<->wire helpers for the conformance vector generator and its vitest suite. -// -// JSON has no byte-string type, so every CDDL `bstr` field is represented in a vector's `message` as `{ "hex": "" }` rather than a raw string or array of numbers -- this keeps `message` valid, diffable JSON while still letting the codec reconstruct exactly the bytes CBOR needs. `toWire` walks a `message` value replacing every such marker with a real byte buffer before encoding; `fromWire` walks a decoded value the other way, turning every real byte string back into the same marker shape so it can be compared against the original `message` with a plain deep-equal. +/* Shared JSON<->wire helpers for the conformance vector generator and its vitest suite. + + JSON has no byte-string type, so every CDDL `bstr` field is represented in a vector's `message` as `{ "hex": "" }` rather than a raw string or array of numbers -- this keeps `message` valid, diffable JSON while still letting the codec reconstruct exactly the bytes CBOR needs. `toWire` walks a `message` value replacing every such marker with a real byte buffer before encoding; `fromWire` walks a decoded value the other way, turning every real byte string back into the same marker shape so it can be compared against the original `message` with a plain deep-equal. */ export interface HexBytes { hex: string; @@ -54,6 +54,7 @@ function isHexBytes(value: unknown): value is HexBytes { return false; if (!("hex" in value)) return false; if (Object.keys(value).length !== 1) return false; + return typeof value.hex === "string"; } @@ -78,8 +79,10 @@ export function toWire(value: JsonWire): unknown { if (value !== null && typeof value === "object") { const out: Record = {}; for (const [k, v] of Object.entries(value)) out[k] = toWire(v); + return out; } + return value; } @@ -93,11 +96,13 @@ export function fromWire(value: unknown): JsonWire { if (value instanceof Map) { const out: Record = {}; for (const [k, v] of value.entries()) out[String(k)] = fromWire(v); + return out; } if (isPlainObject(value)) { const out: Record = {}; for (const [k, v] of Object.entries(value)) out[k] = fromWire(v); + return out; } if ( @@ -124,6 +129,7 @@ function isJsonWire(value: unknown): value is JsonWire { } if (Array.isArray(value)) return value.every(isJsonWire); if (typeof value === "object") return Object.values(value).every(isJsonWire); + return false; } @@ -131,6 +137,7 @@ function isVector(value: unknown): value is Vector { if (typeof value !== "object" || value === null) return false; if (!("name" in value) || !("message" in value) || !("wire_hex" in value)) return false; + return ( typeof value.name === "string" && isJsonWire(value.message) && @@ -141,12 +148,14 @@ function isVector(value: unknown): value is Vector { export function isVectorFile(value: unknown): value is VectorFile { if (typeof value !== "object" || value === null) return false; if (!("vectors" in value)) return false; + return Array.isArray(value.vectors) && value.vectors.every(isVector); } function isAdvertVector(value: unknown): value is AdvertVector { if (!isVector(value)) return false; if (!("signing_input_hex" in value) || !("verifies" in value)) return false; + return ( typeof value.signing_input_hex === "string" && typeof value.verifies === "boolean" @@ -156,5 +165,6 @@ function isAdvertVector(value: unknown): value is AdvertVector { export function isAdvertVectorFile(value: unknown): value is AdvertVectorFile { if (typeof value !== "object" || value === null) return false; if (!("vectors" in value)) return false; + return Array.isArray(value.vectors) && value.vectors.every(isAdvertVector); } diff --git a/conformance/generate.ts b/conformance/generate.ts index b6fd7b16..80044412 100644 --- a/conformance/generate.ts +++ b/conformance/generate.ts @@ -1,6 +1,6 @@ -// Produces conformance/{handshake,tokens,adverts,frames}.v1.json from the vector definitions below. Each vector's `wire_hex` is derived mechanically by canonically CBOR-encoding `message` via cbor2's CDE (CBOR Common Deterministic Encoding) mode -- the same RFC 8949 4.2 core deterministic rules DAG-CBOR builds on -- never hand-typed. Signature and public-key bytes are clearly-synthetic filler everywhere except adverts.v1.json, since those files freeze the wire-exact envelope shape (map key ordering, field presence, array structure, nesting) rather than a working signature, the same scope Cascade's own frozen frames/handshake/tokens vectors commit to for structural fields with no real crypto behind them. adverts.v1.json is the exception, and carries real Ed25519 material: a peer-advert's signature is the only thing binding a gossiped device-id to the device that owns it, so what gets signed has to be pinned across implementations, not merely shaped correctly. -// -// Run `pnpm generate` after changing anything below, then `pnpm test` to confirm every vector round-trips. +/* Produces conformance/{handshake,tokens,adverts,frames}.v1.json from the vector definitions below. Each vector's `wire_hex` is derived mechanically by canonically CBOR-encoding `message` via cbor2's CDE (CBOR Common Deterministic Encoding) mode -- the same RFC 8949 4.2 core deterministic rules DAG-CBOR builds on -- never hand-typed. Signature and public-key bytes are clearly-synthetic filler everywhere except adverts.v1.json, since those files freeze the wire-exact envelope shape (map key ordering, field presence, array structure, nesting) rather than a working signature, the same scope Cascade's own frozen frames/handshake/tokens vectors commit to for structural fields with no real crypto behind them. adverts.v1.json is the exception, and carries real Ed25519 material: a peer-advert's signature is the only thing binding a gossiped device-id to the device that owns it, so what gets signed has to be pinned across implementations, not merely shaped correctly. + + Run `pnpm generate` after changing anything below, then `pnpm test` to confirm every vector round-trips. */ import { encode, cdeEncodeOptions } from "cbor2"; import { @@ -28,30 +28,45 @@ function vector(name: string, message: JsonWire): Vector { // -- Byte lengths named for what they actually are, not left as bare literals -- -const SHA256_BYTE_LENGTH = 32; // device-id = SHA-256(identity-key.public-key) -const P256_COORDINATE_BYTE_LENGTH = 32; // uncompressed SEC1 point: 0x04 || X || Y, X and Y each this length +// device-id = SHA-256(identity-key.public-key) +const SHA256_BYTE_LENGTH = 32; +// uncompressed SEC1 point: 0x04 || X || Y, X and Y each this length +const P256_COORDINATE_BYTE_LENGTH = 32; const ED25519_PUBLIC_KEY_BYTE_LENGTH = 32; -const SIGNATURE_BYTE_LENGTH = 64; // raw ES256/EdDSA signature length -const TOKEN_ID_BYTE_LENGTH = 16; // opaque token-id, arbitrarily sized like a UUID -const EXAMPLE_RELAY_PAYLOAD_BYTE_LENGTH = 24; // arbitrary example ciphertext length for relay-data-frame -const SECURE_EPHEMERAL_KEY_BYTE_LENGTH = 65; // SEC1 uncompressed P-256 point in secure-hello-frame: 0x04 || X || Y -const SECURE_NONCE_BYTE_LENGTH = 16; // secure-hello-frame nonce -const EXAMPLE_SECURE_CIPHERTEXT_BYTE_LENGTH = 32; // arbitrary example AEAD output length for secure-data-frame +// raw ES256/EdDSA signature length +const SIGNATURE_BYTE_LENGTH = 64; +// opaque token-id, arbitrarily sized like a UUID +const TOKEN_ID_BYTE_LENGTH = 16; +// arbitrary example ciphertext length for relay-data-frame +const EXAMPLE_RELAY_PAYLOAD_BYTE_LENGTH = 24; +// SEC1 uncompressed P-256 point in secure-hello-frame: 0x04 || X || Y +const SECURE_EPHEMERAL_KEY_BYTE_LENGTH = 65; +// secure-hello-frame nonce +const SECURE_NONCE_BYTE_LENGTH = 16; +// arbitrary example AEAD output length for secure-data-frame +const EXAMPLE_SECURE_CIPHERTEXT_BYTE_LENGTH = 32; // -- Shared synthetic identities, reused across files for a coherent story -- -const deviceA = hex("11".repeat(SHA256_BYTE_LENGTH)); // issuer / coordinator -const deviceB = hex("22".repeat(SHA256_BYTE_LENGTH)); // bearer of the root token / delegator -const deviceC = hex("33".repeat(SHA256_BYTE_LENGTH)); // bearer of the delegated token -const deviceD = hex("44".repeat(SHA256_BYTE_LENGTH)); // handle-record subject -const deviceGroup = hex("55".repeat(SHA256_BYTE_LENGTH)); // exadev.io/threshold's own group device-id -- SHA-256(group verifying key), an ordinary identity.cddl device-id derivation applied to a FROST-issued Ed25519 key, distinct from any single participant's own device-id -const deviceGroupKeyBytes = hex("66".repeat(ED25519_PUBLIC_KEY_BYTE_LENGTH)); // synthetic group verifying-key bytes, reused by threshold-keygen-round1's existing-group-key and threshold-keygen-confirm's group-key +// issuer / coordinator +const deviceA = hex("11".repeat(SHA256_BYTE_LENGTH)); +// bearer of the root token / delegator +const deviceB = hex("22".repeat(SHA256_BYTE_LENGTH)); +// bearer of the delegated token +const deviceC = hex("33".repeat(SHA256_BYTE_LENGTH)); +// handle-record subject +const deviceD = hex("44".repeat(SHA256_BYTE_LENGTH)); +// exadev.io/threshold's own group device-id -- SHA-256(group verifying key), an ordinary identity.cddl device-id derivation applied to a FROST-issued Ed25519 key, distinct from any single participant's own device-id +const deviceGroup = hex("55".repeat(SHA256_BYTE_LENGTH)); +// synthetic group verifying-key bytes, reused by threshold-keygen-round1's existing-group-key and threshold-keygen-confirm's group-key +const deviceGroupKeyBytes = hex("66".repeat(ED25519_PUBLIC_KEY_BYTE_LENGTH)); const publicKeyEs256A = hex( "04" + "aa".repeat(P256_COORDINATE_BYTE_LENGTH) + "bb".repeat(P256_COORDINATE_BYTE_LENGTH), -); // uncompressed P-256 point, synthetic + // uncompressed P-256 point, synthetic +); const publicKeyEs256B = hex( "04" + "cc".repeat(P256_COORDINATE_BYTE_LENGTH) + @@ -59,13 +74,15 @@ const publicKeyEs256B = hex( ); const publicKeyEd25519D = hex("ee".repeat(ED25519_PUBLIC_KEY_BYTE_LENGTH)); -const signatureFiller = hex("ff".repeat(SIGNATURE_BYTE_LENGTH)); // synthetic ES256/EdDSA-shaped signature +// synthetic ES256/EdDSA-shaped signature +const signatureFiller = hex("ff".repeat(SIGNATURE_BYTE_LENGTH)); -const EDDSA = -8; // COSE algorithm identifier for pure Ed25519 (RFC 9053) +// COSE algorithm identifier for pure Ed25519 (RFC 9053) +const EDDSA = -8; -// ----------------------------------------------------------------------- -// handshake.v1.json -// ----------------------------------------------------------------------- +/* ----------------------------------------------------------------------- + handshake.v1.json + ----------------------------------------------------------------------- */ const handshakeVectors: Vector[] = [ vector("handshake_v1_management_exec_data", { @@ -81,9 +98,9 @@ const handshakeVectors: Vector[] = [ }), ]; -// ----------------------------------------------------------------------- -// tokens.v1.json -// ----------------------------------------------------------------------- +/* ----------------------------------------------------------------------- + tokens.v1.json + ----------------------------------------------------------------------- */ const rootTokenClaims: JsonWire = { "token-id": hex("01".repeat(TOKEN_ID_BYTE_LENGTH)), @@ -112,7 +129,8 @@ const delegatedTokenClaims: JsonWire = { bearer: deviceC, capability: "exec:pty", scope: { kind: "folder", path: "/work/subdir" }, - expires: 1861920000000, // earlier than the parent's expiry -- delegation narrows, never widens + // earlier than the parent's expiry -- delegation narrows, never widens + expires: 1861920000000, parent: hex(rootTokenVector.wire_hex), }; @@ -153,8 +171,9 @@ const roomMemberVectors: Vector[] = roomMember.vectors; // A room:member grant chain demonstrating this session's own delegations-remaining fix: the owner (deviceA) issues a root grant to deviceB capped at one further re-delegation, and deviceB narrows it (a strictly lower value, 0) when re-delegating to deviceC -- deviceC's own token therefore bears no further-delegation authority at all, closing the unbounded-admission gap the claim exists to fix. -// core/room's own noticeboard entry (room.cddl's room-notice), self-certifying the same way capability-token and handle-record already are. deviceB posts to the same general room its roomMemberRootToken already grants it membership in, embedding that exact token in full so a reader with no other context can verify posting authority from the notice alone. -const NOTICE_ID_BYTE_LENGTH = 16; // opaque notice-id, arbitrarily sized like token-id +/* core/room's own noticeboard entry (room.cddl's room-notice), self-certifying the same way capability-token and handle-record already are. deviceB posts to the same general room its roomMemberRootToken already grants it membership in, embedding that exact token in full so a reader with no other context can verify posting authority from the notice alone. + opaque notice-id, arbitrarily sized like token-id */ +const NOTICE_ID_BYTE_LENGTH = 16; const roomNoticeClaims: JsonWire = { room: `${deviceAHex}/general`, @@ -180,7 +199,8 @@ const roomNoticeVector = vector("room_notice_v1_posted", roomNotice); const roomNoticeForwardClaims: JsonWire = { room: `${deviceAHex}/general`, poster: deviceC, - "poster-key": { alg: -7, "public-key": publicKeyEs256B }, // reusing B's synthetic key bytes for C is fine here -- this file freezes envelope shape, not real per-device key material + // reusing B's synthetic key bytes for C is fine here -- this file freezes envelope shape, not real per-device key material + "poster-key": { alg: -7, "public-key": publicKeyEs256B }, token: roomMember.delegatedToken, "notice-id": hex("a2".repeat(NOTICE_ID_BYTE_LENGTH)), "posted-at": 1861920100000, @@ -210,7 +230,8 @@ const roomNoticeEncryptedClaims: JsonWire = { "notice-id": hex("a3".repeat(NOTICE_ID_BYTE_LENGTH)), "posted-at": 1861920200000, "content-type": "text/plain+aes256gcm", - content: hex("deadbeef"), // structural placeholder for an AES-256-GCM ciphertext, not real crypto -- see this file's own header comment + // structural placeholder for an AES-256-GCM ciphertext, not real crypto -- see this file's own header comment + content: hex("deadbeef"), "key-epoch": 1, }; @@ -255,9 +276,9 @@ const tokenVectors: Vector[] = [ handleRecordVector, ]; -// ----------------------------------------------------------------------- -// frames.v1.json -- every $frame-variant in spec/frame.cddl except handshake-frame, which lives in handshake.v1.json above. -// ----------------------------------------------------------------------- +/* ----------------------------------------------------------------------- + frames.v1.json -- every $frame-variant in spec/frame.cddl except handshake-frame, which lives in handshake.v1.json above. + ----------------------------------------------------------------------- */ const frameVectors: Vector[] = [ vector("ping_v1", { type: "ping" }), @@ -515,8 +536,8 @@ const frameVectors: Vector[] = [ scope: { kind: "room", path: `${deviceAHex}/general` }, token: roomMember.rootToken, }), - // room.join and room.invite are deliberately ungated (no token field) -- access control is a human's explicit approval in the receiving UI, not a pre-shared token, the first verbs in this spec to work that way. This join vector uses a DM room path (the sorted device-id pair), the shape a first, tokenless contact actually needs. - // wire-mesh#324: the same capability.request ask, presented by a requester holding a manage:request permission -- manage-request-frame's own token field carries it, exactly what a receiver that has opted into gating checks. + /* room.join and room.invite are deliberately ungated (no token field) -- access control is a human's explicit approval in the receiving UI, not a pre-shared token, the first verbs in this spec to work that way. This join vector uses a DM room path (the sorted device-id pair), the shape a first, tokenless contact actually needs. + wire-mesh#324: the same capability.request ask, presented by a requester holding a manage:request permission -- manage-request-frame's own token field carries it, exactly what a receiver that has opted into gating checks. */ vector("manage_request_v1_capability_request_gated", { type: "manage-request", "request-id": 21, @@ -652,7 +673,8 @@ const frameVectors: Vector[] = [ session: 7, seq: 3, channel: "stdout", - bytes: hex("68656c6c6f0a"), // "hello\n" + // "hello\n" + bytes: hex("68656c6c6f0a"), }), vector("stream_ack_v1", { type: "stream-ack", @@ -679,9 +701,11 @@ const frameVectors: Vector[] = [ }), vector("bulk_data_v1_chunk", { type: "bulk-data", - "transfer-id": hex("aa".repeat(TOKEN_ID_BYTE_LENGTH)), // transfer-id is the identical opaque-16-byte convention token-id already uses + // transfer-id is the identical opaque-16-byte convention token-id already uses + "transfer-id": hex("aa".repeat(TOKEN_ID_BYTE_LENGTH)), seq: 3, - bytes: hex("68656c6c6f0a"), // "hello\n" + // "hello\n" + bytes: hex("68656c6c6f0a"), }), vector("bulk_ack_v1", { type: "bulk-ack", @@ -692,7 +716,8 @@ const frameVectors: Vector[] = [ vector("bulk_end_v1", { type: "bulk-end", "transfer-id": hex("aa".repeat(TOKEN_ID_BYTE_LENGTH)), - digest: hex("cd".repeat(SHA256_BYTE_LENGTH)), // digest is a SHA-256 hash, the same 32-byte length device-id derivation already uses + // digest is a SHA-256 hash, the same 32-byte length device-id derivation already uses + digest: hex("cd".repeat(SHA256_BYTE_LENGTH)), }), // exadev.io/threshold (wire-mesh#29/#171) -- FROST(Ed25519) threshold signing's two-round commit/sign protocol, session abort, and the collapsed DKG/reshare keygen-round1/round2/confirm triplet. The group itself (deviceGroup) is a synthetic Ed25519 device-id distinct from deviceA/B/C, which here play the role of the group's own committing/signing participants. vector("manage_request_v1_threshold_commit", { diff --git a/conformance/relay-vectors.ts b/conformance/relay-vectors.ts index 55321355..cf74de19 100644 --- a/conformance/relay-vectors.ts +++ b/conformance/relay-vectors.ts @@ -18,6 +18,7 @@ export function relayVectors(ctx: Readonly): Vector[] { wire_hex: ctx.wireHex(message), }); const payload = hex("de".repeat(ctx.payloadByteLength)); + return [ vector("relay_offer_v1", { type: "relay-offer", diff --git a/conformance/token-vectors.ts b/conformance/token-vectors.ts index eb0e9875..756fdedc 100644 --- a/conformance/token-vectors.ts +++ b/conformance/token-vectors.ts @@ -1,9 +1,9 @@ -// Token vectors kept out of generate.ts the same way adverts.ts already is: generate.ts -// sits at this repo's own max-lines cap, so when a set of token vectors grows the file it -// moves here instead. Everything shared with generate.ts (the synthetic identities, the -// signature filler, the CDE hex encoders) arrives via the context parameter rather than -// being redefined here, so the two files cannot drift apart on what deviceB or a filler -// signature is. +/* Token vectors kept out of generate.ts the same way adverts.ts already is: generate.ts + sits at this repo's own max-lines cap, so when a set of token vectors grows the file it + moves here instead. Everything shared with generate.ts (the synthetic identities, the + signature filler, the CDE hex encoders) arrives via the context parameter rather than + being redefined here, so the two files cannot drift apart on what deviceB or a filler + signature is. */ import { encode, cdeEncodeOptions } from "cbor2"; import { hex, type JsonWire, type Vector } from "@exadev/wire-mesh-conformance"; @@ -42,8 +42,8 @@ export function grantTokenVectors(ctx: Readonly): Vector[] { ctx.signatureFiller, ]; - // The named no-self-grant bar, the exact node shape the TS core's noSelfGrantBar - // builds: not(and(granted-capability-is "exec:pty", grantee-is )). + /* The named no-self-grant bar, the exact node shape the TS core's noSelfGrantBar + builds: not(and(granted-capability-is "exec:pty", grantee-is )). */ const noSelfGrantBar = { kind: "not", operand: { @@ -74,11 +74,11 @@ export function grantTokenVectors(ctx: Readonly): Vector[] { encode([noSelfGrantBar], cdeEncodeOptions), ).toString("hex"); - // A manage:grant root (deviceA grants deviceB the right to mint, any verb within - // /work since grants-capability is absent, one hop deep) carrying that bar, then an - // exec:pty token deviceB minted under it (citing it via authorised-by, granted to - // deviceC so the bar holds), and a second-level manage:grant deviceB passed onward to - // deviceC, itself naming exec:pty. + /* A manage:grant root (deviceA grants deviceB the right to mint, any verb within + /work since grants-capability is absent, one hop deep) carrying that bar, then an + exec:pty token deviceB minted under it (citing it via authorised-by, granted to + deviceC so the bar holds), and a second-level manage:grant deviceB passed onward to + deviceC, itself naming exec:pty. */ const manageGrantRootTokenVector = vector( "capability_token_v1_manage_grant_root", token(ctx.deviceA, ctx.publicKeyEs256A, { @@ -132,10 +132,12 @@ export function grantTokenVectors(ctx: Readonly): Vector[] { ]; } -/// core/room's own membership chain demonstrating delegations-remaining: the owner -/// (deviceA) issues a root grant to deviceB capped at one further re-delegation, and -/// deviceB narrows it (a strictly lower value, 0) when re-delegating to deviceC, whose -/// own token therefore bears no further-delegation authority at all. +/** + * / core/room's own membership chain demonstrating delegations-remaining: the owner + * / (deviceA) issues a root grant to deviceB capped at one further re-delegation, and + * / deviceB narrows it (a strictly lower value, 0) when re-delegating to deviceC, whose + * / own token therefore bears no further-delegation authority at all. + */ export function roomMemberTokens(ctx: Readonly): { vectors: Vector[]; /** The root room:member token's own JsonWire shape, for vectors that embed it (a room-notice's posting authority, a manage-request's presented token). */ @@ -191,11 +193,13 @@ export function roomMemberTokens(ctx: Readonly): { }; } -/// wire-mesh#324's request-permission root: deviceA grants deviceB the right to ASK for -/// room:member over any room (a path-less room scope is the kind's whole root, so the -/// token's scope covers any room path a request might carry). Presented in -/// manage-request-frame's own token field; the gated frame vector below embeds exactly -/// these wire bytes. +/** + * / wire-mesh#324's request-permission root: deviceA grants deviceB the right to ASK for + * / room:member over any room (a path-less room scope is the kind's whole root, so the + * / token's scope covers any room path a request might carry). Presented in + * / manage-request-frame's own token field; the gated frame vector below embeds exactly + * / these wire bytes. + */ export function manageRequestTokenVector( ctx: Readonly, ): Vector { @@ -204,6 +208,7 @@ export function manageRequestTokenVector( message, wire_hex: ctx.wireHex(message), }); + return vector("capability_token_v1_manage_request_root", [ hex(ctx.protectedHeaderHex(ctx.deviceA)), {}, diff --git a/conformance/tsconfig.json b/conformance/tsconfig.json index 0a98ca14..f9ed4416 100644 --- a/conformance/tsconfig.json +++ b/conformance/tsconfig.json @@ -1,17 +1,17 @@ { "compilerOptions": { - "target": "ES2024", + // generate.ts imports ./adverts.ts by its real extension, which is what Node's own TypeScript support resolves; nothing here is emitted, so the extension never has to be rewritten. + "allowImportingTsExtensions": true, + "exactOptionalPropertyTypes": true, "module": "nodenext", "moduleResolution": "nodenext", - "strict": true, - "noUncheckedIndexedAccess": true, - "exactOptionalPropertyTypes": true, - "noImplicitReturns": true, - "noFallthroughCasesInSwitch": true, "noEmit": true, - // generate.ts imports ./adverts.ts by its real extension, which is what Node's own TypeScript support resolves; nothing here is emitted, so the extension never has to be rewritten. - "allowImportingTsExtensions": true, + "noFallthroughCasesInSwitch": true, + "noImplicitReturns": true, + "noUncheckedIndexedAccess": true, "skipLibCheck": true, + "strict": true, + "target": "ES2024", "types": ["node"] }, "include": ["codec.ts"] diff --git a/conformance/tsconfig.node.json b/conformance/tsconfig.node.json index af9a9b8f..95f04631 100644 --- a/conformance/tsconfig.node.json +++ b/conformance/tsconfig.node.json @@ -4,7 +4,7 @@ "adverts.ts", "vector-files.ts", "generate.ts", - "verify.test.ts", + "verify.unit.test.ts", "tsdown.config.ts", "eslint.config.ts" ] diff --git a/conformance/tsdown.config.ts b/conformance/tsdown.config.ts index 323d11c7..b1373339 100644 --- a/conformance/tsdown.config.ts +++ b/conformance/tsdown.config.ts @@ -1,6 +1,8 @@ import { defineConfig } from "tsdown"; -// codec.ts is the one module meant to be consumed as a real package (by generate.ts and verify.test.ts today, and potentially by rust/ts's own conformance-check tooling later): built dual ESM/CJS with declarations so it resolves correctly under every module system, not just the one this repo happens to run scripts with. attw verifies that claim directly rather than trusting it. +/** + * codec.ts is the one module meant to be consumed as a real package (by generate.ts and verify.unit.test.ts today, and potentially by rust/ts's own conformance-check tooling later): built dual ESM/CJS with declarations so it resolves correctly under every module system, not just the one this repo happens to run scripts with. attw verifies that claim directly rather than trusting it. + */ export default defineConfig({ entry: ["codec.ts"], format: ["esm", "cjs"], diff --git a/conformance/turbo.json b/conformance/turbo.json index e2793b5a..898b131d 100644 --- a/conformance/turbo.json +++ b/conformance/turbo.json @@ -12,18 +12,18 @@ "inputs": ["generate.ts"], "outputs": ["*.v1.json"] }, + "_lint": { + "dependsOn": ["_build"], + "inputs": ["$TURBO_DEFAULT$", "eslint.config.ts"], + "outputs": [".eslintcache"] + }, "_test": { "dependsOn": ["_build"], - "inputs": ["verify.test.ts", "*.v1.json"] + "inputs": ["verify.unit.test.ts", "*.v1.json"] }, "_typecheck": { "dependsOn": ["_build"], "inputs": ["**/*.ts", "tsconfig.json", "tsconfig.node.json"] - }, - "_lint": { - "dependsOn": ["_build"], - "inputs": ["$TURBO_DEFAULT$", "eslint.config.ts"], - "outputs": [".eslintcache"] } } } diff --git a/conformance/verify.test.ts b/conformance/verify.unit.test.ts similarity index 100% rename from conformance/verify.test.ts rename to conformance/verify.unit.test.ts diff --git a/rust/crates/wire-mesh-conformance/src/main.rs b/rust/crates/wire-mesh-conformance/src/main.rs index 5cb00568..28aeed40 100644 --- a/rust/crates/wire-mesh-conformance/src/main.rs +++ b/rust/crates/wire-mesh-conformance/src/main.rs @@ -1,5 +1,5 @@ //! `conformance-check` — the Rust gate over the frozen conformance -//! vectors, mirroring `conformance/verify.test.ts` and the TS core's +//! vectors, mirroring `conformance/verify.unit.test.ts` and the TS core's //! conformance test in one binary. //! //! Two independent loops per vector: From a82dfbdada7ccff09906b0854dd1b9de116298bb Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sat, 10 Oct 2026 10:13:19 +0100 Subject: [PATCH 3/7] refactor(node): satisfy the eslint-config 3 rules Trailing comments move above the code they describe, consecutive line comments become block comments and statements are separated as the shared configuration requires. - The successor certificates are minted together. Concurrency: independent and side-effect-free. Each start is derived from the last stored certificate and a mint is a key generation with no shared state, so the order the mints run in is not observable. - The WebTransport server start returns its bound port instead of writing a variable it read before awaiting the bind. - Frame polling in tests uses vi.waitFor, and the self-signed certificate override is a stubbed environment variable reset in a finally block. --- .../node/src/adapters/certificate-schedule.ts | 18 ++++++--- .../src/adapters/node-websocket-transport.ts | 21 ++++++++-- .../node/src/adapters/pinned-certificate.ts | 2 + .../src/adapters/webtransport-transport.ts | 11 +++++- ts/packages/node/src/cli-options.ts | 5 +++ ts/packages/node/src/server.ts | 11 ++++-- ts/packages/node/src/static-console.ts | 1 + .../test/cli-entrypoint.integration.test.ts | 1 + ts/packages/node/test/fake-node-websocket.ts | 4 ++ ts/packages/node/test/hex.ts | 2 + .../node/test/mailbox.integration.test.ts | 27 +++++++------ ...de-websocket-transport.integration.test.ts | 24 ++++++------ .../test/relay-end-to-end.integration.test.ts | 38 ++++++++++--------- .../relay-secure-channel.integration.test.ts | 4 ++ ts/packages/node/test/signed-peers.ts | 1 + ts/packages/node/test/tls-cert-fixture.ts | 1 + ...webtransport-transport.integration.test.ts | 3 +- ts/packages/node/tsdown.config.ts | 4 +- 18 files changed, 123 insertions(+), 55 deletions(-) diff --git a/ts/packages/node/src/adapters/certificate-schedule.ts b/ts/packages/node/src/adapters/certificate-schedule.ts index abf9f02c..7f1fdcd9 100644 --- a/ts/packages/node/src/adapters/certificate-schedule.ts +++ b/ts/packages/node/src/adapters/certificate-schedule.ts @@ -35,6 +35,7 @@ function isStoredCertificate(value: unknown): value is StoredCertificate { if (typeof value !== "object" || value === null) { return false; } + return ( "certificatePem" in value && typeof value.certificatePem === "string" && @@ -80,6 +81,7 @@ async function readStored( if (!Array.isArray(parsed) || !parsed.every(isStoredCertificate)) { throw new Error(`the stored ${STORAGE_KEY} are not in the expected shape`); } + return parsed.map(fromStored); } @@ -121,23 +123,29 @@ export async function advanceCertificateSchedule( startedIndex === -1 ? [await mintPinnedCertificate(now, lifetimeMs)] : stillValid.slice(startedIndex); - while (certificates.length < ADVERTISED_CERTIFICATES + 1) { + const missing = ADVERTISED_CERTIFICATES + 1 - certificates.length; + if (missing > 0) { const last = certificates[certificates.length - 1]; if (last === undefined) { throw new Error("the certificate schedule is empty"); } - certificates.push( - await mintPinnedCertificate( - new Date(last.notBefore.getTime() + servingMs), - lifetimeMs, + // Each successor starts one serving period after its predecessor, so every start is known up front and the certificates can be minted together. + const successors = await Promise.all( + Array.from({ length: missing }, async (_, index) => + mintPinnedCertificate( + new Date(last.notBefore.getTime() + servingMs * (index + 1)), + lifetimeMs, + ), ), ); + certificates.push(...successors); } await writeStored(storage, certificates); const [serving, next] = certificates; if (serving === undefined || next === undefined) { throw new Error("the certificate schedule is empty"); } + return { serving, advertised: certificates.slice(0, ADVERTISED_CERTIFICATES), diff --git a/ts/packages/node/src/adapters/node-websocket-transport.ts b/ts/packages/node/src/adapters/node-websocket-transport.ts index a6c03944..f3d17182 100644 --- a/ts/packages/node/src/adapters/node-websocket-transport.ts +++ b/ts/packages/node/src/adapters/node-websocket-transport.ts @@ -23,6 +23,7 @@ const HTTP_NOT_FOUND = 404; function hostPortToWsUrl(address: string): string { const { host, port } = parseAddress(address); + return `ws://${host}:${String(port)}`; } @@ -31,6 +32,7 @@ function parseAddress(address: string): { host: string; port: number } { if (lastColon === -1) { throw new Error(`expected "host:port", got "${address}"`); } + return { host: address.slice(0, lastColon), port: Number(address.slice(lastColon + 1)), @@ -65,6 +67,7 @@ function decodeMessage(data: Uint8Array): Frame { if (!result.success) { throw new SchemaInvalidFrameError(result.error.message); } + return result.data; } @@ -73,6 +76,7 @@ function bytesFromRawData(data: RawData): Uint8Array { if (Array.isArray(data)) { throw new Error("expected a single WebSocket message, got fragments"); } + return new Uint8Array(data); } @@ -106,6 +110,7 @@ export function wrapNodeWebSocket(ws: WebSocket): Connection { // A text message from a confused or hostile client is a protocol violation on this connection, same class as undecodable bytes. failAll(new Error("expected a binary WebSocket message")); ws.close(CLOSE_PROTOCOL_ERROR, "protocol error"); + return; } let frame: Frame; @@ -121,6 +126,7 @@ export function wrapNodeWebSocket(ws: WebSocket): Connection { : new Error(`frame body failed to decode: ${String(error)}`), ); ws.close(CLOSE_PROTOCOL_ERROR, "protocol error"); + return; } const waiter = waiters.shift(); @@ -147,6 +153,7 @@ export function wrapNodeWebSocket(ws: WebSocket): Connection { if (ended) { return { value: undefined, done: true }; } + return new Promise((resolve, reject) => { waiters.push({ resolve, reject }); }); @@ -161,11 +168,13 @@ export function wrapNodeWebSocket(ws: WebSocket): Connection { return Promise.reject(new Error("connection is closed")); } ws.send(messageFromFrame(frame)); + return Promise.resolve(); }, receive: () => receiveStream, async close(): Promise { ws.close(CLOSE_NORMAL); + return Promise.resolve(); }, }; @@ -184,10 +193,10 @@ export function createNodeWebSocketTransport( ): Transport { return { async connect(address): Promise { - // A URL-form address is used verbatim (wss:// included, so a client - // can dial a TLS-fronted hub such as a Workers custom domain); the - // bare host:port form keeps its plain-ws behaviour, matching what - // listen() itself serves. + /* A URL-form address is used verbatim (wss:// included, so a client + can dial a TLS-fronted hub such as a Workers custom domain); the + bare host:port form keeps its plain-ws behaviour, matching what + listen() itself serves. */ const url = /^wss?:\/\/./.test(address); if (!url && !/^[^/]+:\d+$/.test(address)) { throw new Error( @@ -195,6 +204,7 @@ export function createNodeWebSocketTransport( ); } const target = url ? address : hostPortToWsUrl(address); + return new Promise((resolve, reject) => { const ws = new WebSocket(target); ws.once("open", () => { @@ -205,6 +215,7 @@ export function createNodeWebSocketTransport( }, async listen(address, onConnection): Promise { const { host, port } = parseAddress(address); + return new Promise((resolve, reject) => { const requestHandler = ( request: IncomingMessage, @@ -212,6 +223,7 @@ export function createNodeWebSocketTransport( ): void => { if (options.onHttpRequest) { options.onHttpRequest(request, response); + return; } response.writeHead(HTTP_NOT_FOUND).end(); @@ -235,6 +247,7 @@ export function createNodeWebSocketTransport( if (bound === null || typeof bound === "string") { // A TCP server's bound address is always an AddressInfo object; null only if the server were not listening, which cannot hold inside this listening callback. reject(new Error("listener did not report a bound address")); + return; } const listener: Listener = { diff --git a/ts/packages/node/src/adapters/pinned-certificate.ts b/ts/packages/node/src/adapters/pinned-certificate.ts index 094a9ff9..d6cd29e6 100644 --- a/ts/packages/node/src/adapters/pinned-certificate.ts +++ b/ts/packages/node/src/adapters/pinned-certificate.ts @@ -38,6 +38,7 @@ function toPem(label: string, der: Readonly): string { const base64 = Buffer.from(der).toString("base64"); const lines = base64.match(new RegExp(`.{1,${String(PEM_LINE_LENGTH)}}`, "g")) ?? []; + return `-----BEGIN ${label}-----\n${lines.join("\n")}\n-----END ${label}-----\n`; } @@ -70,6 +71,7 @@ export async function mintPinnedCertificate( ], }); const digest = await crypto.subtle.digest("SHA-256", certificate.rawData); + return { certificatePem: certificate.toString("pem"), privateKeyPem: toPem( diff --git a/ts/packages/node/src/adapters/webtransport-transport.ts b/ts/packages/node/src/adapters/webtransport-transport.ts index dafb9c35..5f6b8184 100644 --- a/ts/packages/node/src/adapters/webtransport-transport.ts +++ b/ts/packages/node/src/adapters/webtransport-transport.ts @@ -38,6 +38,7 @@ function reachableHosts(host: string): readonly string[] { if (!WILDCARD_HOSTS.includes(host)) { return [host]; } + return Object.values(networkInterfaces()) .flatMap((addresses) => addresses ?? []) .filter((entry) => entry.family === "IPv4" && !entry.internal) @@ -88,6 +89,7 @@ async function loadHttp3Server(): Promise { await import("@fails-components/webtransport-transport-http3-quiche"); const module = await import("@fails-components/webtransport"); await module.quicheLoaded; + return module.Http3Server; } catch (error) { throw new WebTransportUnavailableError(error); @@ -136,6 +138,7 @@ export function createWebTransportTransport( const reportError = (error: unknown): void => { options.onError?.(error); }; + return { // The package's Node client verifies a server certificate against the system's trust and has no way to pin a hash, so it cannot reach a server that serves a self-signed certificate. A browser is the client of this transport; node peers reach each other over WebSocket. async connect(): Promise { @@ -164,6 +167,8 @@ export function createWebTransportTransport( /** A server this listener is running, and how to retire it. */ interface RunningServer { readonly server: InstanceType; + /** The port the server is bound to, which a later server of the same listener reuses. */ + readonly port: number; /** Refuses every session that arrives from now on, ends the ones open, and resolves once they have finished closing. Stopping a server drops its sessions without telling the client, which would keep a session that no longer works until it next sends; a close lets it reconnect. A client that reconnects the moment it is told would otherwise land on this server, accepted and then dropped silently a moment later, so a session arriving while it drains is closed at once and that client's next attempt reaches the replacement. */ readonly drain: () => Promise; } @@ -183,7 +188,6 @@ export function createWebTransportTransport( if (bound === null) { throw new Error("the WebTransport server bound no address"); } - port = bound.port; const liveSessions = new Set(); const drainState = { draining: false }; const endSession = (session: IncomingSession): void => { @@ -205,6 +209,7 @@ export function createWebTransportTransport( await session.ready; if (drainState.draining) { endSession(session); + return; } liveSessions.add(session); @@ -236,8 +241,10 @@ export function createWebTransportTransport( })().catch(reportError); } })().catch(reportError); + return { server: started, + port: bound.port, drain: async () => { drainState.draining = true; const open = [...liveSessions]; @@ -252,6 +259,7 @@ export function createWebTransportTransport( }; let running: RunningServer | undefined = await startServer(); + port = running.port; const advertise = (): readonly string[] => reachableHosts(host).map((reachable) => formatPinnedAddress( @@ -289,6 +297,7 @@ export function createWebTransportTransport( return; } running = await startServer(); + port = running.port; options.onCertificateRenewed?.(advertise()); })().then( () => { diff --git a/ts/packages/node/src/cli-options.ts b/ts/packages/node/src/cli-options.ts index bcab9f25..1f177ffd 100644 --- a/ts/packages/node/src/cli-options.ts +++ b/ts/packages/node/src/cli-options.ts @@ -102,6 +102,7 @@ export class CliUsageError extends Error { function usageLabelOf(name: string, definition: FlagDefinition): string { const long = `--${name}${definition.valueName === undefined ? "" : ` <${definition.valueName}>`}`; + return definition.short === undefined ? ` ${long}` : `-${definition.short}, ${long}`; @@ -119,6 +120,7 @@ export function helpText(): string { }), ); const labelWidth = Math.max(...rows.map((row) => row.label.length)); + return [ "Usage: wire-mesh [options]", "", @@ -145,6 +147,7 @@ function validatedCertificateLifetimeMs( `--certificate-lifetime expects a whole number of seconds from 1 to ${String(PINNED_CERTIFICATE_LIFETIME_MS / MILLISECONDS_PER_SECOND)}, got "${seconds}"`, ); } + return lifetimeMs; } @@ -162,6 +165,7 @@ function validatedBindAddress(flag: string, address: string): string { `--${flag} expects host:port with a port from 0 to ${String(MAX_PORT)}, got "${address}"`, ); } + return address; } @@ -203,6 +207,7 @@ export function parseCliArguments(argv: readonly string[]): CliCommand { ) { throw new CliUsageError("--certificate-lifetime needs --webtransport"); } + return { kind: "serve", bindAddress: validatedBindAddress("bind", values.bind), diff --git a/ts/packages/node/src/server.ts b/ts/packages/node/src/server.ts index 19fe0b4b..79d0af69 100644 --- a/ts/packages/node/src/server.ts +++ b/ts/packages/node/src/server.ts @@ -62,6 +62,7 @@ function readPackageVersion(): string { `no string "version" in ${fileURLToPath(PACKAGE_MANIFEST_URL)}`, ); } + return manifest.version; } @@ -75,6 +76,7 @@ export function createHttpRequestHandler( if (url.pathname === HEALTH_PATH) { response.writeHead(HTTP_OK, { "content-type": "application/json" }); response.end(JSON.stringify(healthResponse(announcer))); + return; } const file = resolveConsoleFile(consoleDir, url.pathname); @@ -84,6 +86,7 @@ export function createHttpRequestHandler( ...file.headers, }); response.end(file.body); + return; } response.writeHead(HTTP_NOT_FOUND).end(); @@ -103,10 +106,12 @@ async function main(argv: readonly string[]): Promise { const command = parseCliArguments(argv); if (command.kind === "help") { logOutput(helpText()); + return; } if (command.kind === "version") { logOutput(readPackageVersion()); + return; } @@ -189,9 +194,9 @@ async function run(argv: readonly string[]): Promise { } } -// Only run as a side effect when executed directly (the CLI bin entry), never on a plain import, which is how the test suite reaches healthResponse() without binding a real port. -// -// Both sides are compared as resolved real paths. A package manager installs a bin as node_modules/.bin/wire-mesh symlinked at this file, and that is the path argv[1] carries, while Node resolves symlinks before recording import.meta.url, so comparing the two as written strings never matches when the CLI is invoked the way anyone actually invokes it, and the process would exit 0 having started nothing. fileURLToPath rather than a "file://" prefix for the same class of reason: it undoes the percent-encoding import.meta.url applies to a path containing a space or a hash. +/* Only run as a side effect when executed directly (the CLI bin entry), never on a plain import, which is how the test suite reaches healthResponse() without binding a real port. + + Both sides are compared as resolved real paths. A package manager installs a bin as node_modules/.bin/wire-mesh symlinked at this file, and that is the path argv[1] carries, while Node resolves symlinks before recording import.meta.url, so comparing the two as written strings never matches when the CLI is invoked the way anyone actually invokes it, and the process would exit 0 having started nothing. fileURLToPath rather than a "file://" prefix for the same class of reason: it undoes the percent-encoding import.meta.url applies to a path containing a space or a hash. */ const invokedPath = process.argv[1]; if ( invokedPath !== undefined && diff --git a/ts/packages/node/src/static-console.ts b/ts/packages/node/src/static-console.ts index 3cd12d8d..0faf81bb 100644 --- a/ts/packages/node/src/static-console.ts +++ b/ts/packages/node/src/static-console.ts @@ -60,6 +60,7 @@ export function resolveConsoleFile( const contentType = CONTENT_TYPES[extname(filePath)] ?? DEFAULT_CONTENT_TYPE; const body = readFileSync(filePath); + return { body, contentType, diff --git a/ts/packages/node/test/cli-entrypoint.integration.test.ts b/ts/packages/node/test/cli-entrypoint.integration.test.ts index 0efee73c..b95e4126 100644 --- a/ts/packages/node/test/cli-entrypoint.integration.test.ts +++ b/ts/packages/node/test/cli-entrypoint.integration.test.ts @@ -158,6 +158,7 @@ async function packageVersion(): Promise { ) { throw new Error("package.json has no string version"); } + return manifest.version; } diff --git a/ts/packages/node/test/fake-node-websocket.ts b/ts/packages/node/test/fake-node-websocket.ts index 7d9122d7..4c4c7346 100644 --- a/ts/packages/node/test/fake-node-websocket.ts +++ b/ts/packages/node/test/fake-node-websocket.ts @@ -4,14 +4,18 @@ type Listener = (...args: readonly unknown[]) => void; export class FakeNodeWebSocket { sent: (string | Uint8Array)[] = []; + closed = false; + closeCode: number | undefined; + private readonly listeners = new Map(); on(type: string, listener: Listener): this { const list = this.listeners.get(type) ?? []; list.push(listener); this.listeners.set(type, list); + return this; } diff --git a/ts/packages/node/test/hex.ts b/ts/packages/node/test/hex.ts index f07c8dd3..f385f4e4 100644 --- a/ts/packages/node/test/hex.ts +++ b/ts/packages/node/test/hex.ts @@ -11,6 +11,7 @@ export function bytesFromHex(hex: string): Uint8Array { HEX_RADIX, ); } + return out; } @@ -18,5 +19,6 @@ export function bytesFromHex(hex: string): Uint8Array { export function deviceIdFromFillHex(fillHex: string): Uint8Array { const out = new Uint8Array(SHA256_BYTE_LENGTH); out.fill(Number.parseInt(fillHex, HEX_RADIX)); + return out; } diff --git a/ts/packages/node/test/mailbox.integration.test.ts b/ts/packages/node/test/mailbox.integration.test.ts index 410a6c6c..f603fc61 100644 --- a/ts/packages/node/test/mailbox.integration.test.ts +++ b/ts/packages/node/test/mailbox.integration.test.ts @@ -3,7 +3,7 @@ import { mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { afterEach, describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import { createNodeFsStorage } from "wire-mesh-core/adapters/node-fs-storage"; import { createMailbox } from "wire-mesh-core/domain/hub-mailbox"; import { createRelayHub } from "wire-mesh-core/domain/relay-hub"; @@ -33,19 +33,23 @@ async function client(address: string): Promise<{ void (async () => { for await (const frame of connection.receive()) received.push(frame); })(); + return { connection, async next(type) { - const deadline = Date.now() + FRAME_WAIT_TIMEOUT_MS; - for (;;) { - const index = received.findIndex((frame) => frame.type === type); - const [frame] = index === -1 ? [] : received.splice(index, 1); - if (frame !== undefined) return frame; - if (Date.now() > deadline) throw new Error(`no ${type} frame arrived`); - await new Promise((resolve) => { - setTimeout(resolve, FRAME_POLL_INTERVAL_MS); - }); - } + return vi.waitFor( + () => { + const index = received.findIndex((frame) => frame.type === type); + const [frame] = index === -1 ? [] : received.splice(index, 1); + if (frame === undefined) throw new Error(`no ${type} frame arrived`); + + return frame; + }, + { + timeout: FRAME_WAIT_TIMEOUT_MS, + interval: FRAME_POLL_INTERVAL_MS, + }, + ); }, }; } @@ -60,6 +64,7 @@ async function serve( limits: nodeMailboxLimits, }), }); + return createNodeWebSocketTransport().listen("127.0.0.1:0", (connection) => { void hub.handleConnection(connection); }); diff --git a/ts/packages/node/test/node-websocket-transport.integration.test.ts b/ts/packages/node/test/node-websocket-transport.integration.test.ts index 31a5e207..9bf8ff41 100644 --- a/ts/packages/node/test/node-websocket-transport.integration.test.ts +++ b/ts/packages/node/test/node-websocket-transport.integration.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { encode } from "cbor2"; import type { Frame } from "wire-mesh-core/generated/protocol"; import { WebSocket as WsClient, type WebSocket } from "ws"; @@ -12,10 +12,13 @@ import { bytesFromHex } from "./hex.js"; import { generateCertFixture } from "./tls-cert-fixture.js"; const SHA256_BYTE_LENGTH = 32; -const COSE_ALG_EDDSA = -8; // the identity-key.alg value for an Ed25519 key; this advert is only encoded and decoded, never verified, so the value only has to be well-formed +// the identity-key.alg value for an Ed25519 key; this advert is only encoded and decoded, never verified, so the value only has to be well-formed +const COSE_ALG_EDDSA = -8; const ping: Frame = { type: "ping" }; -const CBOR_MAP_ONE_ENTRY_FIRST_BYTE = 0xa1; // a one-entry CBOR map head -- the ping frame, no length prefix -const CBOR_BREAK_BYTE = 0xff; // the CBOR break byte on its own: undecodable as a complete value +// a one-entry CBOR map head -- the ping frame, no length prefix +const CBOR_MAP_ONE_ENTRY_FIRST_BYTE = 0xa1; +// the CBOR break byte on its own: undecodable as a complete value +const CBOR_BREAK_BYTE = 0xff; const CLOSE_PROTOCOL_ERROR = 1002; const HTTP_OK = 200; const POLL_INTERVAL_MS = 10; @@ -25,6 +28,7 @@ async function collect(iterable: Readonly>): Promise { for await (const item of iterable) { out.push(item); } + return out; } @@ -154,6 +158,7 @@ describe("connect with a URL-form address", () => { const check = (): void => { if (received.length > 0) { resolve(); + return; } setTimeout(check, POLL_INTERVAL_MS); @@ -197,6 +202,7 @@ describe("createNodeWebSocketTransport, a real loopback round trip", () => { const check = (): void => { if (received.length > 0) { resolve(); + return; } setTimeout(check, POLL_INTERVAL_MS); @@ -261,6 +267,7 @@ describe("createNodeWebSocketTransport with a tls identity", () => { const check = (): void => { if (received.length > 0) { resolve(); + return; } setTimeout(check, POLL_INTERVAL_MS); @@ -288,18 +295,13 @@ describe("createNodeWebSocketTransport with a tls identity", () => { const listener = await transport.listen("127.0.0.1:0", () => undefined); // Node's global fetch rejects the fixture's self-signed cert by default; this env var is the standard, narrowly-scoped way to bypass that for a single call in a test — restored immediately after, never left set for the rest of the suite. - const original = process.env.NODE_TLS_REJECT_UNAUTHORIZED; - process.env.NODE_TLS_REJECT_UNAUTHORIZED = "0"; + vi.stubEnv("NODE_TLS_REJECT_UNAUTHORIZED", "0"); try { const response = await fetch(`https://${listener.address}/`); expect(response.status).toBe(HTTP_OK); expect(await response.json()).toEqual({ ok: true }); } finally { - if (original === undefined) { - delete process.env.NODE_TLS_REJECT_UNAUTHORIZED; - } else { - process.env.NODE_TLS_REJECT_UNAUTHORIZED = original; - } + vi.unstubAllEnvs(); } await listener.close(); diff --git a/ts/packages/node/test/relay-end-to-end.integration.test.ts b/ts/packages/node/test/relay-end-to-end.integration.test.ts index dfd2108a..ce1dba1d 100644 --- a/ts/packages/node/test/relay-end-to-end.integration.test.ts +++ b/ts/packages/node/test/relay-end-to-end.integration.test.ts @@ -1,6 +1,6 @@ // Real, in-suite equivalent of cloudflare-hub/scripts/live-check.mjs: two genuine wire-mesh clients gossiping, relay-connecting, and exchanging relay-data through a real createRelayHub() wired over a real createNodeWebSocketTransport() listener -- the same health-check wiring server.ts itself uses, so this test also proves the plain-HTTP health path works on the same listener a WebSocket client connects to. -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { createRelayHub } from "wire-mesh-core/domain/relay-hub"; import type { Frame } from "wire-mesh-core/generated/protocol"; import { createNodeWebSocketTransport } from "../src/adapters/node-websocket-transport.js"; @@ -20,29 +20,31 @@ interface FrameQueue { function createFrameQueue(): FrameQueue { const frames: Frame[] = []; + return { push(frame) { frames.push(frame); }, async waitFor(expectedType) { - const started = Date.now(); - for (;;) { - const index = frames.findIndex((frame) => frame.type === expectedType); - if (index !== -1) { - const [frame] = frames.splice(index, 1); - if (frame) { - return frame; - } - } - if (Date.now() - started > FRAME_WAIT_TIMEOUT_MS) { - throw new Error( - `timed out waiting for ${expectedType}; queue holds ${JSON.stringify(frames.map((frame) => frame.type))}`, + return vi.waitFor( + () => { + const index = frames.findIndex( + (frame) => frame.type === expectedType, ); - } - await new Promise((resolve) => { - setTimeout(resolve, FRAME_POLL_INTERVAL_MS); - }); - } + const [frame] = index === -1 ? [] : frames.splice(index, 1); + if (frame === undefined) { + throw new Error( + `timed out waiting for ${expectedType}; queue holds ${JSON.stringify(frames.map((queued) => queued.type))}`, + ); + } + + return frame; + }, + { + timeout: FRAME_WAIT_TIMEOUT_MS, + interval: FRAME_POLL_INTERVAL_MS, + }, + ); }, }; } diff --git a/ts/packages/node/test/relay-secure-channel.integration.test.ts b/ts/packages/node/test/relay-secure-channel.integration.test.ts index e780876a..c1ccd6cd 100644 --- a/ts/packages/node/test/relay-secure-channel.integration.test.ts +++ b/ts/packages/node/test/relay-secure-channel.integration.test.ts @@ -79,6 +79,7 @@ async function twoSessionsThroughAHub(tamper: (frame: Frame) => Frame) { await new Promise((resolve) => { setTimeout(resolve, GOSSIP_SETTLE_MS); }); + return { seen, peerA, peerB, sessionA, sessionB, listener }; } @@ -89,8 +90,10 @@ describe("a request through a relay, end to end", () => { const received = (async () => { for await (const request of sessionB.incomingManageRequests) { await request.respond({ result: "ok" }); + return request; } + return undefined; })(); @@ -129,6 +132,7 @@ describe("a request through a relay, end to end", () => { if (inner?.type !== "secure-data") return frame; const ciphertext = Uint8Array.from(inner.ciphertext); ciphertext[0] = (ciphertext[0] ?? 0) ^ LOW_BYTE_MASK; + return { ...frame, payload: messageFromFrame({ ...inner, ciphertext }), diff --git a/ts/packages/node/test/signed-peers.ts b/ts/packages/node/test/signed-peers.ts index df67a564..8ecdb107 100644 --- a/ts/packages/node/test/signed-peers.ts +++ b/ts/packages/node/test/signed-peers.ts @@ -59,6 +59,7 @@ export async function createTestPeer(): Promise { "snapshot-seconds": FIXTURE_SNAPSHOT_SECONDS, "identity-key": identity.identityKey, }); + return { identity, device: identity.deviceId, diff --git a/ts/packages/node/test/tls-cert-fixture.ts b/ts/packages/node/test/tls-cert-fixture.ts index 652f8b97..d5b19808 100644 --- a/ts/packages/node/test/tls-cert-fixture.ts +++ b/ts/packages/node/test/tls-cert-fixture.ts @@ -52,6 +52,7 @@ export function generateCertFixture(): CertFixture { ...Buffer.from(jwk.x, "base64url"), ...Buffer.from(jwk.y, "base64url"), ]); + return { certificatePem, privateKeyPem, rawPublicKey }; } finally { rmSync(dir, { recursive: true, force: true }); diff --git a/ts/packages/node/test/webtransport-transport.integration.test.ts b/ts/packages/node/test/webtransport-transport.integration.test.ts index e5e1e5fa..5f6b2074 100644 --- a/ts/packages/node/test/webtransport-transport.integration.test.ts +++ b/ts/packages/node/test/webtransport-transport.integration.test.ts @@ -39,8 +39,9 @@ describe("createWebTransportTransport", () => { afterEach(async () => { vi.useRealTimers(); - await listener?.close(); + const closing = listener; listener = undefined; + await closing?.close(); }); it( diff --git a/ts/packages/node/tsdown.config.ts b/ts/packages/node/tsdown.config.ts index 20cbc1fc..6eaf6bfc 100644 --- a/ts/packages/node/tsdown.config.ts +++ b/ts/packages/node/tsdown.config.ts @@ -1,6 +1,8 @@ import { defineConfig } from "tsdown"; -// Every real module gets its own build entry rather than a re-exporting index.ts -- the barrel-policy lint rule requires importing straight from the module that owns each export. +/** + * Every real module gets its own build entry rather than a re-exporting index.ts -- the barrel-policy lint rule requires importing straight from the module that owns each export. + */ export default defineConfig({ entry: ["src/adapters/node-websocket-transport.ts", "src/server.ts"], format: ["esm", "cjs"], From 6d8c1e8e735eaa001d24239ccc0b46fd008690ef Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sat, 10 Oct 2026 10:13:20 +0100 Subject: [PATCH 4/7] refactor(cloudflare-hub): satisfy the eslint-config 3 rules Trailing comments move above the code they describe, consecutive line comments become block comments and statements are separated as the shared configuration requires. The fake socket stream in a test is drained by recursion. No concurrency change. --- .../src/adapters/durable-object-storage.ts | 1 + .../src/adapters/web-crypto-identity.ts | 2 ++ .../src/adapters/websocket-transport.ts | 10 +++++- .../cloudflare-hub/src/advert-policy.ts | 8 +++-- .../cloudflare-hub/src/hibernating-hub.ts | 20 +++++++----- ts/packages/cloudflare-hub/src/worker.ts | 13 +++++--- .../test/advert-policy.unit.test.ts | 1 + .../test/durable-object-storage.unit.test.ts | 2 ++ .../cloudflare-hub/test/fake-web-socket.ts | 3 ++ ts/packages/cloudflare-hub/test/hex.ts | 2 ++ .../test/hibernation.integration.test.ts | 4 +++ .../test/hub.integration.test.ts | 31 ++++++++++++------- .../test/mailbox.integration.test.ts | 10 ++++++ .../cloudflare-hub/test/signed-peers.ts | 1 + .../test/web-crypto-identity.unit.test.ts | 10 +++--- .../websocket-transport.integration.test.ts | 7 +++-- 16 files changed, 91 insertions(+), 34 deletions(-) diff --git a/ts/packages/cloudflare-hub/src/adapters/durable-object-storage.ts b/ts/packages/cloudflare-hub/src/adapters/durable-object-storage.ts index 442b234d..d5183de5 100644 --- a/ts/packages/cloudflare-hub/src/adapters/durable-object-storage.ts +++ b/ts/packages/cloudflare-hub/src/adapters/durable-object-storage.ts @@ -26,6 +26,7 @@ export function durableObjectStorage( if (!isBytes(value)) { throw new Error(`storage value at ${key} is not bytes`); } + return value; }, async set(key, value) { diff --git a/ts/packages/cloudflare-hub/src/adapters/web-crypto-identity.ts b/ts/packages/cloudflare-hub/src/adapters/web-crypto-identity.ts index 635dad34..d8c3964f 100644 --- a/ts/packages/cloudflare-hub/src/adapters/web-crypto-identity.ts +++ b/ts/packages/cloudflare-hub/src/adapters/web-crypto-identity.ts @@ -55,6 +55,7 @@ export async function verifyWithPublicKey( signature: Uint8Array, ): Promise { const cryptoKey = await importPublicKey(key); + return crypto.subtle.verify( algParams(key.alg), cryptoKey, @@ -88,6 +89,7 @@ export async function createWebCryptoIdentity(): Promise { keyPair.privateKey, toBufferSource(message), ); + return new Uint8Array(signature); }, verify: verifyWithPublicKey, diff --git a/ts/packages/cloudflare-hub/src/adapters/websocket-transport.ts b/ts/packages/cloudflare-hub/src/adapters/websocket-transport.ts index 8009e419..95c8b16b 100644 --- a/ts/packages/cloudflare-hub/src/adapters/websocket-transport.ts +++ b/ts/packages/cloudflare-hub/src/adapters/websocket-transport.ts @@ -4,7 +4,9 @@ import { cdeDecodeOptions, cdeEncodeOptions, decode, encode } from "cbor2"; import { frameSchema, type Frame } from "wire-mesh-core/generated/protocol"; import type { Connection } from "wire-mesh-core/ports/transport"; -// RFC 6455 close codes, named rather than bare: 1000 normal closure, 1002 protocol error. +/** + * RFC 6455 close codes, named rather than bare: 1000 normal closure, 1002 protocol error. + */ export const CLOSE_NORMAL = 1000; export const CLOSE_PROTOCOL_ERROR = 1002; @@ -37,6 +39,7 @@ export function decodeMessage(data: Readonly): Frame { if (!result.success) { throw new SchemaInvalidFrameError(result.error.message); } + return result.data; } @@ -69,6 +72,7 @@ export function wrapWebSocket(ws: Readonly): Connection { // Only binary messages carry frames; a text message from a confused or hostile client is a protocol violation on this connection, same class as undecodable bytes failAll(new Error("expected a binary WebSocket message")); ws.close(CLOSE_PROTOCOL_ERROR, "protocol error"); + return; } let frame: Frame; @@ -84,6 +88,7 @@ export function wrapWebSocket(ws: Readonly): Connection { : new Error(`frame body failed to decode: ${String(error)}`), ); ws.close(CLOSE_PROTOCOL_ERROR, "protocol error"); + return; } const waiter = waiters.shift(); @@ -110,6 +115,7 @@ export function wrapWebSocket(ws: Readonly): Connection { if (ended) { return { value: undefined, done: true }; } + return new Promise((resolve, reject) => { waiters.push({ resolve, reject }); }); @@ -125,11 +131,13 @@ export function wrapWebSocket(ws: Readonly): Connection { return Promise.reject(new Error("connection is closed")); } ws.send(messageFromFrame(frame)); + return Promise.resolve(); }, receive: () => receiveStream, async close(): Promise { ws.close(CLOSE_NORMAL); + return Promise.resolve(); }, }; diff --git a/ts/packages/cloudflare-hub/src/advert-policy.ts b/ts/packages/cloudflare-hub/src/advert-policy.ts index 04b8552f..ecd3bbc2 100644 --- a/ts/packages/cloudflare-hub/src/advert-policy.ts +++ b/ts/packages/cloudflare-hub/src/advert-policy.ts @@ -1,6 +1,6 @@ -// What the public hub carries of a peer-advert's extension tail. Anyone can connect to this hub and it forwards every advert whole to every client (an advert is signed, so it cannot be trimmed on the way through), so anything an application puts in its tail is published to strangers. The hub therefore carries only the extension entries listed here, each held to an exact shape, and refuses an advert with anything else. -// -// The entries mirror what agent-comms publishes to a hub session: an agent's name and harness, its user principal's membership proof, its presence, the public rooms it hosts by path and name, and the package versions it runs. What it deliberately does not carry is the rest of what a bridge knows about itself (its working directory, process id, joined rooms, tags, private rooms and room descriptions), which is for peers it has chosen to trust and reaches them over a session of their own. The shapes are duplicated here rather than imported because agent-comms depends on wire-mesh and not the other way round; registering them properly is wire-mesh#242. +/* What the public hub carries of a peer-advert's extension tail. Anyone can connect to this hub and it forwards every advert whole to every client (an advert is signed, so it cannot be trimmed on the way through), so anything an application puts in its tail is published to strangers. The hub therefore carries only the extension entries listed here, each held to an exact shape, and refuses an advert with anything else. + + The entries mirror what agent-comms publishes to a hub session: an agent's name and harness, its user principal's membership proof, its presence, the public rooms it hosts by path and name, and the package versions it runs. What it deliberately does not carry is the rest of what a bridge knows about itself (its working directory, process id, joined rooms, tags, private rooms and room descriptions), which is for peers it has chosen to trust and reaches them over a session of their own. The shapes are duplicated here rather than imported because agent-comms depends on wire-mesh and not the other way round; registering them properly is wire-mesh#242. */ import type { AdvertExtensionPolicy, @@ -21,6 +21,7 @@ function exactStrings( const known = new Set([...required, ...optional]); if (Object.keys(value).some((key) => !known.has(key))) return false; if (required.some((key) => typeof value[key] !== "string")) return false; + return optional.every( (key) => !(key in value) || typeof value[key] === "string", ); @@ -30,6 +31,7 @@ function exactStrings( /** A list of public rooms, each with exactly a path and a name. A room description is not allowed: a project room's default one names the directory it was made for. */ const isPublicRoomList: ExtensionValidator = (value) => { const roomShape = exactStrings(["path", "name", "type"], []); + return ( Array.isArray(value) && value.every( diff --git a/ts/packages/cloudflare-hub/src/hibernating-hub.ts b/ts/packages/cloudflare-hub/src/hibernating-hub.ts index 8ee0f245..e7320c26 100644 --- a/ts/packages/cloudflare-hub/src/hibernating-hub.ts +++ b/ts/packages/cloudflare-hub/src/hibernating-hub.ts @@ -1,10 +1,10 @@ -// The relay hub bound to hibernating server sockets, kept out of worker.ts so it can be exercised without the `cloudflare:workers` module a Durable Object class has to import. Everything here is expressed against HubSocket, the slice of the runtime's own socket this hub actually touches, so a test drives the identical code path with a fake socket and the Durable Object simply forwards its handler arguments. -// -// Surviving eviction (wire-mesh#223): a Durable Object instance is discarded after a short idle period while its sockets stay open, and the next message runs the constructor again on an instance holding nothing. The hub's own registry and pairings are ordinary instance state and go with it, so before this module existed a woken instance saw a relay-data frame on a pairing it had never heard of and dropped it. That is invisible to both clients: neither socket closed, so neither has any reason to re-establish anything, and the sender simply never gets its answer. It bit exactly the case a relay is most useful for, a request whose response waits on a person rather than a machine, since anything answered within a few seconds beats the eviction and anything slower does not. -// -// The fix keeps each connection's relay state on its own socket, through serializeAttachment, and adopt() reassembles the hub from those attachments on first use. An attachment was chosen over Durable Object storage for three reasons, all about lifetime rather than capacity. The runtime discards an attachment when its socket closes, which is exactly when the state stops being meaningful, so nothing has to be swept and a client that goes away while no instance is running leaves nothing behind. It is read synchronously, so a woken instance has the hub whole before it routes the frame that woke it, with no await between arrival and routing. And a socket is the only thing that survives eviction with an identity, so a storage row would still have needed an attachment to say which row belonged to which socket. The 16KiB an attachment holds is far beyond a device-id, a handful of adverts, and a pairing list, which is the entire payload. -// -// Adoption is ordered, not incremental, because a pairing names its peer by device-id and the peer is a socket this instance has yet to look at: every survivor is registered first, then restoreConnections resolves pairings across the whole batch at once. A pairing whose peer is absent from that batch is genuinely gone, and is dropped rather than half-restored. The legacy unaddressed-relay-data route rides along in the same attachment as mostRecentDevice, so a client that omits to-device keeps reaching the same peer it did before the eviction. +/* The relay hub bound to hibernating server sockets, kept out of worker.ts so it can be exercised without the `cloudflare:workers` module a Durable Object class has to import. Everything here is expressed against HubSocket, the slice of the runtime's own socket this hub actually touches, so a test drives the identical code path with a fake socket and the Durable Object simply forwards its handler arguments. + + Surviving eviction (wire-mesh#223): a Durable Object instance is discarded after a short idle period while its sockets stay open, and the next message runs the constructor again on an instance holding nothing. The hub's own registry and pairings are ordinary instance state and go with it, so before this module existed a woken instance saw a relay-data frame on a pairing it had never heard of and dropped it. That is invisible to both clients: neither socket closed, so neither has any reason to re-establish anything, and the sender simply never gets its answer. It bit exactly the case a relay is most useful for, a request whose response waits on a person rather than a machine, since anything answered within a few seconds beats the eviction and anything slower does not. + + The fix keeps each connection's relay state on its own socket, through serializeAttachment, and adopt() reassembles the hub from those attachments on first use. An attachment was chosen over Durable Object storage for three reasons, all about lifetime rather than capacity. The runtime discards an attachment when its socket closes, which is exactly when the state stops being meaningful, so nothing has to be swept and a client that goes away while no instance is running leaves nothing behind. It is read synchronously, so a woken instance has the hub whole before it routes the frame that woke it, with no await between arrival and routing. And a socket is the only thing that survives eviction with an identity, so a storage row would still have needed an attachment to say which row belonged to which socket. The 16KiB an attachment holds is far beyond a device-id, a handful of adverts, and a pairing list, which is the entire payload. + + Adoption is ordered, not incremental, because a pairing names its peer by device-id and the peer is a socket this instance has yet to look at: every survivor is registered first, then restoreConnections resolves pairings across the whole batch at once. A pairing whose peer is absent from that batch is genuinely gone, and is dropped rather than half-restored. The legacy unaddressed-relay-data route rides along in the same attachment as mostRecentDevice, so a client that omits to-device keeps reaching the same peer it did before the eviction. */ import { z } from "zod"; import { publicHubAdvertPolicy } from "./advert-policy.js"; @@ -57,6 +57,7 @@ function attachedState( return undefined; } const { device, adverts, pairedDevices, mostRecentDevice } = parsed.data; + return { device, adverts, @@ -70,10 +71,12 @@ function sendOnlyConnection(socket: Readonly): Connection { return { async send(frame: Frame): Promise { socket.send(messageFromFrame(frame)); + return Promise.resolve(); }, async close(): Promise { socket.close(CLOSE_NORMAL, "hub closing connection"); + return Promise.resolve(); }, receive: () => { @@ -148,6 +151,7 @@ export function createHibernatingRelayHub( connections.set(socket, connection); sockets.set(connection, socket); hub.registerConnection(connection); + return connection; } @@ -190,6 +194,7 @@ export function createHibernatingRelayHub( // Only binary messages carry frames; a text message from a confused or hostile client is a protocol violation, same class as undecodable bytes, so the socket closes rather than anything being relayed. socket.close(CLOSE_PROTOCOL_ERROR, "protocol error"); forget(socket); + return; } let frame: Frame; @@ -202,6 +207,7 @@ export function createHibernatingRelayHub( } socket.close(CLOSE_PROTOCOL_ERROR, "protocol error"); forget(socket); + return; } await hub.onFrame(connectionFor(socket), frame); diff --git a/ts/packages/cloudflare-hub/src/worker.ts b/ts/packages/cloudflare-hub/src/worker.ts index 75cda2ed..8dc7ee5d 100644 --- a/ts/packages/cloudflare-hub/src/worker.ts +++ b/ts/packages/cloudflare-hub/src/worker.ts @@ -1,8 +1,8 @@ -// The Worker entry. The Durable Object class must be exported from this entrypoint file for wrangler to bind it (wrangler.toml names this export), so it is defined here directly rather than re-exported from a sibling module. The class itself is only an adapter: the runtime's three WebSocket handlers forward straight to hibernating-hub.ts, which holds everything the hub actually does and needs no `cloudflare:workers` import to be tested. -// -// Hibernation-native by necessity: the original shape parked a JS consume loop (handleConnection over the adapter's receive() iteration) on classically-accepted sockets, which worked under `wrangler dev` but silently processed nothing in production. After fetch() returned the 101, the real runtime never delivered further messages into that parked promise chain, so every inbound frame after connect was dropped (the first production live-check run failed exactly there: connects and upgrades fine, zero frames ever forwarded). The hibernation API is the documented production shape: state.acceptWebSocket hands the socket to the runtime, and inbound messages arrive as webSocketMessage handler invocations, event-driven, with no parked loop, and the socket itself survives DO eviction. The hub is driven per-message via its registerConnection/onFrame/onDisconnect surface (the same event-driven entry wire-mesh#102 added for ordinary relaying nodes). -// -// Eviction honesty: no instance field survives eviction, only the sockets, via hibernation. The hub therefore keeps every connection's relay state (its device-id, its adverts, its pairings) on the socket that state belongs to, through serializeAttachment, and rebuilds itself from ctx.getWebSockets() the first time a woken instance is asked to do anything. Both the device registry and the relay pairings come back that way, so a client whose socket stayed open through an eviction needs to do nothing and notice nothing: its next frame routes exactly as it would have. See hibernating-hub.ts for why an attachment rather than Durable Object storage holds it, and why adoption resolves the whole set of sockets at once rather than one at a time. +/* The Worker entry. The Durable Object class must be exported from this entrypoint file for wrangler to bind it (wrangler.toml names this export), so it is defined here directly rather than re-exported from a sibling module. The class itself is only an adapter: the runtime's three WebSocket handlers forward straight to hibernating-hub.ts, which holds everything the hub actually does and needs no `cloudflare:workers` import to be tested. + + Hibernation-native by necessity: the original shape parked a JS consume loop (handleConnection over the adapter's receive() iteration) on classically-accepted sockets, which worked under `wrangler dev` but silently processed nothing in production. After fetch() returned the 101, the real runtime never delivered further messages into that parked promise chain, so every inbound frame after connect was dropped (the first production live-check run failed exactly there: connects and upgrades fine, zero frames ever forwarded). The hibernation API is the documented production shape: state.acceptWebSocket hands the socket to the runtime, and inbound messages arrive as webSocketMessage handler invocations, event-driven, with no parked loop, and the socket itself survives DO eviction. The hub is driven per-message via its registerConnection/onFrame/onDisconnect surface (the same event-driven entry wire-mesh#102 added for ordinary relaying nodes). + + Eviction honesty: no instance field survives eviction, only the sockets, via hibernation. The hub therefore keeps every connection's relay state (its device-id, its adverts, its pairings) on the socket that state belongs to, through serializeAttachment, and rebuilds itself from ctx.getWebSockets() the first time a woken instance is asked to do anything. Both the device registry and the relay pairings come back that way, so a client whose socket stayed open through an eviction needs to do nothing and notice nothing: its next frame routes exactly as it would have. See hibernating-hub.ts for why an attachment rather than Durable Object storage holds it, and why adoption resolves the whole set of sockets at once rather than one at a time. */ import { DurableObject } from "cloudflare:workers"; import { createMailbox } from "wire-mesh-core/domain/hub-mailbox"; @@ -37,6 +37,7 @@ export class RelayHubDurableObject extends DurableObject { // Hibernation accept: the runtime owns delivery (webSocketMessage below) and the socket survives eviction, unlike classic accept. this.ctx.acceptWebSocket(pair[1]); this.hub.accept(pair[1]); + return new Response(null, { status: 101, webSocket: pair[0] }); } @@ -68,12 +69,14 @@ export default { async fetch(request: Request, env: Env): Promise { if (request.headers.get("Upgrade") === "websocket") { const stub = env.HUB.get(env.HUB.idFromName(HUB_INSTANCE_NAME)); + return stub.fetch(request); } const { pathname } = new URL(request.url); if (pathname === "/health") { return healthResponse(); } + return env.ASSETS.fetch(request); }, } satisfies ExportedHandler; diff --git a/ts/packages/cloudflare-hub/test/advert-policy.unit.test.ts b/ts/packages/cloudflare-hub/test/advert-policy.unit.test.ts index 5b1df776..0440b1f9 100644 --- a/ts/packages/cloudflare-hub/test/advert-policy.unit.test.ts +++ b/ts/packages/cloudflare-hub/test/advert-policy.unit.test.ts @@ -11,6 +11,7 @@ async function advertWith( extensions: Record, ): Promise { const identity = await createWebCryptoIdentity(); + return signPeerAdvert(identity, { device: identity.deviceId, addresses: ["203.0.113.5:4433"], diff --git a/ts/packages/cloudflare-hub/test/durable-object-storage.unit.test.ts b/ts/packages/cloudflare-hub/test/durable-object-storage.unit.test.ts index 75c7c982..2e585746 100644 --- a/ts/packages/cloudflare-hub/test/durable-object-storage.unit.test.ts +++ b/ts/packages/cloudflare-hub/test/durable-object-storage.unit.test.ts @@ -7,11 +7,13 @@ import { /** The parts of a Durable Object's storage the adapter uses, backed by a Map. Values are cloned on the way in and out, as the runtime's structured-clone storage does. */ function fakeStorage(): DurableObjectKeyValues & { raw: Map } { const raw = new Map(); + return { raw, get: async (key) => Promise.resolve(structuredClone(raw.get(key))), put: async (key, value) => { raw.set(key, structuredClone(value)); + return Promise.resolve(); }, delete: async (key) => Promise.resolve(raw.delete(key)), diff --git a/ts/packages/cloudflare-hub/test/fake-web-socket.ts b/ts/packages/cloudflare-hub/test/fake-web-socket.ts index addea9bc..e04b37dc 100644 --- a/ts/packages/cloudflare-hub/test/fake-web-socket.ts +++ b/ts/packages/cloudflare-hub/test/fake-web-socket.ts @@ -2,8 +2,11 @@ export class FakeWebSocket { binaryType = "arraybuffer"; + sent: ArrayBuffer[] = []; + closed = false; + private readonly listeners = new Map< string, ((event: { data?: unknown }) => void)[] diff --git a/ts/packages/cloudflare-hub/test/hex.ts b/ts/packages/cloudflare-hub/test/hex.ts index f07c8dd3..f385f4e4 100644 --- a/ts/packages/cloudflare-hub/test/hex.ts +++ b/ts/packages/cloudflare-hub/test/hex.ts @@ -11,6 +11,7 @@ export function bytesFromHex(hex: string): Uint8Array { HEX_RADIX, ); } + return out; } @@ -18,5 +19,6 @@ export function bytesFromHex(hex: string): Uint8Array { export function deviceIdFromFillHex(fillHex: string): Uint8Array { const out = new Uint8Array(SHA256_BYTE_LENGTH); out.fill(Number.parseInt(fillHex, HEX_RADIX)); + return out; } diff --git a/ts/packages/cloudflare-hub/test/hibernation.integration.test.ts b/ts/packages/cloudflare-hub/test/hibernation.integration.test.ts index b7569bc5..6fc53424 100644 --- a/ts/packages/cloudflare-hub/test/hibernation.integration.test.ts +++ b/ts/packages/cloudflare-hub/test/hibernation.integration.test.ts @@ -19,7 +19,9 @@ const relayPayload = bytesFromHex("deadbeef"); /** A hibernating server socket as the runtime presents one: sends are recorded, and the attachment is round-tripped through structuredClone, the same serialisation the runtime applies to it. */ class FakeHubSocket implements HubSocket { sent: Uint8Array[] = []; + closed = false; + private attachment: unknown = null; send(message: Uint8Array): void { @@ -46,6 +48,7 @@ class FakeHubSocket implements HubSocket { function arrayBufferFor(frame: Frame): ArrayBuffer { const bytes = messageFromFrame(frame); + return bytes.buffer.slice( bytes.byteOffset, bytes.byteOffset + bytes.byteLength, @@ -73,6 +76,7 @@ async function pairedHub( a, arrayBufferFor({ type: "relay-connect", "target-device": peerB.device }), ); + return hub; } diff --git a/ts/packages/cloudflare-hub/test/hub.integration.test.ts b/ts/packages/cloudflare-hub/test/hub.integration.test.ts index 6560be3b..51e4d6e2 100644 --- a/ts/packages/cloudflare-hub/test/hub.integration.test.ts +++ b/ts/packages/cloudflare-hub/test/hub.integration.test.ts @@ -12,25 +12,31 @@ import { FakeWebSocket } from "./fake-web-socket.js"; import { createTestPeer, hubVerifier } from "./signed-peers.js"; const relayPayload = bytesFromHex("deadbeef"); -const CBOR_BREAK_BYTE = 0xff; // the CBOR break byte on its own: undecodable, the hostile-input case +// the CBOR break byte on its own: undecodable, the hostile-input case +const CBOR_BREAK_BYTE = 0xff; /** An in-memory Connection driving the hub through the port contract: queued inbound frames the test pushes, and a record of everything the hub sends back. */ class FakeConnection { inbound: Frame[] = []; + sent: Frame[] = []; + private closed = false; + private readonly wakeWaiters: (() => void)[] = []; get connection(): Readonly { return { send: async (frame: Frame): Promise => { this.sent.push(frame); + return Promise.resolve(); }, receive: () => this.stream(), close: async (): Promise => { this.closed = true; this.wake(); + return Promise.resolve(); }, }; @@ -45,6 +51,7 @@ class FakeConnection { async end(): Promise { this.closed = true; this.wake(); + return Promise.resolve(); } @@ -67,18 +74,18 @@ class FakeConnection { } private async drain(): Promise> { - for (;;) { - const next = this.inbound.shift(); - if (next !== undefined) { - return { value: next, done: false }; - } - if (this.closed) { - return { value: undefined, done: true }; - } - await new Promise((resolve) => { - this.wakeWaiters.push(resolve); - }); + const next = this.inbound.shift(); + if (next !== undefined) { + return { value: next, done: false }; + } + if (this.closed) { + return { value: undefined, done: true }; } + await new Promise((resolve) => { + this.wakeWaiters.push(resolve); + }); + + return this.drain(); } } diff --git a/ts/packages/cloudflare-hub/test/mailbox.integration.test.ts b/ts/packages/cloudflare-hub/test/mailbox.integration.test.ts index 7bbda1c6..6bc0fd44 100644 --- a/ts/packages/cloudflare-hub/test/mailbox.integration.test.ts +++ b/ts/packages/cloudflare-hub/test/mailbox.integration.test.ts @@ -16,14 +16,17 @@ import { createTestPeer } from "./signed-peers.js"; /** Storage that is not the instance: a Map that the next hub instance is handed as well. */ function sharedStorage(): KeyValueStorage { const values = new Map>(); + return { get: async (key) => Promise.resolve(values.get(key)), set: async (key, value) => { values.set(key, value); + return Promise.resolve(); }, delete: async (key) => { values.delete(key); + return Promise.resolve(); }, keys: async (prefix) => @@ -33,19 +36,25 @@ function sharedStorage(): KeyValueStorage { class FakeHubSocket implements HubSocket { sent: Uint8Array[] = []; + private attachment: unknown = null; + send(message: Uint8Array): void { this.sent.push(message); } + close(): void { return undefined; } + serializeAttachment(value: unknown): void { this.attachment = structuredClone(value); } + deserializeAttachment(): unknown { return this.attachment; } + frames(): unknown[] { return this.sent.map((message) => decode(message)); } @@ -53,6 +62,7 @@ class FakeHubSocket implements HubSocket { function buffer(frame: Frame): ArrayBuffer { const bytes = messageFromFrame(frame); + return bytes.buffer.slice( bytes.byteOffset, bytes.byteOffset + bytes.byteLength, diff --git a/ts/packages/cloudflare-hub/test/signed-peers.ts b/ts/packages/cloudflare-hub/test/signed-peers.ts index 6dd2c3d2..9bdf40f2 100644 --- a/ts/packages/cloudflare-hub/test/signed-peers.ts +++ b/ts/packages/cloudflare-hub/test/signed-peers.ts @@ -42,6 +42,7 @@ export async function createTestPeer(): Promise { "snapshot-seconds": FIXTURE_SNAPSHOT_SECONDS, "identity-key": identity.identityKey, }); + return { identity, device: identity.deviceId, diff --git a/ts/packages/cloudflare-hub/test/web-crypto-identity.unit.test.ts b/ts/packages/cloudflare-hub/test/web-crypto-identity.unit.test.ts index 1096c288..5bafedd7 100644 --- a/ts/packages/cloudflare-hub/test/web-crypto-identity.unit.test.ts +++ b/ts/packages/cloudflare-hub/test/web-crypto-identity.unit.test.ts @@ -12,9 +12,11 @@ import { import { bytesFromHex } from "./hex.js"; const ES256 = -7; -const ES512 = -36; // a real COSE algorithm this adapter deliberately does not implement +// a real COSE algorithm this adapter deliberately does not implement +const ES512 = -36; const SHA256_BYTE_LENGTH = 32; -const UNCOMPRESSED_P256_POINT_BYTE_LENGTH = 65; // 0x04 || X || Y +// 0x04 || X || Y +const UNCOMPRESSED_P256_POINT_BYTE_LENGTH = 65; function someMessage(): Uint8Array { return bytesFromHex("0102030405"); @@ -44,8 +46,8 @@ describe("createWebCryptoIdentity", () => { expect( await identity.verify(identity.identityKey, message, signature), ).toBe(true); - // A genuinely different message (same bytes in a fresh buffer would still - // legitimately verify -- the signature covers content, not identity). + /* A genuinely different message (same bytes in a fresh buffer would still + legitimately verify -- the signature covers content, not identity). */ const tampered = bytesFromHex("0102030406"); expect( await identity.verify(identity.identityKey, tampered, signature), diff --git a/ts/packages/cloudflare-hub/test/websocket-transport.integration.test.ts b/ts/packages/cloudflare-hub/test/websocket-transport.integration.test.ts index ed42666c..e5e9e24f 100644 --- a/ts/packages/cloudflare-hub/test/websocket-transport.integration.test.ts +++ b/ts/packages/cloudflare-hub/test/websocket-transport.integration.test.ts @@ -9,8 +9,10 @@ import { FakeWebSocket } from "./fake-web-socket.js"; import { bytesFromHex } from "./hex.js"; const ping: Frame = { type: "ping" }; -const CBOR_MAP_ONE_ENTRY_FIRST_BYTE = 0xa1; // a one-entry CBOR map head -- the ping frame, no length prefix -const CBOR_BREAK_BYTE = 0xff; // the CBOR break byte on its own: undecodable as a complete value +// a one-entry CBOR map head -- the ping frame, no length prefix +const CBOR_MAP_ONE_ENTRY_FIRST_BYTE = 0xa1; +// the CBOR break byte on its own: undecodable as a complete value +const CBOR_BREAK_BYTE = 0xff; const samplePayload = bytesFromHex("010203"); function arrayBuffer(bytes: Uint8Array): ArrayBuffer { @@ -27,6 +29,7 @@ function frameFromBuffer(buffer: Readonly): unknown { async function collect(iterable: Readonly>): Promise { const out: T[] = []; for await (const item of iterable) out.push(item); + return out; } From be4a2638c4396e85ebbbd4b009e20417ee9e556e Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sat, 10 Oct 2026 10:13:32 +0100 Subject: [PATCH 5/7] refactor(wire-mesh-sfu): satisfy the eslint-config 3 rules Trailing comments move above the code they describe, consecutive line comments become block comments and statements are separated as the shared configuration requires. - Offer sections are walked one after another, in offer order, through a small sequential helper. Sequential: each section produces or consumes on the mediasoup transport and registers what it made, so a failing section stops the ones after it, nothing registers once join has thrown, and map insertion order stays section order. - Track maps are sent to the other participants in roster order, stopping at the first failure as the loop did. Sequential. - The negotiation id moves off the mutated roster entry into a WeakMap keyed by the entry, so an offer still being authorised when its participant is removed cannot hand its id to the same device added again. A test pins that. - A test clears its backend reference before awaiting the teardown. --- .../src/adapters/mediasoup-media-backend.ts | 28 +++++--- .../wire-mesh-sfu/src/adapters/sdp-bridge.ts | 18 +++-- .../wire-mesh-sfu/src/domain/in-order.ts | 12 ++++ .../wire-mesh-sfu/src/domain/sfu-session.ts | 44 ++++++++----- ts/packages/wire-mesh-sfu/src/server.ts | 8 ++- .../test/fixtures/chrome-offer.ts | 4 +- ...ediasoup-media-backend.integration.test.ts | 8 ++- .../test/sdp-bridge.unit.test.ts | 12 ++-- .../test/sfu-session.unit.test.ts | 66 +++++++++++++++++++ ts/packages/wire-mesh-sfu/tsdown.config.ts | 4 +- 10 files changed, 160 insertions(+), 44 deletions(-) create mode 100644 ts/packages/wire-mesh-sfu/src/domain/in-order.ts diff --git a/ts/packages/wire-mesh-sfu/src/adapters/mediasoup-media-backend.ts b/ts/packages/wire-mesh-sfu/src/adapters/mediasoup-media-backend.ts index 0b604c10..1e8cc31f 100644 --- a/ts/packages/wire-mesh-sfu/src/adapters/mediasoup-media-backend.ts +++ b/ts/packages/wire-mesh-sfu/src/adapters/mediasoup-media-backend.ts @@ -1,8 +1,8 @@ -// The one real SfuMediaBackend implementation this package ships: wraps a single mediasoup Worker/Router, one WebRtcTransport per joined participant, and sdp-bridge.ts's own SDP<->RtpParameters translation. Leans on mediasoup rather than a from-scratch RTP forwarder for the same audited-library-over-reimplementation reasoning the design settled on FROST threshold signing with (wire-mesh#29, wire-mesh#37). -// -// DTLS role: this backend always answers with a=setup:active (this SFU initiates the DTLS handshake as the client), so the remote's own dtlsParameters.role passed to transport.connect() is always "server", its complement. Either choice is a valid DTLS negotiation outcome for an offer proposing a=setup:actpass; this is a deliberate, fixed pick for simplicity, not a constraint mediasoup or the browser imposes. -// -// Scope: one recv slot per already-proposed recvonly m-line, filled from whichever other participant's matching-kind producer is available first, never reassigned once filled. A participant joining after another's own recvonly slots are already exhausted (or already filled) is not connected to it without a later renegotiation this backend does not drive: see this package's README for the fuller explanation and what a later version would add. +/* The one real SfuMediaBackend implementation this package ships: wraps a single mediasoup Worker/Router, one WebRtcTransport per joined participant, and sdp-bridge.ts's own SDP<->RtpParameters translation. Leans on mediasoup rather than a from-scratch RTP forwarder for the same audited-library-over-reimplementation reasoning the design settled on FROST threshold signing with (wire-mesh#29, wire-mesh#37). + + DTLS role: this backend always answers with a=setup:active (this SFU initiates the DTLS handshake as the client), so the remote's own dtlsParameters.role passed to transport.connect() is always "server", its complement. Either choice is a valid DTLS negotiation outcome for an offer proposing a=setup:actpass; this is a deliberate, fixed pick for simplicity, not a constraint mediasoup or the browser imposes. + + Scope: one recv slot per already-proposed recvonly m-line, filled from whichever other participant's matching-kind producer is available first, never reassigned once filled. A participant joining after another's own recvonly slots are already exhausted (or already filled) is not connected to it without a later renegotiation this backend does not drive: see this package's README for the fuller explanation and what a later version would add. */ import { createWorker } from "mediasoup"; import type { @@ -25,6 +25,7 @@ import { type AnswerSection, type BridgeRtpParameters, } from "./sdp-bridge.js"; +import { eachInOrder } from "../domain/in-order.js"; import type { BackendTrack, SfuMediaBackend } from "../domain/media-backend.js"; export interface MediasoupMediaBackendOptions { @@ -124,6 +125,7 @@ function toDtlsFingerprint( `offer's own a=fingerprint carries an unrecognised algorithm: ${fingerprint.type}`, ); } + return { algorithm: fingerprint.type, value: fingerprint.hash }; } @@ -221,6 +223,7 @@ export async function createMediasoupMediaBackend( } } } + return undefined; } @@ -248,14 +251,16 @@ export async function createMediasoupMediaBackend( const answerSections: AnswerSection[] = []; const consumedProducerIds = new Set(); - for (const section of offer.sections) { + // One section at a time, in offer order: a section that fails stops the ones after it, and nothing registers once join has thrown. + await eachInOrder(offer.sections, async (section) => { if (section.kind === "other") { answerSections.push({ mid: section.mid, kind: "other", direction: "inactive", }); - continue; + + return; } if (section.send !== undefined) { const narrowed = intersectWithRouterCapabilities( @@ -275,7 +280,8 @@ export async function createMediasoupMediaBackend( kind: section.kind, direction: "inactive", }); - continue; + + return; } if (section.canReceive) { const available = findAvailableProducer( @@ -315,7 +321,8 @@ export async function createMediasoupMediaBackend( consumer, ), }); - continue; + + return; } } answerSections.push({ @@ -323,7 +330,7 @@ export async function createMediasoupMediaBackend( kind: section.kind, direction: "inactive", }); - } + }); await transport.connect({ dtlsParameters: { @@ -387,6 +394,7 @@ export async function createMediasoupMediaBackend( } } state.transport.close(); + return [...affected]; }, }; diff --git a/ts/packages/wire-mesh-sfu/src/adapters/sdp-bridge.ts b/ts/packages/wire-mesh-sfu/src/adapters/sdp-bridge.ts index 91f962d2..2dacd0f6 100644 --- a/ts/packages/wire-mesh-sfu/src/adapters/sdp-bridge.ts +++ b/ts/packages/wire-mesh-sfu/src/adapters/sdp-bridge.ts @@ -1,6 +1,6 @@ -// Bridges plain WebRTC SDP offer/answer (what a browser RTCPeerConnection, unmodified, actually sends and expects) against mediasoup's own transport/router state, which is expressed as structured RtpParameters/IceParameters/DtlsParameters rather than SDP text. mediasoup deliberately does not do this itself (its own FAQ: "mediasoup does not process or generate SDP... you can build your own SDP handling on top using an npm module such as sdp-transform"); this module is that layer, kept as a standalone adapter with no mediasoup import of its own so it takes and returns only plain, already-portable data shapes (mediasoup's own Rtp/Ice/Dtls parameter types are structurally compatible with what this module expects, needing no cast at the call site in mediasoup-media-backend.ts). -// -// Scope: intersects the offer's own codecs against the router's own RouterRtpCapabilities to choose exactly one payload type per m-line (no simulcast, no codec preference beyond "first mutually supported match"), and treats a non-audio/video m-line (e.g. an application/SCTP m-line from a data channel bundled on the same connection) as rejected in the answer (port 0), never produced or consumed. Both are explicit, documented scope boundaries, not silent gaps: see this package's README for what a fuller implementation would add. +/* Bridges plain WebRTC SDP offer/answer (what a browser RTCPeerConnection, unmodified, actually sends and expects) against mediasoup's own transport/router state, which is expressed as structured RtpParameters/IceParameters/DtlsParameters rather than SDP text. mediasoup deliberately does not do this itself (its own FAQ: "mediasoup does not process or generate SDP... you can build your own SDP handling on top using an npm module such as sdp-transform"); this module is that layer, kept as a standalone adapter with no mediasoup import of its own so it takes and returns only plain, already-portable data shapes (mediasoup's own Rtp/Ice/Dtls parameter types are structurally compatible with what this module expects, needing no cast at the call site in mediasoup-media-backend.ts). + + Scope: intersects the offer's own codecs against the router's own RouterRtpCapabilities to choose exactly one payload type per m-line (no simulcast, no codec preference beyond "first mutually supported match"), and treats a non-audio/video m-line (e.g. an application/SCTP m-line from a data channel bundled on the same connection) as rejected in the answer (port 0), never produced or consumed. Both are explicit, documented scope boundaries, not silent gaps: see this package's README for what a fuller implementation would add. */ import { parse as parseSdp, @@ -67,6 +67,7 @@ function iceDtlsFor( `m-line mid=${midOf(media) ?? "?"} carries no ICE ufrag/pwd or DTLS fingerprint, at session or media level`, ); } + return { iceUfrag, icePwd, fingerprint }; } @@ -82,6 +83,7 @@ function midOf(media: Readonly): string | undefined { if (typeof mid === "number") { return String(mid); } + return typeof mid === "string" ? mid : undefined; } @@ -109,6 +111,7 @@ function rtpParametersFromMedia(media: Readonly): BridgeRtpParameters { const codecs: BridgeRtpCodecParameters[] = media.rtp.map((rtp) => { const config = fmtpByPayload.get(rtp.payload); const rtcpFeedback = rtcpFbByPayload.get(rtp.payload); + return { mimeType: `${media.type}/${rtp.codec}`, payloadType: rtp.payload, @@ -132,6 +135,7 @@ function rtpParametersFromMedia(media: Readonly): BridgeRtpParameters { .filter((group) => group.semantics === "FID") .map((group) => group.ssrcs.split(" ").map(Number)) .find((pair) => pair[0] === mediaSsrc)?.[1]; + return { mid, codecs, @@ -164,6 +168,7 @@ export function parseOffer(offerSdp: string): ParsedOffer { if (kind === "other") { return { mid, kind, canReceive: false }; } + return { mid, kind, @@ -178,6 +183,7 @@ export function parseOffer(offerSdp: string): ParsedOffer { if (firstMedia === undefined) { throw new Error("offer carries no m-lines"); } + return { iceDtls: iceDtlsFor(session, firstMedia), sections }; } @@ -201,6 +207,7 @@ export function intersectWithRouterCapabilities( if (matched === undefined) { return undefined; } + return { ...offered, codecs: [matched] }; } @@ -231,7 +238,8 @@ export interface BuildAnswerOptions { } const SDP_UNICAST_ADDRESS = "0.0.0.0"; -const SDP_PORT_WHEN_BUNDLED = 9; // RFC 8843: every bundled m-line after the first uses the discard port; ICE/DTLS/media all flow over the first m-line's own candidates. +// RFC 8843: every bundled m-line after the first uses the discard port; ICE/DTLS/media all flow over the first m-line's own candidates. +const SDP_PORT_WHEN_BUNDLED = 9; const SDP_PORT_REJECTED = 0; /** Builds a complete SDP answer for a parsed offer's own m-line sequence: sections must be given in exactly the same order as the offer's own parseOffer().sections, one per m-line, so RFC 8843 bundling and m-line-position matching hold. Every section becomes a real m-line: "other" (non-audio/video) sections are rejected (port 0), matching how this bridge treats them in parseOffer. */ @@ -257,6 +265,7 @@ export function buildAnswer(options: Readonly): string { buildAnswerMedia(section, index, options), ), }; + return writeSdp(session); } @@ -278,6 +287,7 @@ function buildAnswerMedia( } const rtpParameters = section.rtpParameters; const payloads = rtpParameters?.codecs.map((codec) => codec.payloadType); + return { type: section.kind, port: diff --git a/ts/packages/wire-mesh-sfu/src/domain/in-order.ts b/ts/packages/wire-mesh-sfu/src/domain/in-order.ts new file mode 100644 index 00000000..32d1c8d4 --- /dev/null +++ b/ts/packages/wire-mesh-sfu/src/domain/in-order.ts @@ -0,0 +1,12 @@ +/** + * Runs `step` for each item, starting each only once the one before it has finished and stopping at the first that rejects, so side effects happen in item order and nothing after a failure runs. + */ +export async function eachInOrder( + items: readonly T[], + step: (item: T) => Promise, +): Promise { + await items.reduce(async (previous, item) => { + await previous; + await step(item); + }, Promise.resolve()); +} diff --git a/ts/packages/wire-mesh-sfu/src/domain/sfu-session.ts b/ts/packages/wire-mesh-sfu/src/domain/sfu-session.ts index 228f5353..ce53f445 100644 --- a/ts/packages/wire-mesh-sfu/src/domain/sfu-session.ts +++ b/ts/packages/wire-mesh-sfu/src/domain/sfu-session.ts @@ -19,6 +19,7 @@ import { isWebrtcOffer, rtcIceCandidateInitFromWire, } from "wire-mesh-core/domain/webrtc-signaling"; +import { eachInOrder } from "./in-order.js"; import type { SfuMediaBackend } from "./media-backend.js"; export interface SfuCall { @@ -38,8 +39,6 @@ interface ParticipantEntry { session: Readonly; /** deviceId, hex-encoded, purely as this call's own backend participantId: SfuMediaBackend's contract takes plain strings, not DeviceId's raw bytes, since a remote-process backend needs a serialisable key. */ participantId: string; - /** This participant's own current negotiation-id, learned from its offer, so a later sfu-track-map to it (or its own answer) can carry the right value. Undefined until its first offer arrives. */ - negotiationId?: number; } const HEX_RADIX = 16; @@ -50,22 +49,24 @@ function participantIdFor(deviceId: DeviceId): string { for (const byte of deviceId) { id += byte.toString(HEX_RADIX).padStart(HEX_DIGITS_PER_BYTE, "0"); } + return id; } -/** Sends this participant's own current sfu-track-map: every mid on its own connection now carrying live media, whether from its own initial join (backend-reported consumed tracks) or a later participant leaving (an empty resend for any mid whose track just ended). negotiationId must already be known (the participant's own offer must have arrived): a participant this call has never negotiated with has no connection-specific mid space to describe. */ +/** Sends this participant's own current sfu-track-map: every mid on its own connection now carrying live media, whether from its own initial join (backend-reported consumed tracks) or a later participant leaving (an empty resend for any mid whose track just ended). `negotiationId` is the participant's own current negotiation-id, learned from its offer; it is undefined until the first offer arrives, and a participant this call has never negotiated with has no connection-specific mid space to describe, so nothing is sent. */ async function sendTrackMap( entry: Readonly, + negotiationId: number | undefined, tracks: readonly { mid: string; member: DeviceId; kind: "audio" | "video"; }[], ): Promise { - if (entry.negotiationId === undefined) { + if (negotiationId === undefined) { return; } - const command = buildSfuTrackMapCommand(entry.negotiationId, tracks); + const command = buildSfuTrackMapCommand(negotiationId, tracks); await entry.session.sendManageRequest(command, { kind: "node" }); } @@ -74,6 +75,8 @@ export function createSfuCall( verifyOptions: Readonly, ): SfuCall { const participants = new Map(); + // Each participant entry's own current negotiation-id, learned from its offer. Keyed by the entry, not the device, so an offer still being authorised when its participant is removed cannot hand its id to the same device added again. + const negotiationIds = new WeakMap(); // Every currently live track this call knows about, per owning participant: rebuilt into a per-recipient tracks[] array (excluding the recipient's own tracks) whenever any recipient's sfu-track-map needs resending. Kept flat (not nested under the consuming participant) since it describes the call's actual media state, not any one participant's own view of it. const liveTracks = new Map< string, @@ -94,6 +97,7 @@ export function createSfuCall( } tracks.push(...ownerTracks); } + return tracks; } @@ -102,7 +106,11 @@ export function createSfuCall( if (entry === undefined) { return; } - await sendTrackMap(entry, tracksForRecipient(participantId)); + await sendTrackMap( + entry, + negotiationIds.get(entry), + tracksForRecipient(participantId), + ); } async function handleOffer( @@ -113,9 +121,10 @@ export function createSfuCall( const authorized = await authorizeIncomingOffer(incoming, verifyOptions); if (!authorized) { await incoming.respond({ result: "error", code: "unauthorized" }); + return; } - entry.negotiationId = offer["negotiation-id"]; + negotiationIds.set(entry, offer["negotiation-id"]); const result = await backend.join(entry.participantId, offer.sdp); liveTracks.set( entry.participantId, @@ -131,7 +140,7 @@ export function createSfuCall( verb: WEBRTC_SIGNAL_VERB, params: { verb: "webrtc.answer", - "negotiation-id": entry.negotiationId, + "negotiation-id": offer["negotiation-id"], sdp: result.answerSdp, }, }, @@ -143,21 +152,22 @@ export function createSfuCall( const owner = [...participants.values()].find( (candidate) => candidate.participantId === track.fromParticipantId, ); + return { mid: track.mid, member: owner?.deviceId ?? entry.deviceId, kind: track.kind, }; }); - await sendTrackMap(entry, consumedEntries); + await sendTrackMap(entry, offer["negotiation-id"], consumedEntries); } // Every other participant now has one more live track (this joiner's own) to report. - for (const other of participants.values()) { - if (other.participantId === entry.participantId) { - continue; - } - await republishTo(other.participantId); - } + await eachInOrder( + [...participants.values()] + .filter((other) => other.participantId !== entry.participantId) + .map((other) => other.participantId), + republishTo, + ); } async function handleIceCandidate( @@ -207,9 +217,7 @@ export function createSfuCall( participants.delete(participantId); liveTracks.delete(participantId); const affected = await backend.leave(participantId); - for (const otherParticipantId of affected) { - await republishTo(otherParticipantId); - } + await eachInOrder(affected, republishTo); }, get participantCount() { return participants.size; diff --git a/ts/packages/wire-mesh-sfu/src/server.ts b/ts/packages/wire-mesh-sfu/src/server.ts index 3ffbfd3e..fca1333d 100644 --- a/ts/packages/wire-mesh-sfu/src/server.ts +++ b/ts/packages/wire-mesh-sfu/src/server.ts @@ -1,7 +1,7 @@ #!/usr/bin/env node -// The wire-mesh-sfu CLI entrypoint: a standalone media-relay service accepting real MeshSessions over a WebSocket transport (reusing wire-mesh's own adapter, the identical wire protocol every deployment already speaks) and handing each one to a shared SfuCall. Deployment mode is a genuine, independent choice from the mediasoup backend itself (see spec/webrtc.cddl's own module header): this is the standalone-service half of that choice; an in-process deployment inside another wire-mesh process would call createSfuCall/createMediasoupMediaBackend directly instead of spawning this binary, sharing the identical domain and adapter code. -// -// Identity: this SFU signs its own handshake with a freshly generated, in-memory ES256 keypair each time it starts, never persisted to disk: a deliberate v1 scope choice, not an oversight. It matters for restart continuity (a restarted SFU cannot be recognised as "the same device" by anyone holding an old capability-token scoped to its old device-id) but not for correctness within one run: this SFU never issues its own capability tokens for others to hold long-term, and its own identity is used only to sign/verify handshake and manage-request exchanges for the lifetime of the process. A future revision wanting restart continuity would persist the keypair the same way core/node-fs-storage already persists other node-local state. +/* The wire-mesh-sfu CLI entrypoint: a standalone media-relay service accepting real MeshSessions over a WebSocket transport (reusing wire-mesh's own adapter, the identical wire protocol every deployment already speaks) and handing each one to a shared SfuCall. Deployment mode is a genuine, independent choice from the mediasoup backend itself (see spec/webrtc.cddl's own module header): this is the standalone-service half of that choice; an in-process deployment inside another wire-mesh process would call createSfuCall/createMediasoupMediaBackend directly instead of spawning this binary, sharing the identical domain and adapter code. + + Identity: this SFU signs its own handshake with a freshly generated, in-memory ES256 keypair each time it starts, never persisted to disk: a deliberate v1 scope choice, not an oversight. It matters for restart continuity (a restarted SFU cannot be recognised as "the same device" by anyone holding an old capability-token scoped to its old device-id) but not for correctness within one run: this SFU never issues its own capability tokens for others to hold long-term, and its own identity is used only to sign/verify handshake and manage-request exchanges for the lifetime of the process. A future revision wanting restart continuity would persist the keypair the same way core/node-fs-storage already persists other node-local state. */ import { webcrypto } from "node:crypto"; import { createNodeIdentity } from "wire-mesh-core/adapters/node-identity"; @@ -23,6 +23,7 @@ export function healthResponse(): { ok: true; node: string; roles: string[] } { function argValue(argv: readonly string[], flag: string): string | undefined { const flagIndex = argv.indexOf(flag); + return flagIndex === -1 ? undefined : argv[flagIndex + 1]; } @@ -54,6 +55,7 @@ async function generateIdentity(): Promise { const publicKeyBytes = new Uint8Array( await webcrypto.subtle.exportKey("raw", keyPair.publicKey), ); + return createNodeIdentity(keyPair.privateKey, publicKeyBytes, ES256); } diff --git a/ts/packages/wire-mesh-sfu/test/fixtures/chrome-offer.ts b/ts/packages/wire-mesh-sfu/test/fixtures/chrome-offer.ts index 03c66f69..905735e0 100644 --- a/ts/packages/wire-mesh-sfu/test/fixtures/chrome-offer.ts +++ b/ts/packages/wire-mesh-sfu/test/fixtures/chrome-offer.ts @@ -1,4 +1,6 @@ -// A real Chrome offer's own audio (opus, sendrecv), video (VP8+VP9 as its RTX pair, sendrecv), and a second, recvonly video m-line reserved for consuming another participant's track: one BUNDLE group, per-media ICE/DTLS (Chrome duplicates these onto every m-line rather than only the session level), an SCTP application m-line for the data channel core/webrtc's own signaling doubles as. Shared by sdp-bridge.unit.test.ts (pure parsing, no mediasoup) and mediasoup-media-backend.integration.test.ts (a real mediasoup Router, where the same fixture doubles as two different participants' own offers, since Chrome always numbers its own mids from 0 the same way regardless of who is offering). +/** + * A real Chrome offer's own audio (opus, sendrecv), video (VP8+VP9 as its RTX pair, sendrecv), and a second, recvonly video m-line reserved for consuming another participant's track: one BUNDLE group, per-media ICE/DTLS (Chrome duplicates these onto every m-line rather than only the session level), an SCTP application m-line for the data channel core/webrtc's own signaling doubles as. Shared by sdp-bridge.unit.test.ts (pure parsing, no mediasoup) and mediasoup-media-backend.integration.test.ts (a real mediasoup Router, where the same fixture doubles as two different participants' own offers, since Chrome always numbers its own mids from 0 the same way regardless of who is offering). + */ export const CHROME_SHAPED_OFFER = [ "v=0", "o=- 4611731400430051336 2 IN IP4 127.0.0.1", diff --git a/ts/packages/wire-mesh-sfu/test/mediasoup-media-backend.integration.test.ts b/ts/packages/wire-mesh-sfu/test/mediasoup-media-backend.integration.test.ts index 572039e0..986733e8 100644 --- a/ts/packages/wire-mesh-sfu/test/mediasoup-media-backend.integration.test.ts +++ b/ts/packages/wire-mesh-sfu/test/mediasoup-media-backend.integration.test.ts @@ -16,9 +16,10 @@ describe("createMediasoupMediaBackend, against a real mediasoup Worker", () => { afterEach(async () => { // mediasoup has no explicit "close the whole backend" method in this port (a real deployment runs one backend for the process's own lifetime), but leave()ing every participant this test created closes their transports and everything under them, so the underlying mediasoup Worker has nothing left running by the time the next test's own createMediasoupMediaBackend() call spins up a fresh one. - await backend?.leave("participant-b"); - await backend?.leave("participant-a"); + const closing = backend; backend = undefined; + await closing?.leave("participant-b"); + await closing?.leave("participant-a"); }); it("produces real Producers from a send-capable offer and builds a well-formed, parseable answer", async () => { @@ -42,7 +43,8 @@ describe("createMediasoupMediaBackend, against a real mediasoup Worker", () => { "3", ]); const audioAnswer = parsedAnswer.media[0]; - expect(audioAnswer?.direction).toBe("inactive"); // produced-from mid: the SFU never sends media back on the same mid it received on + // produced-from mid: the SFU never sends media back on the same mid it received on + expect(audioAnswer?.direction).toBe("inactive"); expect(audioAnswer?.iceUfrag).toBeTruthy(); expect(audioAnswer?.icePwd).toBeTruthy(); expect(audioAnswer?.fingerprint?.type).toBeTruthy(); diff --git a/ts/packages/wire-mesh-sfu/test/sdp-bridge.unit.test.ts b/ts/packages/wire-mesh-sfu/test/sdp-bridge.unit.test.ts index ebad8408..9144dc09 100644 --- a/ts/packages/wire-mesh-sfu/test/sdp-bridge.unit.test.ts +++ b/ts/packages/wire-mesh-sfu/test/sdp-bridge.unit.test.ts @@ -39,10 +39,14 @@ describe("parseOffer", () => { ["3", "other"], ]); expect(parsed.sections.map((section) => section.canReceive)).toEqual([ - true, // audio sendrecv - true, // video sendrecv - true, // video recvonly - false, // application m-line is never receivable + // audio sendrecv + true, + // video sendrecv + true, + // video recvonly + true, + // application m-line is never receivable + false, ]); }); diff --git a/ts/packages/wire-mesh-sfu/test/sfu-session.unit.test.ts b/ts/packages/wire-mesh-sfu/test/sfu-session.unit.test.ts index 4441cec6..2d20103e 100644 --- a/ts/packages/wire-mesh-sfu/test/sfu-session.unit.test.ts +++ b/ts/packages/wire-mesh-sfu/test/sfu-session.unit.test.ts @@ -49,6 +49,7 @@ async function generateIdentity(): Promise { const publicKeyBytes = new Uint8Array( await webcrypto.subtle.exportKey("raw", keyPair.publicKey), ); + return createNodeIdentity(keyPair.privateKey, publicKeyBytes, ES256); } @@ -101,6 +102,7 @@ async function mintAuthorizingToken( ), ); const signature = await identity.sign(toBeSigned); + return [protectedHeader, {}, payload, signature]; } @@ -123,6 +125,7 @@ function createFakeSession(): FakeSession { const incomingManageRequests: AsyncIterable = { [Symbol.asyncIterator]() { let delivered = false; + return { async next(): Promise> { if (delivered) { @@ -130,6 +133,7 @@ function createFakeSession(): FakeSession { } delivered = true; const request = await nextRequest; + return request === null ? { value: undefined, done: true } : { value: request, done: false }; @@ -157,6 +161,7 @@ function createFakeSession(): FakeSession { sendDataFrame: async (): Promise => Promise.resolve(), sendManageRequest: async (command): Promise => { sent.push(command); + return Promise.resolve({ result: "ok" }); }, close: async (): Promise => Promise.resolve(), @@ -190,10 +195,12 @@ function createFakeBackend( joinResult: JoinResult, ): SfuMediaBackend & { joinedParticipantIds: string[] } { const joinedParticipantIds: string[] = []; + return { joinedParticipantIds, async join(participantId): Promise { joinedParticipantIds.push(participantId); + return Promise.resolve(joinResult); }, // Not exercised by these tests: see media-backend.ts's own doc comment for why a real backend may legitimately no-op this too. @@ -227,6 +234,7 @@ describe("createSfuCall", () => { undefined, async (outcome): Promise => { responses.push(outcome); + return Promise.resolve(); }, ), @@ -261,6 +269,7 @@ describe("createSfuCall", () => { token, async (outcome): Promise => { responses.push(outcome); + return Promise.resolve(); }, ), @@ -345,6 +354,63 @@ describe("createSfuCall", () => { ]); }); + it("does not hand the negotiation id of an offer authorised after its participant was removed to the same device added again", async () => { + const deviceA = deviceIdFromFillByte(DEVICE_A_FILL_BYTE); + const deviceB = deviceIdFromFillByte(DEVICE_B_FILL_BYTE); + const identity = await generateIdentity(); + let release: () => void = () => undefined; + const gate = new Promise((resolve) => { + release = resolve; + }); + const call = createSfuCall( + createFakeBackend({ + answerSdp: "v=0 answer", + produced: [{ mid: "0", kind: "audio" } satisfies BackendTrack], + consumed: [], + }), + { + identity, + clock: { now: () => Date.now() }, + revocation: { + entriesFor: async () => { + await gate; + + return []; + }, + }, + }, + ); + const first = createFakeSession(); + const firstAdded = call.addParticipant(deviceA, first.session); + first.pushAndEnd( + fakeIncomingOffer( + NEGOTIATION_ID_AUTHORIZED_OFFER, + "v=0 offer", + await mintAuthorizingToken(identity, deviceA), + async (): Promise => Promise.resolve(), + ), + ); + await call.removeParticipant(deviceA); + const second = createFakeSession(); + void call.addParticipant(deviceA, second.session); + release(); + await firstAdded; + + const third = createFakeSession(); + const thirdAdded = call.addParticipant(deviceB, third.session); + third.pushAndEnd( + fakeIncomingOffer( + NEGOTIATION_ID_B_JOINS_FIRST, + "v=0 offer", + await mintAuthorizingToken(identity, deviceB), + async (): Promise => Promise.resolve(), + ), + ); + await thirdAdded; + + expect(second.sent).toEqual([]); + }); + it("removeParticipant is idempotent for a participant never added", async () => { const backend = createFakeBackend({ answerSdp: "unused", diff --git a/ts/packages/wire-mesh-sfu/tsdown.config.ts b/ts/packages/wire-mesh-sfu/tsdown.config.ts index 00eaacdb..0cc89f30 100644 --- a/ts/packages/wire-mesh-sfu/tsdown.config.ts +++ b/ts/packages/wire-mesh-sfu/tsdown.config.ts @@ -1,6 +1,8 @@ import { defineConfig } from "tsdown"; -// Every real module gets its own build entry rather than a re-exporting index.ts: the barrel-policy lint rule requires importing straight from the module that owns each export. +/** + * Every real module gets its own build entry rather than a re-exporting index.ts: the barrel-policy lint rule requires importing straight from the module that owns each export. + */ export default defineConfig({ entry: [ "src/domain/media-backend.ts", From faffd325e193a19661302d56b7eec3c8a51147f5 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sat, 10 Oct 2026 10:13:32 +0100 Subject: [PATCH 6/7] refactor(core): satisfy the eslint-config 3 rules Trailing comments move above the code they describe, consecutive line comments become block comments and statements are separated as the shared configuration requires. The rules that want positional parameter lists folded into options objects are not applied to the modules that export those lists: every module under src is a subpath export except those named in INTERNAL_MODULES, so changing them needs a major release. They are exempted per file in eslint.config.ts, with the reason beside the exemption. Internal functions are changed. Concurrency, each item classified: - Narrowing checks, token conditions and revocation entries are tested through a shared helper strictly one after another and stop at the first offender. Sequential. - Directory stats are read together. Independent and side-effect-free: reads of different paths, mapped in readdir order. - Data-sync entries are read one key at a time, stopping at the first hole and never touching later keys, and written in ascending sequence with the head last. Sequential. - Gossip adverts are verified together, in a session and in the relay hub. Independent and side-effect-free: verifyPeerAdvert derives a device id and checks a signature and never throws. Directory, registration and callbacks run afterwards in frame order. - The relay hub forwards to its peers one connection at a time over the live set. Sequential. - Notice log entries are read and verified in log order, so the first failure in log order is the one reported. Sequential. - Advertised addresses are dialled in order by recursion, and byte stream chunks are read with for await. Sequential. - The bulk sender still awaits its first ack inside the stream loop, so the turns between a chunk being sent and the next wait being registered are unchanged (an ack or a connection end with nothing waiting is dropped); later acks are awaited by recursion, and the final digest read fetches persisted chunks one at a time. Sequential. Other internal changes: the highest received counter is advanced with Math.max after the decrypt resolves, the direction key inputs, the shard to serve and the keygen round are passed as objects, and the delegation-chain walker keeps its position in one value. Polling loops in tests use recursion or vi.waitFor and ordered steps go through a small sequencing helper. --- ts/packages/core/eslint.config.ts | 30 ++++ ts/packages/core/scripts/assert-wasm-dist.ts | 10 +- .../src/adapters/byte-stream-connection.ts | 28 +++- ts/packages/core/src/adapters/frame-codec.ts | 2 + .../core/src/adapters/memory-nonce-store.ts | 3 + .../core/src/adapters/node-fs-storage.ts | 21 +-- .../core/src/adapters/node-identity.ts | 6 +- .../core/src/adapters/tcp-transport.ts | 8 ++ .../core/src/adapters/threshold-dkg.ts | 43 +++--- .../core/src/adapters/threshold-identity.ts | 1 + .../adapters/threshold-network-coordinator.ts | 7 + .../src/adapters/threshold-participant.ts | 7 + .../core/src/adapters/threshold-wasm.ts | 14 ++ .../core/src/adapters/tls-transport.ts | 10 ++ .../src/domain/advert-extension-policy.ts | 1 + ts/packages/core/src/domain/async-queue.ts | 1 + ts/packages/core/src/domain/bulk.ts | 93 +++++++++---- .../core/src/domain/capability-grant.ts | 5 + .../core/src/domain/capability-request.ts | 9 ++ .../core/src/domain/coordinator-election.ts | 16 ++- ts/packages/core/src/domain/data-sync.ts | 70 +++++++--- ts/packages/core/src/domain/device-id.ts | 6 +- .../core/src/domain/direct-manage-request.ts | 2 + ts/packages/core/src/domain/erasure-coding.ts | 11 +- .../core/src/domain/gossip-expansion.ts | 32 +++-- .../core/src/domain/grant-candidates.ts | 1 + ts/packages/core/src/domain/group-key.ts | 16 ++- ts/packages/core/src/domain/handshake.ts | 1 + ts/packages/core/src/domain/hub-mailbox.ts | 22 ++- ts/packages/core/src/domain/mesh-session.ts | 25 +++- ts/packages/core/src/domain/notice-board.ts | 10 +- ts/packages/core/src/domain/path-trace.ts | 2 + ts/packages/core/src/domain/peer-advert.ts | 9 +- .../core/src/domain/ping-round-trips.ts | 2 + ts/packages/core/src/domain/pinned-address.ts | 5 + ts/packages/core/src/domain/relay-advert.ts | 12 +- ts/packages/core/src/domain/relay-channels.ts | 4 + ts/packages/core/src/domain/relay-hub.ts | 105 +++++++++----- ts/packages/core/src/domain/relay-pairing.ts | 1 + ts/packages/core/src/domain/relay-use-gate.ts | 1 + .../core/src/domain/revocation-view.ts | 1 + ts/packages/core/src/domain/room-path.ts | 3 + ts/packages/core/src/domain/room-rekey.ts | 13 +- ts/packages/core/src/domain/room.ts | 16 ++- ts/packages/core/src/domain/secure-channel.ts | 45 ++++-- ts/packages/core/src/domain/sequential.ts | 27 ++++ ts/packages/core/src/domain/shard-manifest.ts | 6 + .../core/src/domain/sharded-delivery.ts | 55 ++++---- .../core/src/domain/threshold-network.ts | 12 +- .../src/domain/threshold-share-envelope.ts | 1 + .../core/src/domain/threshold-share-wire.ts | 1 + .../core/src/domain/threshold-subject.ts | 1 + .../core/src/domain/token-predicates.ts | 38 +++-- ts/packages/core/src/domain/token-scope.ts | 3 + ts/packages/core/src/domain/tokens.ts | 54 +++++--- .../core/src/domain/topology-snapshot.ts | 4 + ts/packages/core/src/domain/topology.ts | 4 + .../core/src/domain/webrtc-signaling.ts | 3 + ts/packages/core/src/ports/clock.ts | 4 +- ts/packages/core/src/ports/storage.ts | 4 +- ts/packages/core/stryker.config.ts | 6 +- ts/packages/core/test/bulk.unit.test.ts | 52 +++++-- .../test/byte-stream-connection.unit.test.ts | 2 + .../core/test/capability-grant.unit.test.ts | 8 ++ .../core/test/capability-request.unit.test.ts | 38 ++--- .../core/test/conformance.unit.test.ts | 3 + ts/packages/core/test/device-id.unit.test.ts | 20 +-- .../test/encrypted-notice.integration.test.ts | 4 + .../core/test/erasure-coding.unit.test.ts | 44 +++--- .../core/test/grant-candidates.unit.test.ts | 3 +- ts/packages/core/test/group-key.unit.test.ts | 9 +- ts/packages/core/test/hex.ts | 2 + .../core/test/hub-mailbox.unit.test.ts | 1 + ...h-session-advert-verification.unit.test.ts | 3 + .../mesh-session-coordinator.unit.test.ts | 1 + .../test/mesh-session-create.unit.test.ts | 39 +++--- .../core/test/mesh-session-fixtures.ts | 73 +++++++--- .../test/mesh-session-manage.unit.test.ts | 1 + .../test/mesh-session-ping-pong.unit.test.ts | 1 + .../test/mesh-session-reconnect.unit.test.ts | 35 +++-- .../mesh-session-redial-address.unit.test.ts | 2 + ...esh-session-relay-data-frames.unit.test.ts | 1 + .../test/mesh-session-routing.unit.test.ts | 4 + .../mesh-session-secure-relay.unit.test.ts | 1 + ...on-send-direct-manage-request.unit.test.ts | 2 + .../test/node-fs-storage.integration.test.ts | 1 + .../core/test/node-identity.unit.test.ts | 1 + .../core/test/notice-board.unit.test.ts | 97 ++++++++----- ts/packages/core/test/path-trace.unit.test.ts | 2 + .../core/test/pinned-address.unit.test.ts | 3 +- ...elay-hub-authenticated-gossip.unit.test.ts | 1 + .../relay-hub-extension-policy.unit.test.ts | 1 + .../core/test/relay-hub-fronting.unit.test.ts | 1 + .../test/relay-hub-hibernation.unit.test.ts | 32 +++-- .../core/test/relay-hub-mailbox.unit.test.ts | 1 + .../test/relay-hub-multiplexing.unit.test.ts | 6 +- .../core/test/relay-hub-test-helpers.ts | 47 ++++--- .../core/test/relay-hub-uplink.unit.test.ts | 1 + ts/packages/core/test/relay-hub.unit.test.ts | 26 ++-- ts/packages/core/test/relay-peer.ts | 64 +++++---- .../core/test/revocation-view.unit.test.ts | 4 +- .../test/room-notice-creation.unit.test.ts | 7 +- ts/packages/core/test/room-notice-fixtures.ts | 2 + .../room-notice-verification.unit.test.ts | 4 +- ts/packages/core/test/room-rekey.unit.test.ts | 130 +++++++++++++----- .../test/room-token-verification.unit.test.ts | 3 + .../core/test/secure-channel.unit.test.ts | 29 ++-- ts/packages/core/test/sequence.ts | 23 ++++ .../core/test/shard-manifest.unit.test.ts | 3 +- .../core/test/sharded-delivery.unit.test.ts | 45 +++--- ...-transport-token-bytes.integration.test.ts | 8 +- .../test/tcp-transport.integration.test.ts | 1 + .../test/threshold-dkg.integration.test.ts | 4 + .../core/test/threshold-identity.unit.test.ts | 13 +- ...ld-network-coordinator.integration.test.ts | 6 + .../core/test/threshold-network.unit.test.ts | 29 ++-- .../test/threshold-participant.unit.test.ts | 6 +- .../threshold-reshare.integration.test.ts | 11 +- .../threshold-share-envelope.unit.test.ts | 1 + .../core/test/threshold-wasm.unit.test.ts | 6 + ts/packages/core/test/tls-cert-fixture.ts | 1 + .../token-delegation-narrowing.unit.test.ts | 6 +- .../token-grant-authorisation.unit.test.ts | 56 ++++---- .../token-predicate-conditions.unit.test.ts | 3 + ...oken-revocation-authorization.unit.test.ts | 3 +- ts/packages/core/test/tokens-fixtures.ts | 24 +++- ts/packages/core/test/topology.unit.test.ts | 1 + .../core/test/tsdown-config.unit.test.ts | 1 + .../core/test/webrtc-signaling.unit.test.ts | 3 +- ts/packages/core/tsdown.config.ts | 21 +-- 130 files changed, 1443 insertions(+), 597 deletions(-) create mode 100644 ts/packages/core/src/domain/sequential.ts create mode 100644 ts/packages/core/test/sequence.ts diff --git a/ts/packages/core/eslint.config.ts b/ts/packages/core/eslint.config.ts index 595dad44..993f5b6b 100644 --- a/ts/packages/core/eslint.config.ts +++ b/ts/packages/core/eslint.config.ts @@ -33,5 +33,35 @@ export default defineConfig( ], }, }, + { + /* These modules export signatures this package publishes: every module + under src/ is a subpath export except those named in tsdown.config.ts's + INTERNAL_MODULES. A positional parameter list is part of that public API, + so folding it into an options object is a breaking change that needs a + major release, not a lint fix. The exemption is per file and limited to + the two parameter-shape rules. */ + files: [ + "src/adapters/threshold-identity.ts", + "src/adapters/threshold-wasm.ts", + "src/domain/bulk.ts", + "src/domain/capability-grant.ts", + "src/domain/capability-request.ts", + "src/domain/direct-manage-request.ts", + "src/domain/mesh-session.ts", + "src/domain/room-rekey.ts", + "src/domain/token-predicates.ts", + "src/domain/topology.ts", + ], + rules: { + "max-params": "off", + "exadev/prefer-options-object-param": "off", + }, + }, + { + /* This fake stands in for MeshSession.sendManageRequest, so it keeps that + published method's positional parameter list. */ + files: ["test/threshold-network-coordinator.integration.test.ts"], + rules: { "exadev/prefer-options-object-param": "off" }, + }, eslintPluginPrettierRecommended, ); diff --git a/ts/packages/core/scripts/assert-wasm-dist.ts b/ts/packages/core/scripts/assert-wasm-dist.ts index 01a74ea1..b97318ce 100644 --- a/ts/packages/core/scripts/assert-wasm-dist.ts +++ b/ts/packages/core/scripts/assert-wasm-dist.ts @@ -1,9 +1,9 @@ #!/usr/bin/env node -// Refuses to pack this package unless wasm-dist actually holds the wasm-bindgen output. -// -// package.json's "files" lists wasm-dist, but it is a gitignored build artefact that only scripts/build-threshold-wasm.sh produces, and npm packs a listed directory that does not exist without complaint. That combination is how every release up to 1.58.3 shipped a tarball with nothing under wasm-dist while dist/adapters/threshold-wasm required out of it, leaving that export subpath unresolvable for anyone installing from the registry. The failure was invisible from inside the repository, where the directory is always present locally. -// -// This runs from prepack, so it covers `npm publish` and a bare `npm pack` alike, whatever builds the package beforehand. It deliberately only checks, never builds: producing these files needs a Rust toolchain and a version-matched wasm-bindgen CLI, and silently invoking that from a lifecycle script would be a far bigger surprise than a clear failure telling the caller which command to run. +/* Refuses to pack this package unless wasm-dist actually holds the wasm-bindgen output. + + package.json's "files" lists wasm-dist, but it is a gitignored build artefact that only scripts/build-threshold-wasm.sh produces, and npm packs a listed directory that does not exist without complaint. That combination is how every release up to 1.58.3 shipped a tarball with nothing under wasm-dist while dist/adapters/threshold-wasm required out of it, leaving that export subpath unresolvable for anyone installing from the registry. The failure was invisible from inside the repository, where the directory is always present locally. + + This runs from prepack, so it covers `npm publish` and a bare `npm pack` alike, whatever builds the package beforehand. It deliberately only checks, never builds: producing these files needs a Rust toolchain and a version-matched wasm-bindgen CLI, and silently invoking that from a lifecycle script would be a far bigger surprise than a clear failure telling the caller which command to run. */ import { existsSync, statSync } from "node:fs"; import { dirname, join } from "node:path"; diff --git a/ts/packages/core/src/adapters/byte-stream-connection.ts b/ts/packages/core/src/adapters/byte-stream-connection.ts index b55fd6c6..839027a8 100644 --- a/ts/packages/core/src/adapters/byte-stream-connection.ts +++ b/ts/packages/core/src/adapters/byte-stream-connection.ts @@ -27,6 +27,7 @@ export function encodeLengthPrefixedFrame(frame: Frame): Uint8Array { const message = new Uint8Array(LENGTH_PREFIX_BYTES + body.length); new DataView(message.buffer).setUint32(0, body.length); message.set(body, LENGTH_PREFIX_BYTES); + return message; } @@ -37,9 +38,27 @@ function concat( const joined = new Uint8Array(first.length + second.length); joined.set(first); joined.set(second, first.length); + return joined; } +/** A reader's chunks as an async iterable, which ends with the stream and leaves releasing or cancelling the reader to its owner. */ +function chunksOf( + reader: ReadableStreamDefaultReader, +): AsyncIterable { + return { + [Symbol.asyncIterator]: () => ({ + next: async (): Promise> => { + const chunk = await reader.read(); + + return chunk.done + ? { done: true, value: undefined } + : { done: false, value: chunk.value }; + }, + }), + }; +} + /** * Frames arriving on a byte stream, in order, until it ends. An empty body (STREAM_OPEN_MARKER) is skipped. A body that does not decode as CBOR, or a length prefix above MAX_FRAME_BYTES, rejects the iteration (a connection-level failure); a body that decodes but fails frame validation is dropped and the stream continues, since an unrecognised frame from a newer peer is what version negotiation exists to tolerate. */ @@ -48,12 +67,8 @@ async function* readFrames( ): AsyncGenerator { let buffered: Uint8Array = new Uint8Array(0); try { - for (;;) { - const chunk = await reader.read(); - if (chunk.done) { - return; - } - buffered = concat(buffered, chunk.value); + for await (const chunk of chunksOf(reader)) { + buffered = concat(buffered, chunk); while (buffered.length >= LENGTH_PREFIX_BYTES) { const bodyLength = new DataView( buffered.buffer, @@ -103,6 +118,7 @@ export function connectionFromByteStream( : Promise.resolve(); // A failure to write the marker resurfaces on the first send, which awaits it. marked.catch(() => undefined); + return { send: async (frame) => { await marked; diff --git a/ts/packages/core/src/adapters/frame-codec.ts b/ts/packages/core/src/adapters/frame-codec.ts index 318157e4..44f81329 100644 --- a/ts/packages/core/src/adapters/frame-codec.ts +++ b/ts/packages/core/src/adapters/frame-codec.ts @@ -40,6 +40,7 @@ export function decodeMessage(data: Readonly): Frame { if (!result.success) { throw new SchemaInvalidFrameError(result.error.message); } + return result.data; } @@ -52,5 +53,6 @@ export function tryDecodeFrame(bytes: Readonly): Frame | null { return null; } const result = frameSchema.safeParse(decoded); + return result.success ? result.data : null; } diff --git a/ts/packages/core/src/adapters/memory-nonce-store.ts b/ts/packages/core/src/adapters/memory-nonce-store.ts index f54cbb85..72dda0cd 100644 --- a/ts/packages/core/src/adapters/memory-nonce-store.ts +++ b/ts/packages/core/src/adapters/memory-nonce-store.ts @@ -13,6 +13,7 @@ export function createMemoryNonceStore(): NonceStore { ); } nonces.set(sessionId, value); + return Promise.resolve(); }, take: async (sessionId) => { @@ -24,10 +25,12 @@ export function createMemoryNonceStore(): NonceStore { ); } nonces.delete(sessionId); + return Promise.resolve(Uint8Array.from(value)); }, discard: async (sessionId) => { nonces.delete(sessionId); + return Promise.resolve(); }, }; diff --git a/ts/packages/core/src/adapters/node-fs-storage.ts b/ts/packages/core/src/adapters/node-fs-storage.ts index 83bc096a..c517599c 100644 --- a/ts/packages/core/src/adapters/node-fs-storage.ts +++ b/ts/packages/core/src/adapters/node-fs-storage.ts @@ -12,6 +12,7 @@ export interface NodeFsStorageOptions { function isEnoent(error: unknown): boolean { if (!(error instanceof Error)) return false; if (!("code" in error)) return false; + return error.code === "ENOENT"; } @@ -28,14 +29,16 @@ async function listAllRelativePaths(baseDir: string): Promise { if (isEnoent(error)) return []; throw error; } - const files: string[] = []; - for (const entry of entries) { - const stat = await fs.stat(path.join(baseDir, entry)).catch(() => null); - if (stat?.isFile() === true) { - files.push(entry.split(path.sep).join("/")); - } - } - return files; + const stats = await Promise.all( + entries.map(async (entry) => ({ + entry, + stat: await fs.stat(path.join(baseDir, entry)).catch(() => null), + })), + ); + + return stats + .filter(({ stat }) => stat?.isFile() === true) + .map(({ entry }) => entry.split(path.sep).join("/")); } export function createNodeFsStorage( @@ -47,6 +50,7 @@ export function createNodeFsStorage( async get(key) { try { const contents = await fs.readFile(keyToFilePath(baseDir, key)); + return new Uint8Array(contents); } catch (error) { if (isEnoent(error)) return undefined; @@ -67,6 +71,7 @@ export function createNodeFsStorage( }, async keys(prefix) { const all = await listAllRelativePaths(baseDir); + return all.filter((key) => key.startsWith(prefix)); }, }; diff --git a/ts/packages/core/src/adapters/node-identity.ts b/ts/packages/core/src/adapters/node-identity.ts index 80f5d872..b83fdcc7 100644 --- a/ts/packages/core/src/adapters/node-identity.ts +++ b/ts/packages/core/src/adapters/node-identity.ts @@ -54,6 +54,7 @@ export async function verifyWithPublicKey( signature: Uint8Array, ): Promise { const cryptoKey = await importPublicKey(key); + return webcrypto.subtle.verify( algParams(key.alg), cryptoKey, @@ -103,6 +104,7 @@ export async function createNodeIdentity( privateKey, toBufferSource(message), ); + return new Uint8Array(signature); }, verify: verifyWithPublicKey, @@ -122,12 +124,14 @@ export async function createNodeIdentity( ); } const peerPublicKey = await importEs256PublicKeyForEcdh(peerKey); - const SHARED_SECRET_BIT_LENGTH = 256; // P-256's own field size + // P-256's own field size + const SHARED_SECRET_BIT_LENGTH = 256; const bits = await webcrypto.subtle.deriveBits( { name: "ECDH", public: peerPublicKey }, ecdhPrivateKey, SHARED_SECRET_BIT_LENGTH, ); + return new Uint8Array(bits); }, }; diff --git a/ts/packages/core/src/adapters/tcp-transport.ts b/ts/packages/core/src/adapters/tcp-transport.ts index d22cddc2..a818fd5d 100644 --- a/ts/packages/core/src/adapters/tcp-transport.ts +++ b/ts/packages/core/src/adapters/tcp-transport.ts @@ -10,6 +10,7 @@ function parseAddress(address: string): { host: string; port: number } { if (lastColon === -1) { throw new Error(`expected "host:port", got "${address}"`); } + return { host: address.slice(0, lastColon), port: Number(address.slice(lastColon + 1)), @@ -60,6 +61,7 @@ function frameReader(socket: Socket): AsyncIterable { : new Error(`frame body failed to decode: ${String(error)}`); failAll(connectionError); socket.destroy(connectionError); + return; } const result = frameSchema.safeParse(decoded); @@ -114,6 +116,7 @@ function frameReader(socket: Socket): AsyncIterable { if (ended) { return Promise.resolve({ value: undefined, done: true }); } + return new Promise((resolve, reject) => { waiters.push({ resolve, reject }); }); @@ -125,10 +128,12 @@ function frameReader(socket: Socket): AsyncIterable { function wrapSocket(socket: Socket): Connection { const frames = frameReader(socket); + return { // Socket.write is not itself async; the contract stays Promise-returning so other adapters (e.g. one with real backpressure/ack semantics) can be genuinely asynchronous. send: async (frame) => { writeFrame(socket, frame); + return Promise.resolve(); }, receive: () => frames, @@ -146,6 +151,7 @@ export function createTcpTransport(): Transport { return { async connect(address) { const { host, port } = parseAddress(address); + return new Promise((resolve, reject) => { const socket = netConnect({ host, port }); socket.once("connect", () => { @@ -156,6 +162,7 @@ export function createTcpTransport(): Transport { }, async listen(address, onConnection): Promise { const { host, port } = parseAddress(address); + return new Promise((resolve, reject) => { const server = createServer((socket) => { onConnection(wrapSocket(socket)); @@ -166,6 +173,7 @@ export function createTcpTransport(): Transport { if (bound === null || typeof bound === "string") { // A TCP server's bound address is always an AddressInfo object; null only if the server were not listening, which cannot hold inside this listening callback reject(new Error("listener did not report a bound address")); + return; } const listener: Listener = { diff --git a/ts/packages/core/src/adapters/threshold-dkg.ts b/ts/packages/core/src/adapters/threshold-dkg.ts index 73672019..5d54e89e 100644 --- a/ts/packages/core/src/adapters/threshold-dkg.ts +++ b/ts/packages/core/src/adapters/threshold-dkg.ts @@ -60,7 +60,9 @@ interface PendingWaiter { /** One (verb-family, session-id)'s worth of per-sender-device delivery: a message that arrives before anyone is waiting for it is buffered; a waiter that arrives before the message does is queued. Mirrors mesh-session.ts's own backlog/waiter pattern for its incomingManageRequests iterator, scoped down to "exactly one value per device-id" rather than an ordered stream. */ class PerDeviceCollector { private readonly waiters = new Map>(); + private readonly backlog = new Map(); + private failure: Error | undefined; async awaitFrom(deviceHex: string): Promise { @@ -70,8 +72,10 @@ class PerDeviceCollector { const buffered = this.backlog.get(deviceHex); if (buffered !== undefined) { this.backlog.delete(deviceHex); + return buffered; } + return new Promise((resolve, reject) => { this.waiters.set(deviceHex, { resolve, reject }); }); @@ -198,13 +202,13 @@ export async function runFreshThresholdDkg( await Promise.all( options.otherParticipants.map(async (peer) => { - const command = buildKeygenRound1Command( - options.sessionId, - options.threshold, - allParticipants, - ownSplit.commitment, - { proofOfKnowledge: ownSplit.proofOfKnowledge }, - ); + const command = buildKeygenRound1Command({ + sessionId: options.sessionId, + threshold: options.threshold, + participants: allParticipants, + commitment: ownSplit.commitment, + proofOfKnowledge: ownSplit.proofOfKnowledge, + }); await options.session.sendManageRequest( command, scope, @@ -223,6 +227,7 @@ export async function runFreshThresholdDkg( `threshold.keygen-round1 from ${deviceIdToHex(peer)} is missing proof-of-knowledge, REQUIRED for a fresh DKG`, ); } + return { deviceId: peer, value: combineRound1Package( @@ -251,6 +256,7 @@ export async function runFreshThresholdDkg( const round2Entries: DeviceKeyed[] = await Promise.all( options.otherParticipants.map(async (peer): Promise => { const share = await collectors.round2.awaitFrom(deviceIdToHex(peer)); + return { deviceId: peer, value: Uint8Array.from(share) }; }), ); @@ -308,9 +314,9 @@ export async function runFreshThresholdDkg( return round3; } -// --- Reshare ------------------------------------------------------------- -// -// Resharing has no single symmetric choreography the way fresh DKG does: only the T SURVIVORS dealt from an existing share broadcast round 1 and send round 2; every member of the NEW participant set (survivors staying on and brand-new joiners alike) must independently collect all T survivors' round1 broadcasts, derive the combined public key package, and combine whatever round2 shares it received. A device that is both a survivor AND a member of the new set runs BOTH contributeThresholdReshare and joinThresholdReshare concurrently; a survivor that is leaving runs only contributeThresholdReshare (and is done -- it has no new share, no confirm round to take part in); a brand-new device with no prior share runs only joinThresholdReshare. +/* --- Reshare ------------------------------------------------------------- + + Resharing has no single symmetric choreography the way fresh DKG does: only the T SURVIVORS dealt from an existing share broadcast round 1 and send round 2; every member of the NEW participant set (survivors staying on and brand-new joiners alike) must independently collect all T survivors' round1 broadcasts, derive the combined public key package, and combine whatever round2 shares it received. A device that is both a survivor AND a member of the new set runs BOTH contributeThresholdReshare and joinThresholdReshare concurrently; a survivor that is leaving runs only contributeThresholdReshare (and is done -- it has no new share, no confirm round to take part in); a brand-new device with no prior share runs only joinThresholdReshare. */ export interface ContributeThresholdReshareOptions { session: Readonly; @@ -363,6 +369,7 @@ export function computeReshareContribution( const shareToSelf = r1.outgoing.find((entry) => bytesEqual(entry.deviceId, options.ownDeviceId), )?.value; + return { commitment: r1.commitment, outgoing: r1.outgoing, @@ -385,13 +392,13 @@ export async function sendReshareContribution( ); await Promise.all( recipients.map(async (peer) => { - const command = buildKeygenRound1Command( - options.sessionId, - options.newThreshold, - options.newParticipants, - commitmentParts, - { existingGroupKey: options.existingGroupKey }, - ); + const command = buildKeygenRound1Command({ + sessionId: options.sessionId, + threshold: options.newThreshold, + participants: options.newParticipants, + commitment: commitmentParts, + existingGroupKey: options.existingGroupKey, + }); await options.session.sendManageRequest( command, scope, @@ -427,6 +434,7 @@ export async function contributeThresholdReshare( ): Promise { const contribution = computeReshareContribution(options); await sendReshareContribution(options, contribution); + return contribution; } @@ -471,6 +479,7 @@ export async function joinThresholdReshare( const otherSurvivorEntries: DeviceKeyed[] = await Promise.all( options.otherSurvivors.map(async (peer): Promise => { const payload = await collectors.round1.awaitFrom(deviceIdToHex(peer)); + return { deviceId: peer, value: reshareCombineCommitmentParts(payload.commitment), diff --git a/ts/packages/core/src/adapters/threshold-identity.ts b/ts/packages/core/src/adapters/threshold-identity.ts index 375d2bd9..85bc00a4 100644 --- a/ts/packages/core/src/adapters/threshold-identity.ts +++ b/ts/packages/core/src/adapters/threshold-identity.ts @@ -118,6 +118,7 @@ export async function createThresholdIdentity( "aggregate threshold signature failed to verify against the group's own key -- refusing to return it", ); } + return signature; }, }; diff --git a/ts/packages/core/src/adapters/threshold-network-coordinator.ts b/ts/packages/core/src/adapters/threshold-network-coordinator.ts index 2fcfb282..6031cb37 100644 --- a/ts/packages/core/src/adapters/threshold-network-coordinator.ts +++ b/ts/packages/core/src/adapters/threshold-network-coordinator.ts @@ -47,6 +47,7 @@ function isUint8Array(value: unknown): value is Uint8Array { function isDeviceIdShaped(value: unknown): value is DeviceId { const DEVICE_ID_BYTE_LENGTH = 32; + return isUint8Array(value) && value.length === DEVICE_ID_BYTE_LENGTH; } @@ -71,6 +72,7 @@ function parseCommitOk( ) { return undefined; } + return { participant, hiding, binding }; } @@ -107,6 +109,7 @@ export function createThresholdNetworkCoordinator( if (!bytesEqual(parsed.participant, participant)) { return undefined; } + return { deviceId: participant, value: combineCommitments(parsed.hiding, parsed.binding), @@ -114,6 +117,7 @@ export function createThresholdNetworkCoordinator( }, ), ); + return responses.filter( (entry): entry is SigningCommitmentEntry => entry !== undefined, ); @@ -122,6 +126,7 @@ export function createThresholdNetworkCoordinator( async signRound(sessionId, commitments) { const wireCommitments = commitments.map((entry: DeviceKeyed) => { const { hiding, binding } = splitCommitments(entry.value); + return { participant: entry.deviceId, hiding, binding }; }); const command = buildSignCommand(sessionId, wireCommitments); @@ -164,10 +169,12 @@ export function createThresholdNetworkCoordinator( ) { return undefined; } + return { deviceId: participant, value: claims.share }; }, ), ); + return responses.filter( (entry): entry is SignatureShareEntry => entry !== undefined, ); diff --git a/ts/packages/core/src/adapters/threshold-participant.ts b/ts/packages/core/src/adapters/threshold-participant.ts index c072ddcc..426773cf 100644 --- a/ts/packages/core/src/adapters/threshold-participant.ts +++ b/ts/packages/core/src/adapters/threshold-participant.ts @@ -84,6 +84,7 @@ async function authorizeIncomingRequest( if (!verdict.ok) { return false; } + return ( verdict.claims.capability === THRESHOLD_SIGN_VERB && verdict.claims.scope.kind === THRESHOLD_GROUP_SCOPE.kind @@ -103,6 +104,7 @@ export function startThresholdParticipant( if (refusal !== undefined) { return refusal; } + return options.authorise?.(subject) ?? { authorise: true }; } @@ -117,6 +119,7 @@ export function startThresholdParticipant( ): Promise { if (params.deadline <= options.clock.now()) { await incoming.respond({ result: "error", code: "deadline-passed" }); + return; } const keyPackage = options.keyPackagesByGroup.get( @@ -124,6 +127,7 @@ export function startThresholdParticipant( ); if (keyPackage === undefined) { await incoming.respond({ result: "error", code: "unknown-group" }); + return; } const decision = decide(params.subject); @@ -133,6 +137,7 @@ export function startThresholdParticipant( code: "refused", message: decision.reason, }); + return; } @@ -173,6 +178,7 @@ export function startThresholdParticipant( message: "threshold.sign for a session-id with no prior threshold.commit", }); + return; } @@ -187,6 +193,7 @@ export function startThresholdParticipant( message: "no persisted, unused nonce pair for this session -- already released, expired, or never persisted", }); + return; } diff --git a/ts/packages/core/src/adapters/threshold-wasm.ts b/ts/packages/core/src/adapters/threshold-wasm.ts index 6681a840..5fa4e507 100644 --- a/ts/packages/core/src/adapters/threshold-wasm.ts +++ b/ts/packages/core/src/adapters/threshold-wasm.ts @@ -50,6 +50,7 @@ function fromParallelArrays( value: toBufferSource(value), }); } + return result; } @@ -67,6 +68,7 @@ export function dkgRound1( minSigners: number, ): DkgRound1Result { const out = wasm.dkg_round1(ownDeviceId, maxSigners, minSigners); + return { secretPackage: toBufferSource(out.secretPackage), package: toBufferSource(out.package), @@ -92,6 +94,7 @@ export function splitRound1Package( } commitment.push(toBufferSource(coefficient)); } + return { commitment, proofOfKnowledge: toBufferSource(out.proofOfKnowledge), @@ -120,6 +123,7 @@ export function dkgRound2( ): DkgRound2Result { const [ids, packages] = toParallelArrays(round1); const out = wasm.dkg_round2(ownSecretPackage, ids, packages); + return { secretPackage: toBufferSource(out.secretPackage), outgoing: fromParallelArrays(out.recipientIds, out.packages), @@ -147,6 +151,7 @@ export function dkgRound3( ids2, packages2, ); + return { keyPackage: toBufferSource(out.keyPackage), publicKeyPackage: toBufferSource(out.publicKeyPackage), @@ -160,6 +165,7 @@ export function dkgTranscriptDigest( groupVerifyingKey: Uint8Array, ): Uint8Array { const [ids, packages] = toParallelArrays(allRound1); + return toBufferSource( wasm.dkg_transcript_digest(ids, packages, groupVerifyingKey), ); @@ -201,6 +207,7 @@ export function signingRound1Commit( ownKeyPackage: Uint8Array, ): SigningRound1Result { const out = wasm.signing_round1_commit(ownKeyPackage); + return { nonces: toBufferSource(out.nonces), commitments: toBufferSource(out.commitments), @@ -217,6 +224,7 @@ export function splitCommitments( commitments: Uint8Array, ): SplitCommitmentsResult { const out = wasm.split_commitments(commitments); + return { hiding: toBufferSource(out.hiding), binding: toBufferSource(out.binding), @@ -237,6 +245,7 @@ export function signingBuildPackage( message: Uint8Array, ): Uint8Array { const [ids, values] = toParallelArrays(commitments); + return toBufferSource(wasm.signing_build_package(ids, values, message)); } @@ -260,6 +269,7 @@ export function signingAggregate( publicKeyPackage: Uint8Array, ): Uint8Array { const [ids, values] = toParallelArrays(shares); + return toBufferSource( wasm.signing_aggregate(signingPackage, ids, values, publicKeyPackage), ); @@ -289,6 +299,7 @@ export function reshareRound1( [...newParticipantDeviceIds], newMinSigners, ); + return { commitment: toBufferSource(out.commitment), outgoing: fromParallelArrays(out.recipientIds, out.shares), @@ -308,6 +319,7 @@ export function reshareSplitCommitment( } out.push(toBufferSource(part)); } + return out; } @@ -331,6 +343,7 @@ export function reshareTranscriptDigest( groupVerifyingKey: Uint8Array, ): Uint8Array { const [ids, commitments] = toParallelArrays(survivorCommitments); + return toBufferSource( wasm.reshare_transcript_digest(ids, commitments, groupVerifyingKey), ); @@ -349,6 +362,7 @@ export function reshareDerivePublicKeyPackage( const out = wasm.reshare_derive_public_key_package(combinedCommitment, [ ...newParticipantDeviceIds, ]); + return { publicKeyPackage: toBufferSource(out.publicKeyPackage), groupVerifyingKey: toBufferSource(out.groupVerifyingKey), diff --git a/ts/packages/core/src/adapters/tls-transport.ts b/ts/packages/core/src/adapters/tls-transport.ts index ffac8c2e..e7729e8a 100644 --- a/ts/packages/core/src/adapters/tls-transport.ts +++ b/ts/packages/core/src/adapters/tls-transport.ts @@ -20,6 +20,7 @@ function parseAddress(address: string): { host: string; port: number } { if (lastColon === -1) { throw new Error(`expected "host:port", got "${address}"`); } + return { host: address.slice(0, lastColon), port: Number(address.slice(lastColon + 1)), @@ -69,6 +70,7 @@ function frameReader(socket: TLSSocket): AsyncIterable { : new Error(`frame body failed to decode: ${String(error)}`); failAll(connectionError); socket.destroy(connectionError); + return; } const result = frameSchema.safeParse(decoded); @@ -120,6 +122,7 @@ function frameReader(socket: TLSSocket): AsyncIterable { if (ended) { return Promise.resolve({ value: undefined, done: true }); } + return new Promise((resolve, reject) => { waiters.push({ resolve, reject }); }); @@ -138,6 +141,7 @@ async function authenticatedPeerDeviceId( if (Object.keys(cert).length === 0 || !("pubkey" in cert)) { return undefined; } + return deriveDeviceId(cert.pubkey); } @@ -146,9 +150,11 @@ function wrapSocket( peerDeviceId: DeviceId | undefined, ): Connection { const frames = frameReader(socket); + return { send: async (frame) => { writeFrame(socket, frame); + return Promise.resolve(); }, receive: () => frames, @@ -173,6 +179,7 @@ export function createTlsTransport(identity: Readonly): Transport { cert: identity.certificatePem, rejectUnauthorized: false, }; + return { async connect(address) { const { host, port } = parseAddress(address); @@ -183,10 +190,12 @@ export function createTlsTransport(identity: Readonly): Transport { s.once("error", reject); }); const peerDeviceId = await authenticatedPeerDeviceId(socket); + return wrapSocket(socket, peerDeviceId); }, async listen(address, onConnection): Promise { const { host, port } = parseAddress(address); + return new Promise((resolve, reject) => { const server = createServer( { ...tlsOptions, requestCert: true }, @@ -201,6 +210,7 @@ export function createTlsTransport(identity: Readonly): Transport { const bound = server.address(); if (bound === null || typeof bound === "string") { reject(new Error("listener did not report a bound address")); + return; } const listener: Listener = { diff --git a/ts/packages/core/src/domain/advert-extension-policy.ts b/ts/packages/core/src/domain/advert-extension-policy.ts index 5af744be..2d5523c1 100644 --- a/ts/packages/core/src/domain/advert-extension-policy.ts +++ b/ts/packages/core/src/domain/advert-extension-policy.ts @@ -44,5 +44,6 @@ export function checkAdvertExtensions( return { ok: false, key }; } } + return { ok: true }; } diff --git a/ts/packages/core/src/domain/async-queue.ts b/ts/packages/core/src/domain/async-queue.ts index 1a52b240..226af89d 100644 --- a/ts/packages/core/src/domain/async-queue.ts +++ b/ts/packages/core/src/domain/async-queue.ts @@ -10,6 +10,7 @@ export interface AsyncQueue { export function createAsyncQueue(): AsyncQueue { const waiters: ((value: T) => void)[] = []; const backlog: T[] = []; + return { push(value) { const waiter = waiters.shift(); diff --git a/ts/packages/core/src/domain/bulk.ts b/ts/packages/core/src/domain/bulk.ts index 5ee81af4..6fe052bf 100644 --- a/ts/packages/core/src/domain/bulk.ts +++ b/ts/packages/core/src/domain/bulk.ts @@ -21,6 +21,7 @@ import type { IncomingManageRequest, MeshSession } from "./mesh-session.js"; import type { Connection } from "../ports/transport.js"; import type { KeyValueStorage } from "../ports/storage.js"; import { bytesToHex } from "./device-id.js"; +import { eachInOrder } from "./sequential.js"; const TRANSFER_ID_BYTE_LENGTH = 16; @@ -31,6 +32,7 @@ export const BULK_READ_CAPABILITY = "bulk:read"; function randomTransferId(): Uint8Array { const bytes = new Uint8Array(TRANSFER_ID_BYTE_LENGTH); crypto.getRandomValues(bytes); + return bytes; } @@ -43,6 +45,7 @@ async function digestOf(bytes: Uint8Array): Promise> { "SHA-256", new Uint8Array(bytes).buffer, ); + return new Uint8Array(buffer); } @@ -51,6 +54,7 @@ function bytesEqual(a: Uint8Array, b: Uint8Array): boolean { for (let i = 0; i < a.length; i += 1) { if (a[i] !== b[i]) return false; } + return true; } @@ -62,12 +66,13 @@ function concatChunks(chunks: readonly Uint8Array[]): Uint8Array { out.set(chunk, offset); offset += chunk.length; } + return out; } -// --------------------------------------------------------------------- -// Command builders -// --------------------------------------------------------------------- +/* --------------------------------------------------------------------- + Command builders + --------------------------------------------------------------------- */ export function buildBulkOpenCommand( transferId: Uint8Array, @@ -117,9 +122,9 @@ export function buildBulkCancelCommand( }; } -// --------------------------------------------------------------------- -// Sender -// --------------------------------------------------------------------- +/* --------------------------------------------------------------------- + Sender + --------------------------------------------------------------------- */ export interface BulkSendOutcome { status: "complete" | "incomplete" | "rejected"; @@ -223,21 +228,40 @@ export function createBulkSender( // Chunks sent but not yet covered by the receiver's own ack-seq -- needed because window is a byte credit while ack-seq is a chunk count, so "how many bytes are currently outstanding" can only be derived by tracking each unacked chunk's own length. const inFlight: { seq: number; length: number }[] = []; + /** Takes one ack's credit in: it replaces the window, and every chunk it covers is retired. */ + const applyAck = (ack: Readonly): void => { + ackedCount = ack["ack-seq"]; + window = ack.window; + // A chunk with seq s is covered once ackedCount > s (ack-seq counts persisted chunks, so ackedCount chunks 0..ackedCount-1 are durable). + for ( + let head = inFlight[0]; + head !== undefined && head.seq < ackedCount; + head = inFlight[0] + ) { + inFlight.shift(); + bytesInFlight -= head.length; + } + }; + const exceedsWindow = (length: number): boolean => + bytesInFlight + length > window; + /** Takes acks until `length` more bytes fit in the window. */ + const awaitCredit = async (length: number): Promise => { + applyAck(await waitForAck(id)); + if (exceedsWindow(length)) { + await awaitCredit(length); + } + }; + try { for await (const chunk of options.source(fromSeq)) { if (cancelled.has(id)) { return { status: "incomplete", ackedCount }; } - while (bytesInFlight + chunk.length > window) { - const ack = await waitForAck(id); - ackedCount = ack["ack-seq"]; - window = ack.window; - for (;;) { - const head = inFlight[0]; - // A chunk with seq s is covered once ackedCount > s (ack-seq counts persisted chunks, so ackedCount chunks 0..ackedCount-1 are durable). - if (head === undefined || head.seq >= ackedCount) break; - inFlight.shift(); - bytesInFlight -= head.length; + // The first ack is awaited here rather than inside awaitCredit: an ack arriving while nothing is waiting for one is dropped, and a connection that ends while nothing waits rejects nothing, so the turns between a chunk's send and the next registered wait are kept to the ones the loop itself takes. + if (exceedsWindow(chunk.length)) { + applyAck(await waitForAck(id)); + if (exceedsWindow(chunk.length)) { + await awaitCredit(chunk.length); } } const dataFrame: BulkDataFrame = { @@ -267,6 +291,7 @@ export function createBulkSender( digest, }; await connection.send(endFrame); + return { status: "complete", ackedCount: seq }; } @@ -276,6 +301,7 @@ export function createBulkSender( transferId: Uint8Array, ): Promise { const ack = await waitForAck(transferIdHex(transferId)); + return stream(connection, transferId, ack["ack-seq"], ack.window); } @@ -300,6 +326,7 @@ export function createBulkSender( const parsed = bulkCancelSchema.safeParse(incoming.command.params); if (!parsed.success) { await incoming.respond({ result: "error", code: "malformed" }); + return; } cancelled.add(transferIdHex(parsed.data["transfer-id"])); @@ -331,11 +358,13 @@ export function createBulkSender( code: outcome.code, }; } + return resumeStream(connection, transferId); }, async continueFrom(connection, transferId) { activeTransferId = transferIdHex(transferId); activeConnection = connection; + return resumeStream(connection, transferId); }, createResumeHandler(connection, onResumed) { @@ -345,6 +374,7 @@ export function createBulkSender( const parsed = bulkResumeSchema.safeParse(incoming.command.params); if (!parsed.success) { await incoming.respond({ result: "error", code: "malformed" }); + return; } const { "transfer-id": transferId } = parsed.data; @@ -353,6 +383,7 @@ export function createBulkSender( result: "error", code: "unknown_transfer", }); + return; } await incoming.respond({ result: "ok" }); @@ -365,9 +396,9 @@ export function createBulkSender( }; } -// --------------------------------------------------------------------- -// Receiver -// --------------------------------------------------------------------- +/* --------------------------------------------------------------------- + Receiver + --------------------------------------------------------------------- */ function openerKey(idHex: string): string { return `bulk/${idHex}/opener`; @@ -462,9 +493,11 @@ export function storedTransferSource( ): (fromSeq: number) => AsyncIterable> { const { storage, transferId } = options; const idHex = transferIdHex(transferId); + return (fromSeq: number) => ({ [Symbol.asyncIterator](): AsyncIterator> { let seq = fromSeq; + return { async next(): Promise>> { const ackedBytes = await storage.get(ackSeqKey(idHex)); @@ -480,6 +513,7 @@ export function storedTransferSource( ); } seq += 1; + return { value: chunk, done: false }; }, }; @@ -498,6 +532,7 @@ export function createBulkReceiver( async function readAckSeq(idHex: string): Promise { const bytes = await storage.get(ackSeqKey(idHex)); + return bytes === undefined ? 0 : decodeUint(bytes); } @@ -510,7 +545,8 @@ export function createBulkReceiver( if (frame.type === "bulk-data") { const ackedCount = await readAckSeq(idHex); - if (frame.seq !== ackedCount) return; // non-contiguous: dropped, per this receiver's own first-pass simplification (see bulk.ts's own header comment) + // non-contiguous: dropped, per this receiver's own first-pass simplification (see bulk.ts's own header comment) + if (frame.seq !== ackedCount) return; await storage.set(chunkKey(idHex, frame.seq), frame.bytes); const newAckedCount = ackedCount + 1; await storage.set(ackSeqKey(idHex), encodeUint(newAckedCount)); @@ -523,16 +559,20 @@ export function createBulkReceiver( window, }; await connection.send(ackFrame); + return; } // bulk-end: verify the claimed digest against every durably-persisted chunk before treating the transfer as genuinely complete (spec/bulk.cddl's own obligation 3). const ackedCount = await readAckSeq(idHex); const chunks: Uint8Array[] = []; - for (let seq = 0; seq < ackedCount; seq += 1) { - const chunk = await storage.get(chunkKey(idHex, seq)); - if (chunk !== undefined) chunks.push(chunk); - } + await eachInOrder( + Array.from({ length: ackedCount }, (_, seq) => seq), + async (seq) => { + const chunk = await storage.get(chunkKey(idHex, seq)); + if (chunk !== undefined) chunks.push(chunk); + }, + ); const digest = await digestOf(concatChunks(chunks)); const ok = bytesEqual(digest, frame.digest); reg.onComplete?.({ ok, chunkCount: ackedCount }); @@ -544,6 +584,7 @@ export function createBulkReceiver( const parsed = bulkOpenSchema.safeParse(incoming.command.params); if (!parsed.success) { await incoming.respond({ result: "error", code: "malformed" }); + return; } const { "transfer-id": transferId } = parsed.data; @@ -551,6 +592,7 @@ export function createBulkReceiver( const existingOpener = await storage.get(openerKey(idHex)); if (existingOpener !== undefined) { await incoming.respond({ result: "error", code: "already_exists" }); + return; } @@ -572,6 +614,7 @@ export function createBulkReceiver( ? { message: decision.reason } : {}), }); + return; } await storage.set(openerKey(idHex), openerDevice); @@ -644,6 +687,7 @@ export function createBulkReceiver( window, }; await connection.send(ackFrame); + return { status: "ok" }; }, createCancelHandler() { @@ -653,6 +697,7 @@ export function createBulkReceiver( const parsed = bulkCancelSchema.safeParse(incoming.command.params); if (!parsed.success) { await incoming.respond({ result: "error", code: "malformed" }); + return; } registrations.delete(transferIdHex(parsed.data["transfer-id"])); diff --git a/ts/packages/core/src/domain/capability-grant.ts b/ts/packages/core/src/domain/capability-grant.ts index 70c00177..1fc875cc 100644 --- a/ts/packages/core/src/domain/capability-grant.ts +++ b/ts/packages/core/src/domain/capability-grant.ts @@ -92,11 +92,13 @@ export function createCapabilityGrantHandler( ): Promise { if (incoming.command.verb !== options.capability) { await incoming.respond({ result: "error", code: "malformed" }); + return; } const parsed = capabilityGrantSchema.safeParse(incoming.command.params); if (!parsed.success) { await incoming.respond({ result: "error", code: "malformed" }); + return; } const grantedToken = parsed.data["granted-token"]; @@ -109,6 +111,7 @@ export function createCapabilityGrantHandler( }); if (!verdict.ok) { await incoming.respond({ result: "error", code: verdict.reason }); + return; } @@ -117,11 +120,13 @@ export function createCapabilityGrantHandler( result: "error", code: "capability_mismatch", }); + return; } if (!scopeNarrows(verdict.claims.scope, incoming.scope)) { await incoming.respond({ result: "error", code: "scope_mismatch" }); + return; } diff --git a/ts/packages/core/src/domain/capability-request.ts b/ts/packages/core/src/domain/capability-request.ts index 76b44fed..b06edd10 100644 --- a/ts/packages/core/src/domain/capability-request.ts +++ b/ts/packages/core/src/domain/capability-request.ts @@ -33,6 +33,7 @@ const TOKEN_ID_BYTE_LENGTH = 16; function randomTokenId(): Uint8Array { const bytes = new Uint8Array(TOKEN_ID_BYTE_LENGTH); crypto.getRandomValues(bytes); + return bytes; } @@ -68,6 +69,7 @@ export function buildCapabilityRequestCommand( /** A capability request the other side answered with a manage-error. The code is the receiver's own (`denied` for a human's no, `token_required`, `capability_mismatch` or `scope_mismatch` from the request-permission gate, or a verifier reason for a presented token it did not accept), kept as a field so a caller can tell a policy refusal from a person's decision without parsing the message. */ export class CapabilityRequestRefusedError extends Error { readonly capability: string; + readonly code: string; constructor(capability: string, code: string) { @@ -109,6 +111,7 @@ export async function requestCapability( `capability request for "${capability}" response was malformed`, ); } + return parsed.data; } @@ -199,6 +202,7 @@ async function checkRequestPermission( if (!scopeNarrows(verdict.claims.scope, incoming.scope)) { return { ok: false, code: "scope_mismatch" }; } + return { ok: true }; } @@ -214,6 +218,7 @@ export function createCapabilityRequestHandler( const parsed = capabilityRequestSchema.safeParse(incoming.command.params); if (!parsed.success || parsed.data.capability !== options.capability) { await incoming.respond({ result: "error", code: "malformed" }); + return; } const params = parsed.data; @@ -224,12 +229,14 @@ export function createCapabilityRequestHandler( result: "error", code: verdict.code, }); + return; } } const validUntil = params["valid-until"]; if (validUntil !== undefined && validUntil <= options.clock.now()) { await incoming.respond({ result: "error", code: "expired" }); + return; } @@ -263,6 +270,7 @@ export function createCapabilityRequestHandler( ? { message: decision.reason } : {}), }); + return; } const verdict = await mintCapabilityToken({ @@ -279,6 +287,7 @@ export function createCapabilityRequestHandler( }); if (!verdict.ok) { await incoming.respond({ result: "error", code: "mint_failed" }); + return; } const grantedToken: CapabilityToken = verdict.token; diff --git a/ts/packages/core/src/domain/coordinator-election.ts b/ts/packages/core/src/domain/coordinator-election.ts index e91304f0..e6b54423 100644 --- a/ts/packages/core/src/domain/coordinator-election.ts +++ b/ts/packages/core/src/domain/coordinator-election.ts @@ -1,8 +1,8 @@ -// The gossiped, term-based coordinator election over coordinator-frame (spec/transport.cddl): pure state, no session or transport dependencies, so the same logic runs in any consumer exactly the way relay-hub.ts's own transport-agnostic core does. The spec fixes the whole algorithm and this module is only its mechanics: `term` is a monotonically increasing epoch a peer raises when claiming the role; a higher term always supersedes a lower one, and two simultaneous claims at equal terms are broken by lowest device-id, a verifier-side obligation the CDDL itself cannot enforce (the same idiom capability-token delegation's narrowing rule and room-notice ordering already use). This gives the rendezvous role the same crash-recovery property a port-race coordinator has (any live peer can take over by raising the term) without relying on OS-level port contention to do the electing. -// -// Ownership of when to claim, when to re-gossip, and what the role grants stays with the caller: this module only answers "given the claims I have seen, who holds the role" and mints a claim that beats them. A holder refreshing its claim for late joiners re-announces the incumbent unchanged (announceCurrent) rather than raising the term, because term inflation without a contest would make every refresh a takeover and destroy the monotonic epoch's meaning. -// -// Gossip propagation is the caller's transport concern, matching how peer-advert already travels: a claim this module mints reaches exactly the peers the sender's gossip reaches, and a claim evaluated here arrived the same way. There is no membership list to scope an election to (the mesh is the live connected component of the transport graph, nothing more), which is why the spec gossips this frame the same way it gossips peer-advert rather than addressing it to a roster. +/* The gossiped, term-based coordinator election over coordinator-frame (spec/transport.cddl): pure state, no session or transport dependencies, so the same logic runs in any consumer exactly the way relay-hub.ts's own transport-agnostic core does. The spec fixes the whole algorithm and this module is only its mechanics: `term` is a monotonically increasing epoch a peer raises when claiming the role; a higher term always supersedes a lower one, and two simultaneous claims at equal terms are broken by lowest device-id, a verifier-side obligation the CDDL itself cannot enforce (the same idiom capability-token delegation's narrowing rule and room-notice ordering already use). This gives the rendezvous role the same crash-recovery property a port-race coordinator has (any live peer can take over by raising the term) without relying on OS-level port contention to do the electing. + + Ownership of when to claim, when to re-gossip, and what the role grants stays with the caller: this module only answers "given the claims I have seen, who holds the role" and mints a claim that beats them. A holder refreshing its claim for late joiners re-announces the incumbent unchanged (announceCurrent) rather than raising the term, because term inflation without a contest would make every refresh a takeover and destroy the monotonic epoch's meaning. + + Gossip propagation is the caller's transport concern, matching how peer-advert already travels: a claim this module mints reaches exactly the peers the sender's gossip reaches, and a claim evaluated here arrived the same way. There is no membership list to scope an election to (the mesh is the live connected component of the transport graph, nothing more), which is why the spec gossips this frame the same way it gossips peer-advert rather than addressing it to a roster. */ import type { CoordinatorFrame, DeviceId } from "../generated/protocol.js"; @@ -22,6 +22,7 @@ export function compareDeviceIds(a: DeviceId, b: DeviceId): number { if (left === undefined || right === undefined) break; if (left !== right) return left < right ? -1 : 1; } + return a.length - b.length; } @@ -45,6 +46,7 @@ export interface CoordinatorElectionOptions { export class CoordinatorElection { private readonly ownDevice: DeviceId; + private incumbent: CoordinatorClaim | undefined; constructor(options: Readonly) { @@ -86,6 +88,7 @@ export class CoordinatorElection { coordinator: this.ownDevice, ...(capacityHint !== undefined ? { capacityHint: capacityHint } : {}), }; + return frame; } @@ -94,6 +97,7 @@ export class CoordinatorElection { */ announceCurrent(): CoordinatorFrame | undefined { if (this.incumbent === undefined) return undefined; + return { type: "coordinator", term: this.incumbent.term, @@ -125,8 +129,10 @@ export class CoordinatorElection { ? { capacityHint: frame["capacity-hint"] } : {}), }; + return { outcome: "accepted", incumbent: this.incumbent }; } + return { outcome: "retained", incumbent: previous }; } } diff --git a/ts/packages/core/src/domain/data-sync.ts b/ts/packages/core/src/domain/data-sync.ts index f8a1db87..473b8ebf 100644 --- a/ts/packages/core/src/domain/data-sync.ts +++ b/ts/packages/core/src/domain/data-sync.ts @@ -11,6 +11,7 @@ import type { import type { IdentityPort } from "../ports/identity.js"; import type { KeyValueStorage } from "../ports/storage.js"; import { deviceIdToHex } from "./device-id.js"; +import { eachInOrder } from "./sequential.js"; function headKey(peer: DeviceId): string { return `data/${deviceIdToHex(peer)}/head`; @@ -26,6 +27,7 @@ async function readHeadSeq( ): Promise { const raw = await storage.get(headKey(peer)); if (raw === undefined) return 0; + return Number(new TextDecoder().decode(raw)); } @@ -66,6 +68,7 @@ export async function appendOwnEntry( const seq = currentHead + 1; await options.storage.set(entryKey(options.identity.deviceId, seq), entry); await writeHeadSeq(options.storage, options.identity.deviceId, seq); + return { seq, haveFrame: { @@ -85,9 +88,32 @@ export async function handleDataHave( ): Promise { const ourHead = await readHeadSeq(storage, frame.peer); if (ourHead >= frame["head-seq"]) return null; + return { type: "data-request", peer: frame.peer, "from-seq": ourHead }; } +/** + * The entries stored for `peer` from sequence `first` to `last` inclusive, read one after another and cut at the first hole: a hole is a record missing from a range the head claims is held, which should not happen while entryKey and headKey stay consistent, and stopping there is better than returning a run with a silent gap that a receiver's own check would only catch after the fact. Keys past a hole are never read. + */ +async function readRun( + storage: Readonly, + peer: DeviceId, + first: number, + last: number, +): Promise[]> { + const run: Uint8Array[] = []; + const readFrom = async (seq: number): Promise => { + if (seq > last) return; + const entry = await storage.get(entryKey(peer, seq)); + if (entry === undefined) return; + run.push(entry); + await readFrom(seq + 1); + }; + await readFrom(first); + + return run; +} + /** * Handles an incoming data-request: the entries this store actually holds for `frame.peer` after `frame["from-seq"]`, capped at `limit`, or null if it holds none past that point. The responder need not be `frame.peer` itself -- any peer holding a replica may answer on the original author's behalf, per data-domain.cddl's own fan-out rule; this function makes no distinction. */ @@ -99,16 +125,14 @@ export async function handleDataRequest( const ourHead = await readHeadSeq(storage, frame.peer); if (ourHead <= frame["from-seq"]) return null; const lastSeq = Math.min(ourHead, frame["from-seq"] + limit); - const entries: Uint8Array[] = []; - for (let seq = frame["from-seq"] + 1; seq <= lastSeq; seq += 1) { - const entry = await storage.get(entryKey(frame.peer, seq)); - if (entry === undefined) { - // A hole in this store's own records for a range headSeqFor claims it holds -- shouldn't happen if entryKey/headKey stay consistent, but stop rather than send a data-entries frame with a silent gap a receiver's own check would only catch after the fact. - break; - } - entries.push(entry); - } + const entries = await readRun( + storage, + frame.peer, + frame["from-seq"] + 1, + lastSeq, + ); if (entries.length === 0) return null; + return { type: "data-entries", peer: frame.peer, @@ -131,12 +155,19 @@ export async function handleDataEntries( if (frame["from-seq"] !== ourHead) { return { ok: false, reason: "gap" }; } - let seq = frame["from-seq"]; - for (const entry of frame.entries) { - seq += 1; - await storage.set(entryKey(frame.peer, seq), entry); - } - await writeHeadSeq(storage, frame.peer, seq); + // Written in ascending sequence, the head last, so a failure part way leaves a contiguous prefix the head does not yet cover. + await eachInOrder(frame.entries, async (entry, index) => { + await storage.set( + entryKey(frame.peer, frame["from-seq"] + 1 + index), + entry, + ); + }); + await writeHeadSeq( + storage, + frame.peer, + frame["from-seq"] + frame.entries.length, + ); + return { ok: true }; } @@ -149,11 +180,6 @@ export async function readEntries( fromSeqExclusive: number, ): Promise { const head = await readHeadSeq(storage, peer); - const entries: Uint8Array[] = []; - for (let seq = fromSeqExclusive + 1; seq <= head; seq += 1) { - const entry = await storage.get(entryKey(peer, seq)); - if (entry === undefined) break; - entries.push(entry); - } - return entries; + + return readRun(storage, peer, fromSeqExclusive + 1, head); } diff --git a/ts/packages/core/src/domain/device-id.ts b/ts/packages/core/src/domain/device-id.ts index 06993538..cdc51cf9 100644 --- a/ts/packages/core/src/domain/device-id.ts +++ b/ts/packages/core/src/domain/device-id.ts @@ -4,7 +4,8 @@ import { deviceIdSchema, type DeviceId } from "../generated/protocol.js"; const HEX_RADIX = 16; const HEX_BYTE_WIDTH = 2; -const DEVICE_ID_HEX_LENGTH = 64; // 32 bytes, hex-encoded +// 32 bytes, hex-encoded +const DEVICE_ID_HEX_LENGTH = 64; /** Lowercase, byte-exact hex for an arbitrary-length byte string -- the same encoding convention deviceIdToHex uses for the fixed-length device-id case, extracted so any other byte string needing a stable, displayable, map-keyable text form (e.g. a token-id, which tokens.cddl defines as an arbitrary-length bstr rather than a 32-byte device-id) can use the identical convention without going through a device-id-shaped function. */ export function bytesToHex(bytes: Uint8Array): string { @@ -12,6 +13,7 @@ export function bytesToHex(bytes: Uint8Array): string { for (const byte of bytes) { hex += byte.toString(HEX_RADIX).padStart(HEX_BYTE_WIDTH, "0"); } + return hex; } @@ -34,6 +36,7 @@ export function bytesFromHex(hex: string): Uint8Array { HEX_RADIX, ); } + return bytes; } @@ -44,5 +47,6 @@ export function deviceIdFromHex(hex: string): DeviceId { `expected a 64-character lowercase hex string, got ${JSON.stringify(hex)}`, ); } + return deviceIdSchema.parse(bytesFromHex(hex)); } diff --git a/ts/packages/core/src/domain/direct-manage-request.ts b/ts/packages/core/src/domain/direct-manage-request.ts index 8381c87b..e5c48a8f 100644 --- a/ts/packages/core/src/domain/direct-manage-request.ts +++ b/ts/packages/core/src/domain/direct-manage-request.ts @@ -22,6 +22,7 @@ export async function acceptDirectManageRequest( return null; } const frame = result.value; + return { requestId: frame["request-id"], command: frame.command, @@ -105,5 +106,6 @@ export async function sendDirectManageRequest( responsePromise.catch(() => undefined); } await link.close(); + return outcome; } diff --git a/ts/packages/core/src/domain/erasure-coding.ts b/ts/packages/core/src/domain/erasure-coding.ts index ce5bb870..4e19eb10 100644 --- a/ts/packages/core/src/domain/erasure-coding.ts +++ b/ts/packages/core/src/domain/erasure-coding.ts @@ -40,6 +40,7 @@ const gfLog: number[] = Array.from({ length: GF_MAX_SHARDS + 1 }, () => 0); function gfMul(a: number, b: number): number { if (a === 0 || b === 0) return 0; + return gfExp[(gfLog[a] ?? 0) + (gfLog[b] ?? 0)] ?? 0; } @@ -74,11 +75,13 @@ function encodeMatrixRow(row: number, dataShards: number): number[] { const coefficients: number[] = Array.from({ length: dataShards }, () => 0); if (row < dataShards) { coefficients[row] = 1; + return coefficients; } for (let column = 0; column < dataShards; column += 1) { coefficients[column] = gfInv(row ^ column); } + return coefficients; } @@ -91,6 +94,7 @@ function invertMatrix(matrix: readonly number[][]): number[][] { ...Array.from({ length: size }, () => 0), ]; augmented[size + rowIndex] = 1; + return augmented; }); for (let column = 0; column < size; column += 1) { @@ -131,6 +135,7 @@ function invertMatrix(matrix: readonly number[][]): number[][] { } } } + return work.map((row) => row.slice(size)); } @@ -169,6 +174,7 @@ export async function encodeShards( } } } + return Promise.resolve(shards); } @@ -214,8 +220,8 @@ export async function decodeShards( // Recover the data shards' full byte content (not just the original prefix): the systematic rows let the all-data case pass through, any other mix goes through the inverted submatrix. const recovered: Uint8Array[] = []; if (chosen.every((entry) => entry.index < dataShards)) { - // All data shards survived: identity passthrough, in DATA-shard order - // (the caller's array order is arbitrary). + /* All data shards survived: identity passthrough, in DATA-shard order + (the caller's array order is arbitrary). */ for (let dataIndex = 0; dataIndex < dataShards; dataIndex += 1) { const entry = chosen.find((candidate) => candidate.index === dataIndex); if (entry === undefined) { @@ -261,5 +267,6 @@ export async function decodeShards( } stitched[position] = byte; } + return Promise.resolve(stitched); } diff --git a/ts/packages/core/src/domain/gossip-expansion.ts b/ts/packages/core/src/domain/gossip-expansion.ts index 15e79436..ea896d07 100644 --- a/ts/packages/core/src/domain/gossip-expansion.ts +++ b/ts/packages/core/src/domain/gossip-expansion.ts @@ -58,23 +58,35 @@ export function createGossipExpansion( approved = await options.shouldExpand(candidate); } catch (error) { options.onExpansionFailed?.(candidate, [toError(error)]); + return; } if (!approved) { options.onExpansionDeclined?.(candidate); + return; } - const errors: Error[] = []; - for (const address of candidate.addresses) { - try { - const session = await options.dial(address); - options.onExpanded(candidate, address, session); - return; - } catch (error) { - errors.push(toError(error)); - } + await dialInOrder(candidate, candidate.addresses, []); + } + + /** Dials the addresses one at a time, in order, stopping at the first that connects: a later address is only tried once every earlier one has failed, and when none connects the failures, in order, are reported through `onExpansionFailed`. */ + async function dialInOrder( + candidate: Readonly, + addresses: readonly string[], + failures: readonly Error[], + ): Promise { + const [address, ...remaining] = addresses; + if (address === undefined) { + options.onExpansionFailed?.(candidate, failures); + + return; + } + try { + const session = await options.dial(address); + options.onExpanded(candidate, address, session); + } catch (error) { + await dialInOrder(candidate, remaining, [...failures, toError(error)]); } - options.onExpansionFailed?.(candidate, errors); } return { diff --git a/ts/packages/core/src/domain/grant-candidates.ts b/ts/packages/core/src/domain/grant-candidates.ts index 56f265f9..c51fbfd5 100644 --- a/ts/packages/core/src/domain/grant-candidates.ts +++ b/ts/packages/core/src/domain/grant-candidates.ts @@ -61,5 +61,6 @@ export function findGrantCandidate( } } } + return undefined; } diff --git a/ts/packages/core/src/domain/group-key.ts b/ts/packages/core/src/domain/group-key.ts index 491e9e6f..cfbf9f4a 100644 --- a/ts/packages/core/src/domain/group-key.ts +++ b/ts/packages/core/src/domain/group-key.ts @@ -5,11 +5,15 @@ */ const BITS_PER_BYTE = 8; -const AES_KEY_BYTE_LENGTH = 32; // AES-256 +// AES-256 +const AES_KEY_BYTE_LENGTH = 32; const AES_KEY_BIT_LENGTH = AES_KEY_BYTE_LENGTH * BITS_PER_BYTE; -const GCM_IV_BYTE_LENGTH = 12; // 96-bit, the size AES-GCM is specified and optimised for -const UINT32_BYTE_LENGTH = 4; // the room-path length prefix in hkdfInfo's own encoding -const UINT64_BYTE_LENGTH = 8; // the key-epoch field in hkdfInfo's own encoding +// 96-bit, the size AES-GCM is specified and optimised for +const GCM_IV_BYTE_LENGTH = 12; +// the room-path length prefix in hkdfInfo's own encoding +const UINT32_BYTE_LENGTH = 4; +// the key-epoch field in hkdfInfo's own encoding +const UINT64_BYTE_LENGTH = 8; const HKDF_INFO_PREFIX = new TextEncoder().encode("wire-mesh/room-rekey/v1\0"); @@ -48,6 +52,7 @@ function hkdfInfo( info.set(roomBytes, offset); offset += roomBytes.length; info.set(keyEpoch, offset); + return info; } @@ -65,6 +70,7 @@ export async function deriveWrappingKey( false, ["deriveKey"], ); + return crypto.subtle.deriveKey( { name: "HKDF", @@ -94,6 +100,7 @@ async function aesGcmEncrypt( const out = new Uint8Array(iv.length + ciphertext.length); out.set(iv, 0); out.set(ciphertext, iv.length); + return out; } @@ -106,6 +113,7 @@ async function aesGcmDecrypt( } const iv = ivAndCiphertext.slice(0, GCM_IV_BYTE_LENGTH); const ciphertext = ivAndCiphertext.slice(GCM_IV_BYTE_LENGTH); + return new Uint8Array( await crypto.subtle.decrypt({ name: "AES-GCM", iv }, key, ciphertext), ); diff --git a/ts/packages/core/src/domain/handshake.ts b/ts/packages/core/src/domain/handshake.ts index 072e4594..b2528882 100644 --- a/ts/packages/core/src/domain/handshake.ts +++ b/ts/packages/core/src/domain/handshake.ts @@ -32,6 +32,7 @@ export function negotiate( (domain) => !RETIRED_DOMAINS.includes(domain) && remote.domains.includes(domain), ); + return { ok: version >= 1 && sharedDomains.length > 0, version, diff --git a/ts/packages/core/src/domain/hub-mailbox.ts b/ts/packages/core/src/domain/hub-mailbox.ts index e94239eb..30edac01 100644 --- a/ts/packages/core/src/domain/hub-mailbox.ts +++ b/ts/packages/core/src/domain/hub-mailbox.ts @@ -1,8 +1,8 @@ -// The announcer role of a relay hub (discovery.cddl's mailboxes, data-domain.cddl): a replicator that holds other devices' `core/data` logs while those devices are offline, so a peer that reconnects later can catch up from the hub instead of waiting for the author to be back. It is an ordinary replicator of the primitive data-sync.ts already implements, with the one thing a replicator open to anyone needs that data-sync.ts leaves to its caller: who may write, and how much. -// -// Only the device that owns a log may write to it. The hub takes `data-have` and `data-entries` for a log from the connection whose verified advert names that device, and from no one else, so nobody can fill or poison another device's log; entries are opaque to it and the applications that write them sign and encrypt their own, which is what lets a reader trust what comes back. Any device the hub has an identity for may read a log: the directory it registers from is already open to every client, and what a log adds is metadata the entries' own encryption does not hide. Whether that stays open is the hub-directory decision (wire-mesh#260), not one made here. -// -// Bounded three ways, all set by the host: how many logs it holds, how many bytes any one log may grow to, and how large one entry may be. A frame that would exceed a bound is refused whole and nothing of it is kept, so a log never holds a prefix the writer believes it sent in full. Nothing is ever evicted: a full log stays full until the host clears it, and the writer learns of that only by its entries not being asked for. +/* The announcer role of a relay hub (discovery.cddl's mailboxes, data-domain.cddl): a replicator that holds other devices' `core/data` logs while those devices are offline, so a peer that reconnects later can catch up from the hub instead of waiting for the author to be back. It is an ordinary replicator of the primitive data-sync.ts already implements, with the one thing a replicator open to anyone needs that data-sync.ts leaves to its caller: who may write, and how much. + + Only the device that owns a log may write to it. The hub takes `data-have` and `data-entries` for a log from the connection whose verified advert names that device, and from no one else, so nobody can fill or poison another device's log; entries are opaque to it and the applications that write them sign and encrypt their own, which is what lets a reader trust what comes back. Any device the hub has an identity for may read a log: the directory it registers from is already open to every client, and what a log adds is metadata the entries' own encryption does not hide. Whether that stays open is the hub-directory decision (wire-mesh#260), not one made here. + + Bounded three ways, all set by the host: how many logs it holds, how many bytes any one log may grow to, and how large one entry may be. A frame that would exceed a bound is refused whole and nothing of it is kept, so a log never holds a prefix the writer believes it sent in full. Nothing is ever evicted: a full log stays full until the host clears it, and the writer learns of that only by its entries not being asked for. */ import type { DataEntriesFrame, @@ -82,6 +82,7 @@ async function readCount( key: string, ): Promise { const raw = await storage.get(key); + return raw === undefined ? 0 : Number(new TextDecoder().decode(raw)); } @@ -99,14 +100,17 @@ export function createMailbox(options: Readonly): Mailbox { ): Promise { if (!sameDevice(sender, frame.peer)) { refuse("not-the-owner"); + return null; } if ((await headSeqFor(storage, frame.peer)) === 0) { if ((await readCount(storage, LOG_COUNT_KEY)) >= limits.maxLogs) { refuse("log-limit"); + return null; } } + return handleDataHave(storage, frame); } @@ -117,16 +121,19 @@ export function createMailbox(options: Readonly): Mailbox { ): Promise { if (!sameDevice(sender, frame.peer)) { refuse("not-the-owner"); + return null; } if (frame.entries.some((entry) => entry.length > limits.maxEntryBytes)) { refuse("size-limit"); + return null; } const held = await readCount(storage, logBytesKey(frame.peer)); const added = frame.entries.reduce((sum, entry) => sum + entry.length, 0); if (held + added > limits.maxLogBytes) { refuse("entry-limit"); + return null; } const isNewLog = (await headSeqFor(storage, frame.peer)) === 0; @@ -135,11 +142,13 @@ export function createMailbox(options: Readonly): Mailbox { (await readCount(storage, LOG_COUNT_KEY)) >= limits.maxLogs ) { refuse("log-limit"); + return null; } const stored = await handleDataEntries(storage, frame); if (!stored.ok) { refuse("gap"); + return null; } await storage.set(logBytesKey(frame.peer), encodeCount(held + added)); @@ -149,6 +158,7 @@ export function createMailbox(options: Readonly): Mailbox { encodeCount((await readCount(storage, LOG_COUNT_KEY)) + 1), ); } + return null; } @@ -159,12 +169,14 @@ export function createMailbox(options: Readonly): Mailbox { }; if (sender === undefined) { refuse("unregistered-sender"); + return null; } if (frame.type === "data-have") return handleHave(sender, frame, refuse); if (frame.type === "data-entries") { return handleEntries(sender, frame, refuse); } + return handleDataRequest(storage, frame, limits.readBatch); }, }; diff --git a/ts/packages/core/src/domain/mesh-session.ts b/ts/packages/core/src/domain/mesh-session.ts index fbdd230d..e53a7d88 100644 --- a/ts/packages/core/src/domain/mesh-session.ts +++ b/ts/packages/core/src/domain/mesh-session.ts @@ -297,6 +297,7 @@ function createSessionCore( const requestId = nextRequestId; nextRequestId += 1; const token = tokenOverride ?? currentToken; + return { type: "manage-request", "request-id": requestId, @@ -311,6 +312,7 @@ function createSessionCore( if (connection === null || state.status !== "connected") { throw new Error("not connected"); } + return connection; } @@ -325,6 +327,7 @@ function createSessionCore( } if (!viaRelay) { await connection.send(frame); + return; } if (toDevice === undefined) { @@ -385,6 +388,7 @@ function createSessionCore( }; if (isVersionGetCommand(frame.command.params)) { void incoming.respond(versionGetOutcome()); + return; } incomingQueue.push(incoming); @@ -397,6 +401,7 @@ function createSessionCore( if (inner?.type === "secure-hello") { recordFrame({ direction: "received", frame: inner }); await relayChannels.applyHello(inner); + return; } if (inner?.type === "secure-data") { @@ -423,20 +428,22 @@ function createSessionCore( : {}), }); } + return; } recordFrame({ direction: "received", frame }); + return; } recordFrame({ direction: "received", frame }); if (frame.type === "handshake") { applyRemoteHandshake(frame); } else if (frame.type === "gossip") { - for (const advert of frame.peers) { - // Verified here, never taken on the sender's word: an advert reaching this session was very often forwarded by a hub or a gateway rather than sent by the device it names, and a hub is a facilitator for the directory rather than an authority over it (wire-mesh#225). An advert that fails is dropped on its own; the frame's remaining entries are unaffected, since one forged entry says nothing about the others. - if (!(await verifyPeerAdvert(identity, advert))) { - continue; - } + // Verified here, never taken on the sender's word: an advert reaching this session was very often forwarded by a hub or a gateway rather than sent by the device it names, and a hub is a facilitator for the directory rather than an authority over it (wire-mesh#225). An advert that fails is dropped on its own; the frame's remaining entries are unaffected, since one forged entry says nothing about the others. + const verdicts = await Promise.all( + frame.peers.map(async (advert) => verifyPeerAdvert(identity, advert)), + ); + for (const advert of frame.peers.filter((_, i) => verdicts[i] === true)) { // Latest advert per device wins, order preserved by first insertion -- a re-advert updates in place. directory.set(deviceIdToHex(advert.device), { device: advert.device, @@ -543,6 +550,7 @@ function createSessionCore( ); }); }, reconnect.delayMs(currentAttempt)); + return; } state = { status: "closed", address, reason }; @@ -594,6 +602,7 @@ function createSessionCore( "snapshot-seconds": Math.floor(clock.now() / MS_PER_SECOND), "identity-key": identity.identityKey, }); + return { type: "gossip", peers: [advert] }; } @@ -657,6 +666,7 @@ function createSessionCore( const link = await dial(address); if (feedCancelled) { await link.close(); + return; } await wireUpConnection(link, address, localDomains); @@ -696,6 +706,7 @@ function createSessionCore( }, async sendPingMeasureRtt(timeoutMs?: number): Promise { const link = requireConnectedLink(); + return pingRoundTrips.sendAndAwait( clock.now(), async () => { @@ -742,11 +753,13 @@ function createSessionCore( throw error; } emit(); + return outcome; })(); if (timeoutMs === undefined) { return exchange; } + return Promise.race([ exchange, new Promise((resolve) => { @@ -867,6 +880,7 @@ export function createMeshSession( addresses, onFrame, ); + return session; } @@ -923,5 +937,6 @@ export async function acceptMeshSession( options.onSessionEnd, ); await wireUpConnection(connection, options.label ?? "accepted", localDomains); + return { ...session, peerDeviceId }; } diff --git a/ts/packages/core/src/domain/notice-board.ts b/ts/packages/core/src/domain/notice-board.ts index 559d7144..46214159 100644 --- a/ts/packages/core/src/domain/notice-board.ts +++ b/ts/packages/core/src/domain/notice-board.ts @@ -37,6 +37,7 @@ import { isEncryptedContentType, plaintextContentType, } from "./group-key.js"; +import { eachInOrder } from "./sequential.js"; /** The opaque byte length a caller-omitted noticeId gets: 16 random bytes, the same sizing token-id already uses. */ const GENERATED_NOTICE_ID_BYTE_LENGTH = 16; @@ -151,6 +152,7 @@ export function createNoticeBoard( { identity, storage }, noticeToEntryBytes(notice), ); + return { dataHave, notice, keyEpoch }; } @@ -178,10 +180,12 @@ export function createNoticeBoard( dmRootPolicy: "self" | "either-participant", ): Promise { const entries = await readEntries(storage, peer, 0); + const out: NoticeBoardEntry[] = []; - for (const entry of entries) { + await eachInOrder(entries, async (entry) => { out.push(await readOne(entry, room, dmRootPolicy)); - } + }); + return out; } @@ -229,6 +233,7 @@ export function createNoticeBoard( } try { const plaintext = await decryptNoticeContent(contentKey, claims.content); + return { ...base, plaintext }; } catch { // A held key that fails to decrypt is a genuine inconsistency (the notice wasn't encrypted under the key this epoch actually holds); report it as unreadable rather than as verified-and-empty. @@ -240,6 +245,7 @@ export function createNoticeBoard( try { const decoded: unknown = decode(entry, cdeDecodeOptions); const parsed = roomNoticeSchema.safeParse(decoded); + return parsed.success ? parsed.data : undefined; } catch { return undefined; diff --git a/ts/packages/core/src/domain/path-trace.ts b/ts/packages/core/src/domain/path-trace.ts index 40643c30..4561265b 100644 --- a/ts/packages/core/src/domain/path-trace.ts +++ b/ts/packages/core/src/domain/path-trace.ts @@ -40,6 +40,7 @@ export function buildPathTraceResponse( hubAddress?: string, ): ManageOutcome { const relayed = incoming.fromDevice !== undefined; + return { result: "ok", relayed, @@ -117,5 +118,6 @@ export async function tracePath( ? { hubAddress: parsed.data["hub-address"] } : {}), }; + return { rttMs, local, remote, outcome }; } diff --git a/ts/packages/core/src/domain/peer-advert.ts b/ts/packages/core/src/domain/peer-advert.ts index 6477e180..254a7b0d 100644 --- a/ts/packages/core/src/domain/peer-advert.ts +++ b/ts/packages/core/src/domain/peer-advert.ts @@ -1,6 +1,6 @@ -// Signing and verification for peer-advert (spec/transport.cddl), the one gossiped structure that travels beyond the connection it was sent on: a hub re-broadcasts what it receives, and a gateway forwards adverts for the local peers it fronts. Binding an advert to its arriving connection therefore cannot authenticate it: the signature is what ties an advert to the device it names, and `identity-key` travelling inside the signed content is what makes it self-certifying, so a receiver with no prior contact with that device and no directory to consult can still check it (wire-mesh#225). -// -// Kept as one pure module rather than a method on either consumer: the relay hub (which verifies before registering or re-broadcasting) and the mesh session (which verifies before applying an advert to its own directory) must agree byte-for-byte on what "valid" means, and a hub is a facilitator for the directory rather than an authority over it, so a receiver never delegates this check to whoever forwarded the advert. +/* Signing and verification for peer-advert (spec/transport.cddl), the one gossiped structure that travels beyond the connection it was sent on: a hub re-broadcasts what it receives, and a gateway forwards adverts for the local peers it fronts. Binding an advert to its arriving connection therefore cannot authenticate it: the signature is what ties an advert to the device it names, and `identity-key` travelling inside the signed content is what makes it self-certifying, so a receiver with no prior contact with that device and no directory to consult can still check it (wire-mesh#225). + + Kept as one pure module rather than a method on either consumer: the relay hub (which verifies before registering or re-broadcasting) and the mesh session (which verifies before applying an advert to its own directory) must agree byte-for-byte on what "valid" means, and a hub is a facilitator for the directory rather than an authority over it, so a receiver never delegates this check to whoever forwarded the advert. */ import { cdeEncodeOptions, encode } from "cbor2"; import type { @@ -69,6 +69,7 @@ export function peerAdvertSigningInput( ); input.set(PEER_ADVERT_SIGNING_CONTEXT, 0); input.set(encoded, PEER_ADVERT_SIGNING_CONTEXT.length); + return input; } @@ -78,6 +79,7 @@ export async function signPeerAdvert( advert: Readonly, ): Promise { const signature = await identity.sign(peerAdvertSigningInput(advert)); + return { ...advert, [PEER_ADVERT_SIGNATURE_KEY]: signature }; } @@ -96,6 +98,7 @@ export async function verifyPeerAdvert( if (!bytesEqual(derived, advert.device)) { return false; } + return await identity.verify( key, peerAdvertSigningInput(advert), diff --git a/ts/packages/core/src/domain/ping-round-trips.ts b/ts/packages/core/src/domain/ping-round-trips.ts index ba2635c5..4e488f5e 100644 --- a/ts/packages/core/src/domain/ping-round-trips.ts +++ b/ts/packages/core/src/domain/ping-round-trips.ts @@ -38,6 +38,7 @@ export function createPingRoundTrips(): PingRoundTrips { return false; } pending.splice(index, 1); + return true; } @@ -61,6 +62,7 @@ export function createPingRoundTrips(): PingRoundTrips { if (timeoutMs === undefined) { return rtt; } + return Promise.race([ rtt, new Promise((_resolve, reject) => { diff --git a/ts/packages/core/src/domain/pinned-address.ts b/ts/packages/core/src/domain/pinned-address.ts index 5e9d544a..f085d305 100644 --- a/ts/packages/core/src/domain/pinned-address.ts +++ b/ts/packages/core/src/domain/pinned-address.ts @@ -18,6 +18,7 @@ function hexToBytes(hex: string): Uint8Array { for (let index = 0; index < bytes.length; index++) { bytes[index] = Number.parseInt(hex.slice(index * 2, index * 2 + 2), 16); } + return bytes; } @@ -57,6 +58,7 @@ export function parsePinnedAddress(address: string): PinnedAddress { ); } url.hash = ""; + return { url: url.toString(), sha256: hashes.map(hexToBytes) }; } @@ -104,6 +106,7 @@ export function decodeCertificateHashes( } hashes.push(new Uint8Array(hash)); } + return hashes; } @@ -120,6 +123,7 @@ export function mergeHashes( merged.push(hash); } } + return merged; } @@ -136,5 +140,6 @@ export function withPinnedHashes( ): string { const { url } = parsePinnedAddress(address); const parsed = new URL(url); + return formatPinnedAddress(parsed.host, sha256.map(bytesToHex)); } diff --git a/ts/packages/core/src/domain/relay-advert.ts b/ts/packages/core/src/domain/relay-advert.ts index 91bc4eea..1dec7416 100644 --- a/ts/packages/core/src/domain/relay-advert.ts +++ b/ts/packages/core/src/domain/relay-advert.ts @@ -1,8 +1,8 @@ -// Relay offers carried in peer-advert's open extension tail, so a node's relay capability is discovered by the gossip that already propagates adverts rather than only learned in-band from a relay-offer-frame on a connection already established (spec/transport.cddl's relay role). A client choosing a relay, rather than just using one it was given, reads this extension from the directory and knows which peers offer relay service at which addresses. -// -// The value is the identical address list relay-offer-frame itself carries (`[* tstr]`), under the `wire-mesh` domain the core's own gossiped facts already occupy (wire-mesh/version), per spec/CONVENTIONS.md's domain-qualification rule. It is deliberately a caller-supplied fact rather than a session-managed one: which addresses a deployment offers relay on is the operator's choice (an operator may gate relay use, the same way the spec leaves relay gating to an ordinary core/management verb), so the caller passes buildRelayOfferExtension's result to sendGossipUpdate exactly like a presence extension, and nothing here reserves the key against callers. -// -// Reading is fail-safe by design: an extension that is not an array of strings is treated as no offer at all rather than guessed at or thrown, because an offer is an opportunity a receiver may act on, never an obligation, and a malformed entry must not break directory rendering for the advert's other facts. +/* Relay offers carried in peer-advert's open extension tail, so a node's relay capability is discovered by the gossip that already propagates adverts rather than only learned in-band from a relay-offer-frame on a connection already established (spec/transport.cddl's relay role). A client choosing a relay, rather than just using one it was given, reads this extension from the directory and knows which peers offer relay service at which addresses. + + The value is the identical address list relay-offer-frame itself carries (`[* tstr]`), under the `wire-mesh` domain the core's own gossiped facts already occupy (wire-mesh/version), per spec/CONVENTIONS.md's domain-qualification rule. It is deliberately a caller-supplied fact rather than a session-managed one: which addresses a deployment offers relay on is the operator's choice (an operator may gate relay use, the same way the spec leaves relay gating to an ordinary core/management verb), so the caller passes buildRelayOfferExtension's result to sendGossipUpdate exactly like a presence extension, and nothing here reserves the key against callers. + + Reading is fail-safe by design: an extension that is not an array of strings is treated as no offer at all rather than guessed at or thrown, because an offer is an opportunity a receiver may act on, never an obligation, and a malformed entry must not break directory rendering for the advert's other facts. */ import type { PeerAdvert } from "../generated/protocol.js"; @@ -25,6 +25,7 @@ export function buildRelayOfferExtension( ); } } + return { [RELAY_OFFER_GOSSIP_KEY]: [...addresses] }; } @@ -42,5 +43,6 @@ export function readRelayOffer( addresses.push(entry); } if (addresses.length === 0) return undefined; + return addresses; } diff --git a/ts/packages/core/src/domain/relay-channels.ts b/ts/packages/core/src/domain/relay-channels.ts index 1d2baab1..efc64e21 100644 --- a/ts/packages/core/src/domain/relay-channels.ts +++ b/ts/packages/core/src/domain/relay-channels.ts @@ -74,6 +74,7 @@ export function createRelayChannels( channels.delete(key); } }); + return entry; } @@ -83,6 +84,7 @@ export function createRelayChannels( if (existing?.status === "established") { return existing.channel; } + return (existing ?? start(peer)).settle.promise; }, applyHello: async (hello) => { @@ -107,6 +109,7 @@ export function createRelayChannels( if (channels.get(key) === entry) { channels.delete(key); } + return; } channels.set(key, { status: "established", channel }); @@ -121,6 +124,7 @@ export function createRelayChannels( return undefined; } const frame = await entry.channel.open(data); + return frame === undefined ? undefined : { frame, from: entry.channel.peer.deviceId }; diff --git a/ts/packages/core/src/domain/relay-hub.ts b/ts/packages/core/src/domain/relay-hub.ts index 3350b6c5..18f5d20e 100644 --- a/ts/packages/core/src/domain/relay-hub.ts +++ b/ts/packages/core/src/domain/relay-hub.ts @@ -1,20 +1,20 @@ -// The hub's relay role, expressed purely against core's Transport port and the generated frame schemas -- no Worker-specific or WebSocket-specific type appears here, so the same logic runs under the TCP adapter in tests or any future transport. A connection's device-id is learned from its own gossiped peer-advert (the only spec frame that carries a device-id over a plain connection; TLS-cert identity extraction is deliberately out of scope for the WebSocket-ingress first pass, noted in the README). -// -// Registry semantics (wire-mesh#225). Every advert is verified before it is registered, forwarded, or replayed in a catch-up frame: the embedded identity-key must hash to the device the advert names, and the signature must verify under that key (see peer-advert.ts). Nothing else authenticates a gossiped device-id here, so an unverified advert would let any connection publish an entry under another device's id and draw that device's routed traffic to itself. Adverts for devices other than the gossiping connection's own are entirely legitimate, since a gateway forwards the adverts of the local peers it fronts, which is exactly why an advert is bound to the device that signed it rather than to the connection it arrived on. An advert that fails verification is dropped individually: the frame's remaining adverts are still registered and forwarded, since one forged entry says nothing about the others, and a frame whose adverts are all refused is dropped entirely rather than earning its sender a catch-up dump of the directory. -// -// A signature alone does not stop a connection replaying a victim's genuine, correctly-signed advert to take over its route, so registration additionally compares freshness. An advert naming a device currently registered to a DIFFERENT live connection takes that registration over only when its `snapshot-seconds` is strictly greater than the registered advert's; an advert from the connection that already holds the registration updates it when `snapshot-seconds` is greater than or equal, so an unchanged heartbeat from the rightful owner still refreshes what the hub holds. The limitation this leaves, stated rather than papered over: a device that reconnects within the same second while its previous connection is still open does not displace that connection until its next gossip carries a later second. A device-id mapping is still only removed on disconnect if it still points at the connection that registered it, so a re-announcement by a newer connection isn't clobbered by an older one leaving. -// -// Multiplexing: a connection may hold more than one relay pairing at once (a peer fanning a message out to several members of a group, all of whom are only reachable through this same hub). Pairings are therefore a symmetric adjacency map keyed by *connection*, not resolved through the device registry at forward time -- device-ids here are gossip-asserted, not certificate-verified, and re-resolving through the registry per frame would let a pairing silently re-attach to whichever connection most recently claimed a device-id, a spoofing vector. Keying by connection preserves the existing behaviour that an established pairing survives its peer's device mapping moving to a fresher connection, and dies only with the connection itself. `relay-data-frame`'s `to-device`/`from-device` fields disambiguate which pairing a frame belongs to now that a connection can hold several; a connection with exactly one pairing may omit `to-device`, which every legacy peer already does, so an unaddressed frame under multiplexing routes to the most recently established pairing -- the same "last one wins" semantics the old single-pairing hub already had. `to-device` is also echoed onward, unmodified, in the frame this hub delivers to the receiving connection, never just consumed for the routing lookup above: a receiving connection that itself fronts more than one locally-addressable device (a gateway advertising several local peers over one hub connection, wire-mesh#170) has no other way to tell which of its own devices a given frame was actually addressed to, since from its side every relay-data frame arrives multiplexed over the same single connection regardless of target. -// -// Gossip forwarding and catch-up (wire-mesh#110): every `gossip-frame` this hub receives triggers two things, beyond registering the advert locally. First, the frame's accepted adverts are re-broadcast, byte-identical to how they arrived, to every *other* currently-connected client (never back to the sender) -- unconditionally, on every frame, with no dedup against a previously-seen advert. Forwarding an advert verbatim is not an optimisation but a requirement: the signature covers every entry, so a hub that rebuilt or edited an advert on the way through would invalidate it for every receiver. This is deliberately not the same "never touches payload" blindness `relay-data` gets: the hub already parses a gossip frame's `peers` to populate its own device registry above, so forwarding it is extending existing, already-established visibility, not opening a new blind spot -- `relay-data`'s ciphertext payload stays untouched and unforwarded-to-third-parties, which is the invariant that actually matters for end-to-end confidentiality. Unconditional (no per-advert or per-recipient cache) is a deliberate choice, not an oversight: `peer-advert.snapshot-seconds`/`addresses` are meant to keep propagating on every re-gossip (a liveness heartbeat, an address change), so suppressing a "duplicate" would silently stop legitimate freshness updates from reaching other clients. Loop-safety needs no extra bookkeeping either: this hub only ever re-broadcasts to its own directly-connected clients (a star topology, one hop), and nothing here re-gossips a frame it received back onto the wire on its own initiative, so there is no path for a forwarded frame to cycle back through this hub a second time. A forward that fails (the target connection has died) is swallowed per-recipient so one dead peer never aborts delivery to the rest of the fan-out, or the sending connection's own frame processing -- that peer's own `handleConnection` loop notices the same death independently via its `receive()` stream and cleans up through the ordinary disconnect path. -// -// Second, the sender itself is sent a catch-up `gossip-frame` bundling every *other* currently-known device's latest advert. This is not optional polish: every client gossips its own self-advert exactly once, at connect time, and never repeats it on its own initiative (see `mesh-session.ts`'s `sendGossipUpdate` doc comment -- there is no re-advertisement timer built into a session, so a caller that never calls it again is "today's existing gossip-once-on-connect behaviour"). Forwarding alone therefore only reaches whichever clients happen to already be connected at the exact moment a given advert is gossiped; a client that connects even slightly later would otherwise never learn about anyone who gossiped before it, no matter how long it then stays connected, since nothing ever re-sends that earlier advert again. The catch-up frame closes this gap by replaying the hub's own already-known directory to every gossiping connection, not only ones that have never gossiped before -- a connection re-gossiping (an address change, a fresher `snapshot-seconds`) is caught up again too, which is harmless and correctly reflects current state under the same no-caching philosophy as the forward above. `Registration` keeps the full `peer-advert`, not just a bare device-id, specifically so this catch-up frame can be built from real, previously-received adverts rather than synthesising one. - -// Extension policy: because every entry is signed and an advert is forwarded verbatim, the hub cannot remove a field from an advert; with an extensionPolicy set it refuses, whole, any advert whose extension tail holds something the policy does not allow, exactly as it refuses one that fails verification (not registered, not forwarded, not replayed in a catch-up). Without one every extension is carried, which suits a hub all of whose clients belong to one mesh and is not safe for a hub open to strangers. -// -// Fronting (wire-mesh#311): a connection may speak for several devices, namely every device whose advert it carried and this hub registered to it. That is what a gateway is: a hub whose own clients are fronted to an upstream hub over one connection. Three frame fields carry it. A relay-connect names the initiating fronted device in `source-device`; a relay-data frame names the device it comes from in `from-device`; a relay-inbound names which of the receiving connection's devices was paired in `target-device`. In each direction the hub acts on a claim only when the device it names is registered to the connection making it, so a connection can never initiate or send as a device it does not front, and a relay-data claim additionally needs a pairing between the two devices named. The upstream hub therefore needs no gateway mode at all: it already does this for any connection that fronts. The gateway side is the uplink (handleUplink): the connection to the upstream hub is registered here like a client, so local adverts reach upstream through the ordinary gossip fan-out and upstream adverts reach local clients the same way, and it differs from a client in exactly three respects. A relay-connect naming a device registered to the uplink is carried upstream as a relay-connect from the initiating client's own device rather than answered here; a relay-inbound arriving on the uplink pairs the addressed local client with the upstream device it names and is passed on to that client; and the uplink is never sent a catch-up frame, since the upstream hub's reply to one would itself be answered with another, each hub forever re-announcing to the other. -// -// Outliving the hub instance (wire-mesh#223): registry and pairing state is held in memory and keyed by Connection, neither of which a host can hand to a successor instance directly. exportConnection/restoreConnections bridge that gap by expressing one connection's state as device-ids and adverts alone: serialisable, and resolvable back to connections by a successor that still holds them. A host that can be torn down while its connections stay open subscribes to onConnectionStateChanged, persists the exported value wherever it keeps per-connection state, and replays the survivors through restoreConnections before handling their next frame. The hub itself neither persists nor schedules anything: what a pairing is keyed by, and when a state changes, are its own business, and where that state is kept between instances is the host's. +/* The hub's relay role, expressed purely against core's Transport port and the generated frame schemas -- no Worker-specific or WebSocket-specific type appears here, so the same logic runs under the TCP adapter in tests or any future transport. A connection's device-id is learned from its own gossiped peer-advert (the only spec frame that carries a device-id over a plain connection; TLS-cert identity extraction is deliberately out of scope for the WebSocket-ingress first pass, noted in the README). + + Registry semantics (wire-mesh#225). Every advert is verified before it is registered, forwarded, or replayed in a catch-up frame: the embedded identity-key must hash to the device the advert names, and the signature must verify under that key (see peer-advert.ts). Nothing else authenticates a gossiped device-id here, so an unverified advert would let any connection publish an entry under another device's id and draw that device's routed traffic to itself. Adverts for devices other than the gossiping connection's own are entirely legitimate, since a gateway forwards the adverts of the local peers it fronts, which is exactly why an advert is bound to the device that signed it rather than to the connection it arrived on. An advert that fails verification is dropped individually: the frame's remaining adverts are still registered and forwarded, since one forged entry says nothing about the others, and a frame whose adverts are all refused is dropped entirely rather than earning its sender a catch-up dump of the directory. + + A signature alone does not stop a connection replaying a victim's genuine, correctly-signed advert to take over its route, so registration additionally compares freshness. An advert naming a device currently registered to a DIFFERENT live connection takes that registration over only when its `snapshot-seconds` is strictly greater than the registered advert's; an advert from the connection that already holds the registration updates it when `snapshot-seconds` is greater than or equal, so an unchanged heartbeat from the rightful owner still refreshes what the hub holds. The limitation this leaves, stated rather than papered over: a device that reconnects within the same second while its previous connection is still open does not displace that connection until its next gossip carries a later second. A device-id mapping is still only removed on disconnect if it still points at the connection that registered it, so a re-announcement by a newer connection isn't clobbered by an older one leaving. + + Multiplexing: a connection may hold more than one relay pairing at once (a peer fanning a message out to several members of a group, all of whom are only reachable through this same hub). Pairings are therefore a symmetric adjacency map keyed by *connection*, not resolved through the device registry at forward time -- device-ids here are gossip-asserted, not certificate-verified, and re-resolving through the registry per frame would let a pairing silently re-attach to whichever connection most recently claimed a device-id, a spoofing vector. Keying by connection preserves the existing behaviour that an established pairing survives its peer's device mapping moving to a fresher connection, and dies only with the connection itself. `relay-data-frame`'s `to-device`/`from-device` fields disambiguate which pairing a frame belongs to now that a connection can hold several; a connection with exactly one pairing may omit `to-device`, which every legacy peer already does, so an unaddressed frame under multiplexing routes to the most recently established pairing -- the same "last one wins" semantics the old single-pairing hub already had. `to-device` is also echoed onward, unmodified, in the frame this hub delivers to the receiving connection, never just consumed for the routing lookup above: a receiving connection that itself fronts more than one locally-addressable device (a gateway advertising several local peers over one hub connection, wire-mesh#170) has no other way to tell which of its own devices a given frame was actually addressed to, since from its side every relay-data frame arrives multiplexed over the same single connection regardless of target. + + Gossip forwarding and catch-up (wire-mesh#110): every `gossip-frame` this hub receives triggers two things, beyond registering the advert locally. First, the frame's accepted adverts are re-broadcast, byte-identical to how they arrived, to every *other* currently-connected client (never back to the sender) -- unconditionally, on every frame, with no dedup against a previously-seen advert. Forwarding an advert verbatim is not an optimisation but a requirement: the signature covers every entry, so a hub that rebuilt or edited an advert on the way through would invalidate it for every receiver. This is deliberately not the same "never touches payload" blindness `relay-data` gets: the hub already parses a gossip frame's `peers` to populate its own device registry above, so forwarding it is extending existing, already-established visibility, not opening a new blind spot -- `relay-data`'s ciphertext payload stays untouched and unforwarded-to-third-parties, which is the invariant that actually matters for end-to-end confidentiality. Unconditional (no per-advert or per-recipient cache) is a deliberate choice, not an oversight: `peer-advert.snapshot-seconds`/`addresses` are meant to keep propagating on every re-gossip (a liveness heartbeat, an address change), so suppressing a "duplicate" would silently stop legitimate freshness updates from reaching other clients. Loop-safety needs no extra bookkeeping either: this hub only ever re-broadcasts to its own directly-connected clients (a star topology, one hop), and nothing here re-gossips a frame it received back onto the wire on its own initiative, so there is no path for a forwarded frame to cycle back through this hub a second time. A forward that fails (the target connection has died) is swallowed per-recipient so one dead peer never aborts delivery to the rest of the fan-out, or the sending connection's own frame processing -- that peer's own `handleConnection` loop notices the same death independently via its `receive()` stream and cleans up through the ordinary disconnect path. + + Second, the sender itself is sent a catch-up `gossip-frame` bundling every *other* currently-known device's latest advert. This is not optional polish: every client gossips its own self-advert exactly once, at connect time, and never repeats it on its own initiative (see `mesh-session.ts`'s `sendGossipUpdate` doc comment -- there is no re-advertisement timer built into a session, so a caller that never calls it again is "today's existing gossip-once-on-connect behaviour"). Forwarding alone therefore only reaches whichever clients happen to already be connected at the exact moment a given advert is gossiped; a client that connects even slightly later would otherwise never learn about anyone who gossiped before it, no matter how long it then stays connected, since nothing ever re-sends that earlier advert again. The catch-up frame closes this gap by replaying the hub's own already-known directory to every gossiping connection, not only ones that have never gossiped before -- a connection re-gossiping (an address change, a fresher `snapshot-seconds`) is caught up again too, which is harmless and correctly reflects current state under the same no-caching philosophy as the forward above. `Registration` keeps the full `peer-advert`, not just a bare device-id, specifically so this catch-up frame can be built from real, previously-received adverts rather than synthesising one. */ + +/* Extension policy: because every entry is signed and an advert is forwarded verbatim, the hub cannot remove a field from an advert; with an extensionPolicy set it refuses, whole, any advert whose extension tail holds something the policy does not allow, exactly as it refuses one that fails verification (not registered, not forwarded, not replayed in a catch-up). Without one every extension is carried, which suits a hub all of whose clients belong to one mesh and is not safe for a hub open to strangers. + + Fronting (wire-mesh#311): a connection may speak for several devices, namely every device whose advert it carried and this hub registered to it. That is what a gateway is: a hub whose own clients are fronted to an upstream hub over one connection. Three frame fields carry it. A relay-connect names the initiating fronted device in `source-device`; a relay-data frame names the device it comes from in `from-device`; a relay-inbound names which of the receiving connection's devices was paired in `target-device`. In each direction the hub acts on a claim only when the device it names is registered to the connection making it, so a connection can never initiate or send as a device it does not front, and a relay-data claim additionally needs a pairing between the two devices named. The upstream hub therefore needs no gateway mode at all: it already does this for any connection that fronts. The gateway side is the uplink (handleUplink): the connection to the upstream hub is registered here like a client, so local adverts reach upstream through the ordinary gossip fan-out and upstream adverts reach local clients the same way, and it differs from a client in exactly three respects. A relay-connect naming a device registered to the uplink is carried upstream as a relay-connect from the initiating client's own device rather than answered here; a relay-inbound arriving on the uplink pairs the addressed local client with the upstream device it names and is passed on to that client; and the uplink is never sent a catch-up frame, since the upstream hub's reply to one would itself be answered with another, each hub forever re-announcing to the other. + + Outliving the hub instance (wire-mesh#223): registry and pairing state is held in memory and keyed by Connection, neither of which a host can hand to a successor instance directly. exportConnection/restoreConnections bridge that gap by expressing one connection's state as device-ids and adverts alone: serialisable, and resolvable back to connections by a successor that still holds them. A host that can be torn down while its connections stay open subscribes to onConnectionStateChanged, persists the exported value wherever it keeps per-connection state, and replays the survivors through restoreConnections before handling their next frame. The hub itself neither persists nor schedules anything: what a pairing is keyed by, and when a state changes, are its own business, and where that state is kept between instances is the host's. */ import type { DeviceId, Frame, PeerAdvert } from "../generated/protocol.js"; import type { Connection } from "../ports/transport.js"; @@ -119,6 +119,7 @@ function deviceKey(device: Uint8Array): string { for (const byte of device) { key += byte.toString(HEX_RADIX).padStart(HEX_DIGITS_PER_BYTE, "0"); } + return key; } @@ -131,6 +132,7 @@ function deviceFromKey(key: string): DeviceId { HEX_RADIX, ); } + return device; } @@ -146,6 +148,7 @@ function supersedes( ): boolean { const incoming = advert["snapshot-seconds"]; const held = registered.advert["snapshot-seconds"]; + return registered.connection === connection ? incoming >= held : incoming > held; @@ -183,6 +186,7 @@ export function createRelayHub(options: Readonly): RelayHub { } const created = new Map>(); pairings.set(connection, created); + return created; } @@ -198,6 +202,17 @@ export function createRelayHub(options: Readonly): RelayHub { mostRecentPairing.set(b, a); } + function forgetPairingsWith( + own: Map>, + connection: Readonly, + ): void { + for (const [key, paired] of own) { + if (paired === connection) { + own.delete(key); + } + } + } + function forgetConnection(connection: Readonly): void { connections.delete(connection); for (const [key, registration] of devices) { @@ -216,11 +231,7 @@ export function createRelayHub(options: Readonly): RelayHub { // A peer keys this connection by whichever of its devices the pairing was made with, and a fronting connection holds several, so every entry that points back at it goes. const peerOwn = pairings.get(peer); if (peerOwn) { - for (const [key, paired] of peerOwn) { - if (paired === connection) { - peerOwn.delete(key); - } - } + forgetPairingsWith(peerOwn, connection); } if (mostRecentPairing.get(peer) === connection) { mostRecentPairing.delete(peer); @@ -257,6 +268,7 @@ export function createRelayHub(options: Readonly): RelayHub { } } } + return { device, adverts, @@ -268,9 +280,9 @@ export function createRelayHub(options: Readonly): RelayHub { function restoreConnections( entries: readonly RelayConnectionRestore[], ): void { - // Restored adverts are registered without being re-verified, and that is correct rather than a gap: each one was already verified by the hub instance that accepted it, and the host is handing back the very bytes it persisted for a connection it still holds open. Re-checking would only re-derive the same verdict from the same bytes, at the cost of making restoreConnections asynchronous on a path that runs before the instance answers its first frame. An advert arriving on the wire afterwards is verified normally, and is compared for freshness against these restored ones exactly as against any other registration. - // - // Two passes, because a pairing names its peer by device-id and only the first pass establishes which connection each device-id belongs to. Keyed on each entry's own `device` rather than on the rebuilt `devices` directory: a pairing is with the connection that held the device-id when the pairing was made, which is exactly what that connection's own state records, whereas the directory may already have handed the id to a fresher connection. + /* Restored adverts are registered without being re-verified, and that is correct rather than a gap: each one was already verified by the hub instance that accepted it, and the host is handing back the very bytes it persisted for a connection it still holds open. Re-checking would only re-derive the same verdict from the same bytes, at the cost of making restoreConnections asynchronous on a path that runs before the instance answers its first frame. An advert arriving on the wire afterwards is verified normally, and is compared for freshness against these restored ones exactly as against any other registration. + + Two passes, because a pairing names its peer by device-id and only the first pass establishes which connection each device-id belongs to. Keyed on each entry's own `device` rather than on the rebuilt `devices` directory: a pairing is with the connection that held the device-id when the pairing was made, which is exactly what that connection's own state records, whereas the directory may already have handed the id to a fresher connection. */ const byDevice = new Map>(); for (const { connection, state } of entries) { connections.add(connection); @@ -320,6 +332,7 @@ export function createRelayHub(options: Readonly): RelayHub { if (claimed === undefined) { return connectionDevice.get(connection); } + return devices.get(deviceKey(claimed))?.connection === connection ? claimed : undefined; @@ -362,8 +375,15 @@ export function createRelayHub(options: Readonly): RelayHub { const supplanted = new Set>(); // Only the adverts that both verified and won their freshness comparison: what gets registered, forwarded, and replayed in catch-up frames. A refused advert is not forwarded either, so neither a forgery nor a replayed stale advert reaches another client through this hub. const accepted: PeerAdvert[] = []; - for (const advert of frame.peers) { - if (!(await verifyPeerAdvert(options.identity, advert))) { + const verified = await Promise.all( + frame.peers.map(async (advert) => + (await verifyPeerAdvert(options.identity, advert)) + ? advert + : undefined, + ), + ); + for (const advert of verified) { + if (advert === undefined) { continue; } if (options.extensionPolicy !== undefined) { @@ -410,16 +430,24 @@ export function createRelayHub(options: Readonly): RelayHub { } // Re-broadcast to every other currently-connected client, each accepted advert exactly as it arrived. See module header for why this is unconditional, undeduplicated, and loop-safe, and why an advert must never be rebuilt on the way through. A per-recipient send failure is swallowed so one dead peer never aborts the rest of the fan-out or this connection's own frame processing. const forwarded: Frame = { type: "gossip", peers: accepted }; - for (const other of connections) { - if (other === connection) { - continue; + // Walks the live set one connection at a time, as a loop over it would: a connection that registers meanwhile is reached too. + const forwardTo = async ( + remaining: Readonly>>, + ): Promise => { + const next = remaining.next(); + if (next.done === true) { + return; } - try { - await other.send(forwarded); - } catch { - // That peer's own connection has died; its own handleConnection loop will discover this independently via its receive() stream and clean up through the ordinary disconnect path. + if (next.value !== connection) { + try { + await next.value.send(forwarded); + } catch { + // That peer's own connection has died; its own handleConnection loop will discover this independently via its receive() stream and clean up through the ordinary disconnect path. + } } - } + await forwardTo(remaining); + }; + await forwardTo(connections.values()); // Catch up the sender with every other currently-known device's latest advert -- see module header for why forwarding alone is not sufficient for a late joiner. Never the uplink: see the module header's fronting paragraph for why two hubs must not catch each other up. const catchUp: PeerAdvert[] = []; for (const registration of devices.values()) { @@ -434,6 +462,7 @@ export function createRelayHub(options: Readonly): RelayHub { // The sender's own connection just died; its own handleConnection loop (the very call running this handleFrame) will observe this the same way any other send failure on this connection would. } } + return; } @@ -464,6 +493,7 @@ export function createRelayHub(options: Readonly): RelayHub { "target-device": registration.advert.device, "source-device": initiatorDevice, }); + return; } await registration.connection.send({ @@ -471,11 +501,13 @@ export function createRelayHub(options: Readonly): RelayHub { "source-device": initiatorDevice, "target-device": registration.advert.device, }); + return; } if (frame.type === "relay-inbound") { await handleUplinkInbound(connection, frame); + return; } @@ -508,6 +540,7 @@ export function createRelayHub(options: Readonly): RelayHub { // Echoed straight through from the frame this hub just received, not merely consumed for its own routing use above: a receiving connection fronting more than one locally-addressable device (a gateway) has no other way to learn which of its own devices the sender actually meant, since it's on the far side of a single multiplexed connection from the hub's own perspective. Omitted when the sender left it unaddressed, matching the legacy single-pairing convention (module header, "may omit to-device"). ...(toDevice !== undefined ? { "to-device": toDevice } : {}), }); + return; } @@ -523,6 +556,7 @@ export function createRelayHub(options: Readonly): RelayHub { // The sender's connection died between its frame and this reply; its own handleConnection loop, or the caller driving onFrame, observes that the same way any failed send is observed. } } + return; } @@ -533,6 +567,7 @@ export function createRelayHub(options: Readonly): RelayHub { } catch { // The connection died between receiving this ping and replying -- its own handleConnection loop (or the caller driving onFrame directly) will observe that independently via its receive() stream. } + return; } diff --git a/ts/packages/core/src/domain/relay-pairing.ts b/ts/packages/core/src/domain/relay-pairing.ts index 276bd28f..6f2e91c1 100644 --- a/ts/packages/core/src/domain/relay-pairing.ts +++ b/ts/packages/core/src/domain/relay-pairing.ts @@ -16,6 +16,7 @@ export interface RelayPairings { export function createRelayPairings(): RelayPairings { const established = new Map(); + return { has: (device) => established.has(deviceIdToHex(device)), add: (device) => { diff --git a/ts/packages/core/src/domain/relay-use-gate.ts b/ts/packages/core/src/domain/relay-use-gate.ts index cbab75bb..582286de 100644 --- a/ts/packages/core/src/domain/relay-use-gate.ts +++ b/ts/packages/core/src/domain/relay-use-gate.ts @@ -19,6 +19,7 @@ export interface RelayUseAuthorizationTracker { /** An in-memory, per-connection authorization set. Keyed by Connection identity (reference equality), matching RelayHub's own registry -- a connection that reconnects is a new object and starts unauthorized again, exactly as a fresh relay-hub registration would. */ export function createRelayUseAuthorizationTracker(): RelayUseAuthorizationTracker { const authorized = new Set>(); + return { isAuthorized: (connection) => authorized.has(connection), authorize: (connection) => { diff --git a/ts/packages/core/src/domain/revocation-view.ts b/ts/packages/core/src/domain/revocation-view.ts index 4e8635a7..4de57a6a 100644 --- a/ts/packages/core/src/domain/revocation-view.ts +++ b/ts/packages/core/src/domain/revocation-view.ts @@ -45,6 +45,7 @@ export function createRevocationView(): RevocationView { existing.push(verdict.claims); } } + return verdict; }, }; diff --git a/ts/packages/core/src/domain/room-path.ts b/ts/packages/core/src/domain/room-path.ts index 81187232..d0eab693 100644 --- a/ts/packages/core/src/domain/room-path.ts +++ b/ts/packages/core/src/domain/room-path.ts @@ -22,6 +22,7 @@ export function ownerNamedRoomPath(owner: string, localName: string): string { `expected localName to match [A-Za-z0-9_-]+, got ${JSON.stringify(localName)}`, ); } + return `${owner}/${localName}`; } @@ -32,6 +33,7 @@ export function dmRoomPath(a: string, b: string): string { if (a === b) { throw new Error(`a DM room path cannot name the same device twice (${a})`); } + return a < b ? `${a}+${b}` : `${b}+${a}`; } @@ -75,5 +77,6 @@ export function slugRoomName(name: string): string { `${JSON.stringify(name)} has no valid room-name characters to slug`, ); } + return slug; } diff --git a/ts/packages/core/src/domain/room-rekey.ts b/ts/packages/core/src/domain/room-rekey.ts index 142bd866..8704abf5 100644 --- a/ts/packages/core/src/domain/room-rekey.ts +++ b/ts/packages/core/src/domain/room-rekey.ts @@ -90,15 +90,18 @@ export function createRoomRekeyHandler( ): Promise { if (incoming.command.verb !== ROOM_MEMBER_CAPABILITY) { await incoming.respond({ result: "error", code: "malformed" }); + return; } const parsed = roomRekeySchema.safeParse(incoming.command.params); if (!parsed.success) { await incoming.respond({ result: "error", code: "malformed" }); + return; } if (incoming.scope.kind !== "room" || incoming.scope.path === undefined) { await incoming.respond({ result: "error", code: "scope_mismatch" }); + return; } const roomPath = incoming.scope.path; @@ -109,19 +112,21 @@ export function createRoomRekeyHandler( revocation: options.revocation, expectedBearer: options.identity.deviceId, roomPath, - // The rekey-scoped DM root rule -- see VerifyRoomTokenOptions's own - // dmRootPolicy doc comment for why this caller may accept either path - // participant where room.send never may. + /* The rekey-scoped DM root rule -- see VerifyRoomTokenOptions's own + dmRootPolicy doc comment for why this caller may accept either path + participant where room.send never may. */ dmRootPolicy: "either-participant", }); if (!verdict.ok) { await incoming.respond({ result: "error", code: verdict.reason }); + return; } const deriveSharedSecret = options.identity.deriveSharedSecret; if (deriveSharedSecret === undefined) { await incoming.respond({ result: "error", code: "ecdh_unsupported" }); + return; } @@ -140,11 +145,13 @@ export function createRoomRekeyHandler( room: roomPath, keyEpoch: firstEpoch + i, }); + return unwrapContentKey(wrappingKey, wrapped); }), ); } catch { await incoming.respond({ result: "error", code: "unwrap_failed" }); + return; } diff --git a/ts/packages/core/src/domain/room.ts b/ts/packages/core/src/domain/room.ts index affec857..45198453 100644 --- a/ts/packages/core/src/domain/room.ts +++ b/ts/packages/core/src/domain/room.ts @@ -51,6 +51,7 @@ function bytesEqual(a: Uint8Array, b: Uint8Array): boolean { for (let i = 0; i < a.length; i += 1) { if (a[i] !== b[i]) return false; } + return true; } @@ -66,6 +67,7 @@ function compareBytes(a: Uint8Array, b: Uint8Array): number { } if (byteA !== byteB) return byteA - byteB; } + return a.length - b.length; } @@ -152,12 +154,12 @@ export async function verifyRoomNotice( return { ok: false, reason: "content_expired" }; } - // Obligation 7 (room.cddl): key-epoch is present iff content-type names an encrypted - // content-type. Both directions of the mismatch are refused: an encrypted content-type - // with no epoch leaves a reader unable to know which epoch's key to decrypt under, and an - // epoch on a plaintext content-type names a key nothing was encrypted under. Checked here, - // structurally, before any cryptographic work -- a mismatched pairing is malformed data - // regardless of whether the signature itself happens to be valid. + /* Obligation 7 (room.cddl): key-epoch is present iff content-type names an encrypted + content-type. Both directions of the mismatch are refused: an encrypted content-type + with no epoch leaves a reader unable to know which epoch's key to decrypt under, and an + epoch on a plaintext content-type names a key nothing was encrypted under. Checked here, + structurally, before any cryptographic work -- a mismatched pairing is malformed data + regardless of whether the signature itself happens to be valid. */ const keyEpoch = claims["key-epoch"]; if ( isEncryptedContentType(claims["content-type"]) !== @@ -197,6 +199,7 @@ export function compareRoomNotices( } const posterOrder = compareBytes(a.poster, b.poster); if (posterOrder !== 0) return posterOrder; + return compareBytes(a["notice-id"], b["notice-id"]); } @@ -244,5 +247,6 @@ export async function createRoomNotice( const signature = await options.identity.sign( sig1ToBeSigned(protectedHeader, payload), ); + return [protectedHeader, {}, payload, signature]; } diff --git a/ts/packages/core/src/domain/secure-channel.ts b/ts/packages/core/src/domain/secure-channel.ts index 65bd09ab..5eef0367 100644 --- a/ts/packages/core/src/domain/secure-channel.ts +++ b/ts/packages/core/src/domain/secure-channel.ts @@ -41,6 +41,7 @@ function concat(...parts: readonly Uint8Array[]): Uint8Array { out.set(part, offset); offset += part.length; } + return out; } @@ -50,12 +51,14 @@ function compareBytes(a: Uint8Array, b: Uint8Array): number { const difference = (a[i] ?? 0) - (b[i] ?? 0); if (difference !== 0) return difference; } + return a.length - b.length; } function counterBytes(counter: number): Uint8Array { const bytes = new Uint8Array(COUNTER_BYTE_LENGTH); new DataView(bytes.buffer).setBigUint64(0, BigInt(counter), false); + return bytes; } @@ -97,6 +100,7 @@ export async function beginHandshake( const signature = await identity.sign( helloSigningInput(ephemeralKey, nonce, identity.deviceId, peer), ); + return { peer, privateKey: pair.privateKey, @@ -141,6 +145,7 @@ export async function verifyHello( ), hello.signature, ); + return valid ? { ok: true, peer: { deviceId: sender, identityKey: key } } : { ok: false, reason: "bad_signature" }; @@ -161,6 +166,7 @@ export interface SecureChannel { function ivFor(counter: number): Uint8Array { const iv = new Uint8Array(GCM_IV_BYTE_LENGTH); iv.set(counterBytes(counter), IV_COUNTER_OFFSET); + return iv; } @@ -172,13 +178,19 @@ function associatedData( return concat(DATA_CONTEXT, sender, recipient, counterBytes(counter)); } -async function deriveDirectionKey( - secret: Uint8Array, - salt: Uint8Array, - low: DeviceId, - high: DeviceId, - label: Uint8Array, -): Promise { +async function deriveDirectionKey({ + secret, + salt, + low, + high, + label, +}: Readonly<{ + secret: Uint8Array; + salt: Uint8Array; + low: DeviceId; + high: DeviceId; + label: Uint8Array; +}>): Promise { const material = await crypto.subtle.importKey( "raw", Uint8Array.from(secret), @@ -186,6 +198,7 @@ async function deriveDirectionKey( false, ["deriveKey"], ); + return crypto.subtle.deriveKey( { name: "HKDF", @@ -237,20 +250,20 @@ export async function establishChannel( const lowNonce = localIsLow ? pending.hello.nonce : remote.nonce; const highNonce = localIsLow ? remote.nonce : pending.hello.nonce; const salt = concat(lowNonce, highNonce); - const lowToHigh = await deriveDirectionKey( + const lowToHigh = await deriveDirectionKey({ secret, salt, low, high, - LABEL_LOW_TO_HIGH, - ); - const highToLow = await deriveDirectionKey( + label: LABEL_LOW_TO_HIGH, + }); + const highToLow = await deriveDirectionKey({ secret, salt, low, high, - LABEL_HIGH_TO_LOW, - ); + label: LABEL_HIGH_TO_LOW, + }); const sendKey = localIsLow ? lowToHigh : highToLow; const receiveKey = localIsLow ? highToLow : lowToHigh; @@ -278,6 +291,7 @@ export async function establishChannel( messageFromFrame(frame), ), ); + return { type: "secure-data", counter, ciphertext }; } @@ -306,7 +320,8 @@ export async function establishChannel( return undefined; } // Advanced only after the frame authenticated, so a forged counter cannot lock out the real ones. - highestReceived = data.counter; + highestReceived = Math.max(highestReceived, data.counter); + return tryDecodeFrame(plaintext) ?? undefined; } @@ -318,12 +333,14 @@ export async function establishChannel( // Sealing is chained so frames leave in counter order even if the runtime finishes two encryptions out of order: the receiver refuses a counter it has already passed. const sealed = sealing.then(async () => sealAt(frame, counter)); sealing = sealed.catch(() => undefined); + return sealed; }, open: async (data) => { // Chained for the same reason: two frames opening at once must not both pass the counter check before either has advanced it. const opened = opening.then(async () => openOne(data)); opening = opened.catch(() => undefined); + return opened; }, }; diff --git a/ts/packages/core/src/domain/sequential.ts b/ts/packages/core/src/domain/sequential.ts new file mode 100644 index 00000000..290207b5 --- /dev/null +++ b/ts/packages/core/src/domain/sequential.ts @@ -0,0 +1,27 @@ +/** + * The first item, in order, for which `predicate` resolves true, or undefined when none does. Items are tested strictly one after another and the search stops at the first match: a later item is never tested once an earlier one has matched, so a check that has to fail closed on the earliest offender, or whose later items must not run after an earlier one decided the outcome, keeps that order. + */ +export async function firstAsync( + items: readonly T[], + predicate: (item: T) => Promise, +): Promise { + const [head, ...remaining] = items; + if (head === undefined) { + return undefined; + } + + return (await predicate(head)) ? head : firstAsync(remaining, predicate); +} + +/** + * Runs `step` for each item, starting each only once the one before it has finished and stopping at the first that rejects, so side effects happen in item order and nothing after a failure runs. + */ +export async function eachInOrder( + items: readonly T[], + step: (item: T, index: number) => Promise, +): Promise { + await items.reduce(async (previous, item, index) => { + await previous; + await step(item, index); + }, Promise.resolve()); +} diff --git a/ts/packages/core/src/domain/shard-manifest.ts b/ts/packages/core/src/domain/shard-manifest.ts index 93677855..393002bb 100644 --- a/ts/packages/core/src/domain/shard-manifest.ts +++ b/ts/packages/core/src/domain/shard-manifest.ts @@ -38,6 +38,7 @@ export function encodeShardManifest( manifest: Readonly, ): Uint8Array { validateManifest(manifest); + return new Uint8Array( encode( { @@ -90,6 +91,7 @@ export function decodeShardManifest(entry: Uint8Array): ShardManifest { "shard manifest location is missing device/transfer-id bytes", ); } + // Fresh copies, the same whole-buffer discipline the identity adapters apply at runtime boundaries: cbor2's decoded views can cover a wider buffer. return { device: Uint8Array.from(device), @@ -104,6 +106,7 @@ export function decodeShardManifest(entry: Uint8Array): ShardManifest { shards: locations, }; validateManifest(manifest); + return manifest; } @@ -119,6 +122,7 @@ function targetFor( `no target device for shard ${String(index)} (${String(shard.length)} bytes)`, ); } + return target; } @@ -187,6 +191,7 @@ export async function splitForShardedDelivery( })), }; validateManifest(manifest); + return { manifest, shards, manifestEntry: encodeShardManifest(manifest) }; } @@ -211,5 +216,6 @@ export async function reconstructFromShards( if (options.decrypt === undefined) { return reconstructed; } + return options.decrypt(reconstructed); } diff --git a/ts/packages/core/src/domain/sharded-delivery.ts b/ts/packages/core/src/domain/sharded-delivery.ts index a4c11b92..c7f339c7 100644 --- a/ts/packages/core/src/domain/sharded-delivery.ts +++ b/ts/packages/core/src/domain/sharded-delivery.ts @@ -38,6 +38,7 @@ function bytesEqual(a: Uint8Array, b: Uint8Array): boolean { for (let i = 0; i < a.length; i += 1) { if (a[i] !== b[i]) return false; } + return true; } @@ -49,6 +50,7 @@ function concat(chunks: readonly Uint8Array[]): Uint8Array { out.set(chunk, offset); offset += chunk.length; } + return out; } @@ -61,6 +63,7 @@ async function readStoredTransferBytes( for await (const chunk of source(0)) { chunks.push(chunk); } + return concat(chunks); } @@ -71,11 +74,13 @@ function singleChunkSource( return (fromSeq: number) => ({ [Symbol.asyncIterator](): AsyncIterator> { let exhausted = fromSeq > 0; + return { next: async (): Promise>> => { if (exhausted) return Promise.resolve({ value: undefined, done: true }); exhausted = true; + return Promise.resolve({ value: chunk, done: false }); }, }; @@ -99,9 +104,9 @@ function pumpSenderAcks( })(); } -// --------------------------------------------------------------------- -// Sender -// --------------------------------------------------------------------- +/* --------------------------------------------------------------------- + Sender + --------------------------------------------------------------------- */ /** One already-established, already-authenticated connection+session this module pushes a shard over -- establishing and authenticating it (discovery, handshake, whatever reconnect policy applies) is the caller's own job, mirroring every other domain module here (notice-board.ts, capability-request.ts) that takes a live MeshSession rather than owning connection setup itself. */ export interface ShardChannel { @@ -180,6 +185,7 @@ export async function distributeShardedPayload( source: singleChunkSource(shardBytes), }); pumpSenderAcks(channel.connection, sender); + return sender.open(channel.connection, channel.session, scope, { transferId, targetDevice: location.device, @@ -193,12 +199,13 @@ export async function distributeShardedPayload( { identity, storage }, manifestEntry, ); + return { manifest, manifestEntry, dataHave, outcomes }; } -// --------------------------------------------------------------------- -// Holder (mailbox) -// --------------------------------------------------------------------- +/* --------------------------------------------------------------------- + Holder (mailbox) + --------------------------------------------------------------------- */ export interface ShardHolderOptions { storage: KeyValueStorage; @@ -213,9 +220,12 @@ export interface ShardHolder { serveShard: ( channel: Readonly, transferId: Uint8Array, - targetDevice: DeviceId, - scope: Readonly, - serveOptions?: Readonly<{ contentType?: string; totalSize?: number }>, + serve: Readonly<{ + targetDevice: DeviceId; + scope: Readonly; + contentType?: string; + totalSize?: number; + }>, ) => Promise; } @@ -227,37 +237,34 @@ export function createShardHolder( ): ShardHolder { const { storage } = options; const receiver = createBulkReceiver({ storage }); + return { onFrame: receiver.onFrame, createOpenHandler: receiver.createOpenHandler, - async serveShard( - channel, - transferId, - targetDevice, - scope, - serveOptions = {}, - ) { + async serveShard(channel, transferId, serve) { + const { targetDevice, scope } = serve; const sender = createBulkSender({ source: storedTransferSource({ storage, transferId }), }); pumpSenderAcks(channel.connection, sender); + return sender.open(channel.connection, channel.session, scope, { transferId, targetDevice, - ...(serveOptions.contentType !== undefined - ? { contentType: serveOptions.contentType } + ...(serve.contentType !== undefined + ? { contentType: serve.contentType } : {}), - ...(serveOptions.totalSize !== undefined - ? { totalSize: serveOptions.totalSize } + ...(serve.totalSize !== undefined + ? { totalSize: serve.totalSize } : {}), }); }, }; } -// --------------------------------------------------------------------- -// Reader -// --------------------------------------------------------------------- +/* --------------------------------------------------------------------- + Reader + --------------------------------------------------------------------- */ export interface CreateShardCollectorOptions { manifest: Readonly; @@ -324,6 +331,7 @@ export function createShardCollector( kind: "reject", reason: "shard offer does not match the manifest", }); + return; } void event.decide({ @@ -374,5 +382,6 @@ export async function locateShardManifests( // Not a shard manifest -- some other application entry in the same log. } } + return manifests; } diff --git a/ts/packages/core/src/domain/threshold-network.ts b/ts/packages/core/src/domain/threshold-network.ts index 1de292fa..d0ca4d7a 100644 --- a/ts/packages/core/src/domain/threshold-network.ts +++ b/ts/packages/core/src/domain/threshold-network.ts @@ -124,6 +124,10 @@ export function buildAbortCommand( } export interface KeygenRound1Options { + sessionId: bigint; + threshold: number; + participants: readonly DeviceId[]; + commitment: readonly Uint8Array[]; /** REQUIRED for a fresh DKG (existingGroupKey absent) -- load-bearing, not ceremonial: without it a participant broadcasting last could adaptively bias the resulting group key (the rogue-key attack). MAY be omitted for a reshare. */ proofOfKnowledge?: Uint8Array; /** Present: this is a reshare of the named group's existing Ed25519 public key. Absent: a fresh DKG. Determines this command's own capability verb via keygenCapabilityVerb. */ @@ -132,13 +136,11 @@ export interface KeygenRound1Options { /** Round 1 of DKG or reshare: broadcasts this participant's own Feldman VSS commitment (and, for a fresh DKG, its Schnorr proof of knowledge) to every other participant. The capability verb is derived from `options.existingGroupKey`'s presence via keygenCapabilityVerb -- callers driving the rest of this same ceremony (round2, confirm) must pass the identical fresh-vs-reshare choice to those builders. */ export function buildKeygenRound1Command( - sessionId: bigint, - threshold: number, - participants: readonly DeviceId[], - commitment: readonly Uint8Array[], - options: Readonly = {}, + options: Readonly, ): ManageCommand { + const { sessionId, threshold, participants, commitment } = options; const isReshare = options.existingGroupKey !== undefined; + return { verb: keygenCapabilityVerb(isReshare), params: { diff --git a/ts/packages/core/src/domain/threshold-share-envelope.ts b/ts/packages/core/src/domain/threshold-share-envelope.ts index d810f965..c15db00d 100644 --- a/ts/packages/core/src/domain/threshold-share-envelope.ts +++ b/ts/packages/core/src/domain/threshold-share-envelope.ts @@ -51,6 +51,7 @@ export async function mintShareEnvelope( const signature = await personalIdentity.sign( sig1ToBeSigned(protectedHeader, payload), ); + return [protectedHeader, {}, payload, signature]; } diff --git a/ts/packages/core/src/domain/threshold-share-wire.ts b/ts/packages/core/src/domain/threshold-share-wire.ts index 32c1737a..0d9765c1 100644 --- a/ts/packages/core/src/domain/threshold-share-wire.ts +++ b/ts/packages/core/src/domain/threshold-share-wire.ts @@ -18,6 +18,7 @@ export function decodeShareEnvelopeBytes( return undefined; } const result = coseSign1Schema.safeParse(decoded); + return result.success ? result.data : undefined; } diff --git a/ts/packages/core/src/domain/threshold-subject.ts b/ts/packages/core/src/domain/threshold-subject.ts index 13de7774..4238dfe8 100644 --- a/ts/packages/core/src/domain/threshold-subject.ts +++ b/ts/packages/core/src/domain/threshold-subject.ts @@ -40,6 +40,7 @@ export function refuseUnrecognisedKind( if (known.includes(subject.kind)) { return undefined; } + return { authorise: false, reason: `unrecognised threshold-subject.kind ${JSON.stringify(subject.kind)} -- refusing to sign`, diff --git a/ts/packages/core/src/domain/token-predicates.ts b/ts/packages/core/src/domain/token-predicates.ts index 7c04770a..00b1ff1a 100644 --- a/ts/packages/core/src/domain/token-predicates.ts +++ b/ts/packages/core/src/domain/token-predicates.ts @@ -10,6 +10,7 @@ import { z } from "zod"; import type { DeviceId, TokenClaims } from "../generated/protocol.js"; import type { Clock } from "../ports/clock.js"; import { deviceIdToHex } from "./device-id.js"; +import { firstAsync } from "./sequential.js"; import { bytesEqual, scopeNarrows } from "./token-scope.js"; /** The wire shape of `token-claims.conditions` once CBOR-decoded: trilean's own PredicateNodeSchema is the single source of truth for what a condition entry may contain, re-validated here rather than trusted from a CDDL-generated shadow schema (see tokens.cddl's own comment on why `conditions` is an opaque bstr, not a native CDDL type) -- a token from an untrusted peer must pass trilean's real schema before any of its conditions are evaluated. Explicitly annotated: trilean's PredicateNodeSchema is a deeply recursive z.lazy() type whose inferred shape is too large for tsdown's declaration-file generator to serialise (TS7056) without this. */ @@ -101,11 +102,13 @@ const subjectHandlers: Record< [GRANTEE_IS]: (payload, context) => { if (context.subject === undefined) return { found: false }; if (typeof payload !== "string") return { found: false }; + return booleanFound(deviceIdToHex(context.subject.bearer) === payload); }, [GRANTED_CAPABILITY_IS]: (payload, context) => { if (context.subject === undefined) return { found: false }; if (typeof payload !== "string") return { found: false }; + return booleanFound(context.subject.capability === payload); }, }; @@ -142,24 +145,28 @@ const narrowingHandlers: Record< > = { [BEARER_IS]: (_payload, context) => { if (context.parentClaims === undefined) return booleanFound(false); + return booleanFound( bytesEqual(context.parentClaims.bearer, context.childIssuer), ); }, [EXPIRES_AT]: (_payload, context) => { if (context.parentClaims === undefined) return booleanFound(false); + return booleanFound( context.candidate.expires <= context.parentClaims.expires, ); }, [SCOPE_NARROWS]: (_payload, context) => { if (context.parentClaims === undefined) return booleanFound(false); + return booleanFound( scopeNarrows(context.parentClaims.scope, context.candidate.scope), ); }, [CAPABILITY_IS]: (_payload, context) => { if (context.parentClaims === undefined) return booleanFound(false); + return booleanFound( context.parentClaims.capability === context.candidate.capability, ); @@ -169,6 +176,7 @@ const narrowingHandlers: Record< if (context.parentClaims === undefined) return booleanFound(false); const parentRemaining = context.parentClaims["delegations-remaining"]; if (parentRemaining === undefined) return booleanFound(true); + return booleanFound( context.candidate.delegationsRemaining !== undefined && context.candidate.delegationsRemaining < parentRemaining, @@ -216,21 +224,21 @@ export async function evaluateNarrowing( resolveCollection: unusedResolveCollection, resolveDelegate: async (system, payload) => { if (!isNarrowingSystem(system)) return { found: false }; + return narrowingHandlers[system](payload, context); }, }; - for (const system of NARROWING_SYSTEMS) { - // Each check must short-circuit before the next runs, exactly like the five hardcoded `if`s this replaces; there is nothing to parallelise since a later check's own relevance depends on nothing evaluated here, but ordering (which check is reported as "the" failure) is observable and must match history. + + // Each check must short-circuit before the next runs, exactly like the five hardcoded `if`s this replaces; there is nothing to parallelise since a later check's own relevance depends on nothing evaluated here, but ordering (which check is reported as "the" failure) is observable and must match history. + return firstAsync(NARROWING_SYSTEMS, async (system) => { const result = await trileanEvaluatePredicate( delegateIsTrue(system), context, resolvers, ); - if (result.status !== "definite" || !result.value) { - return system; - } - } - return undefined; + + return result.status !== "definite" || !result.value; + }); } export type ConditionsVerdict = @@ -276,12 +284,14 @@ export async function evaluateConditions( } }, }; - for (const node of nodes) { - // Fail-closed short-circuit: the first unsatisfied condition refuses the whole token, so there is nothing to gain from evaluating the rest. + // Fail-closed short-circuit: the first unsatisfied condition refuses the whole token, so there is nothing to gain from evaluating the rest. + const unsatisfied = await firstAsync(nodes, async (node) => { const result = await trileanEvaluatePredicate(node, context, resolvers); - if (result.status !== "definite" || !result.value) { - return { ok: false, reason: "not_satisfied" }; - } - } - return { ok: true }; + + return result.status !== "definite" || !result.value; + }); + + return unsatisfied === undefined + ? { ok: true } + : { ok: false, reason: "not_satisfied" }; } diff --git a/ts/packages/core/src/domain/token-scope.ts b/ts/packages/core/src/domain/token-scope.ts index 2a6f5999..65f48525 100644 --- a/ts/packages/core/src/domain/token-scope.ts +++ b/ts/packages/core/src/domain/token-scope.ts @@ -8,6 +8,7 @@ export function bytesEqual(a: Uint8Array, b: Uint8Array): boolean { for (let i = 0; i < a.length; i += 1) { if (a[i] !== b[i]) return false; } + return true; } @@ -24,6 +25,7 @@ function pathNarrows(childPath: string, parentPath: string): boolean { if (childPath === parentPath) return true; if (!childPath.startsWith(parentPath)) return false; if (parentPath.endsWith("/")) return true; + return childPath.charAt(parentPath.length) === "/"; } @@ -37,5 +39,6 @@ export function scopeNarrows( if (parent.kind !== child.kind) return false; if (parent.path === undefined) return true; if (child.path === undefined) return false; + return pathNarrows(child.path, parent.path); } diff --git a/ts/packages/core/src/domain/tokens.ts b/ts/packages/core/src/domain/tokens.ts index 78447d16..a9d8835b 100644 --- a/ts/packages/core/src/domain/tokens.ts +++ b/ts/packages/core/src/domain/tokens.ts @@ -24,6 +24,7 @@ import { type TokenDelegateHandler, } from "./token-predicates.js"; import { bytesToHex } from "./device-id.js"; +import { firstAsync } from "./sequential.js"; import { bytesEqual, scopeNarrows } from "./token-scope.js"; /** The capability verb of a grant-capability (wire-mesh#323): authority to mint other capability tokens, the same register manage:revoke already established for authority over tokens. */ @@ -122,6 +123,7 @@ function decodeTokenClaims(token: CapabilityToken): TokenClaims | undefined { return undefined; } const result = tokenClaimsSchema.safeParse(decoded); + return result.success ? result.data : undefined; } @@ -150,6 +152,7 @@ export async function verifyCapabilityToken( ) { return { ok: false, reason: "bearer_mismatch" }; } + return verdict; } @@ -182,6 +185,7 @@ async function revocationEntryGrantsRevoke( authorizationResult.data, { ...options, expectedBearer: entry.issuer }, ); + return ( authorizationVerdict.ok && authorizationVerdict.claims.capability === "manage:revoke" && @@ -189,13 +193,21 @@ async function revocationEntryGrantsRevoke( ); } +/** Where a token sits in the chain being walked. */ +interface ChainLink { + /** Subject mode (wire-mesh#323): when this token is being verified as the AUTHORISER of another token, that other token's claims, against which this token's own conditions are evaluated instead of its own. Undefined for a standalone (leaf) verification, which is every existing call. */ + subjectClaims?: Readonly; + depth: number; + /** The payloads already on the path from the leaf, so a cycle is refused instead of walked. */ + seen: ReadonlySet; +} + +const LEAF_LINK: ChainLink = { depth: 0, seen: new Set() }; + async function verifyTokenChain( token: CapabilityToken, options: Omit, - /** Subject mode (wire-mesh#323): when this token is being verified as the AUTHORISER of another token, that other token's claims, against which this token's own conditions are evaluated instead of its own. Undefined for a standalone (leaf) verification, which is every existing call. */ - subjectClaims?: Readonly, - depth = 0, - seen: ReadonlySet = new Set(), + { subjectClaims, depth, seen }: Readonly = LEAF_LINK, ): Promise { const [protectedHeader, , payload, signature] = token; if (payload === null) { @@ -253,10 +265,11 @@ async function verifyTokenChain( const revocationEntries = await options.revocation.entriesFor( claims["token-id"], ); - for (const entry of revocationEntries) { - if (await revocationEntryGrantsRevoke(entry, claims, options)) { - return { ok: false, reason: "revoked" }; - } + const revokedBy = await firstAsync(revocationEntries, async (entry) => + revocationEntryGrantsRevoke(entry, claims, options), + ); + if (revokedBy !== undefined) { + return { ok: false, reason: "revoked" }; } // claims.conditions is strictly additive to the narrowing checks below -- it is decoded and evaluated here, uniformly for both root and delegated tokens, entirely independent of whether claims.parent is present. See tokens.cddl's own comment on the field and CONVENTIONS.md's verifier-obligations glossary entry for the fail-closed contract this enforces. @@ -307,13 +320,10 @@ async function verifyTokenChain( if (!parentResult.success) { return { ok: false, reason: "parent_invalid" }; } - const parentVerdict = await verifyTokenChain( - parentResult.data, - options, - undefined, - depth + 1, - deeperSeen, - ); + const parentVerdict = await verifyTokenChain(parentResult.data, options, { + depth: depth + 1, + seen: deeperSeen, + }); if (!parentVerdict.ok) { // Walker-level conditions (too deep, cyclic) are properties of the whole chain, not of this one link, so they surface as themselves rather than hiding behind parent_invalid. if ( @@ -322,6 +332,7 @@ async function verifyTokenChain( ) { return { ok: false, reason: parentVerdict.reason }; } + return { ok: false, reason: "parent_invalid" }; } const candidate: NarrowingCandidate = { @@ -340,6 +351,7 @@ async function verifyTokenChain( if (failedSystem !== undefined) { return { ok: false, reason: "delegation_exceeds_parent" }; } + return { ok: true, claims, @@ -363,9 +375,7 @@ async function verifyTokenChain( const authoriserVerdict = await verifyTokenChain( authoriserResult.data, options, - claims, - depth + 1, - deeperSeen, + { subjectClaims: claims, depth: depth + 1, seen: deeperSeen }, ); if (!authoriserVerdict.ok) { if ( @@ -374,6 +384,7 @@ async function verifyTokenChain( ) { return { ok: false, reason: authoriserVerdict.reason }; } + return { ok: false, reason: "authorisation_invalid" }; } const authoriser = authoriserVerdict.claims; @@ -392,6 +403,7 @@ async function verifyTokenChain( if (!linkOk) { return { ok: false, reason: "authorisation_invalid" }; } + return { ok: true, claims, @@ -513,6 +525,7 @@ async function checkNarrowing( granterDeviceId, candidate, ); + return failedSystem === undefined ? undefined : NARROWING_SYSTEM_TO_MINT_REFUSAL[failedSystem]; @@ -577,6 +590,7 @@ async function checkAuthorisation( return "authorisation_conditions_not_satisfied"; } } + return undefined; } @@ -600,6 +614,7 @@ export async function canGrant( if (heldClaims === undefined) { return false; } + return (await checkNarrowing(heldClaims, deviceId, candidate)) === undefined; } @@ -617,6 +632,7 @@ export async function canGrantVia( if (heldClaims === undefined) { return false; } + return ( (await checkAuthorisation(heldClaims, deviceId, candidate)) === undefined ); @@ -751,6 +767,7 @@ export async function mintCapabilityToken( const signature = await options.identity.sign( sig1ToBeSigned(protectedHeader, payload), ); + return { ok: true, token: [protectedHeader, {}, payload, signature] }; } @@ -776,5 +793,6 @@ export async function mintRevocationEntry( const signature = await options.identity.sign( sig1ToBeSigned(protectedHeader, payload), ); + return [protectedHeader, {}, payload, signature]; } diff --git a/ts/packages/core/src/domain/topology-snapshot.ts b/ts/packages/core/src/domain/topology-snapshot.ts index abcd3c82..54f3d9f7 100644 --- a/ts/packages/core/src/domain/topology-snapshot.ts +++ b/ts/packages/core/src/domain/topology-snapshot.ts @@ -19,6 +19,7 @@ function directPeerDevice( if (inputs.authenticatedPeer !== undefined) { return inputs.authenticatedPeer; } + return inputs.isAccepted ? (inputs.firstAdvertisedPeer ?? undefined) : undefined; @@ -29,6 +30,7 @@ export function computeTopologyPeers( inputs: Readonly, ): TopologyPeers { const direct = directPeerDevice(inputs); + return { direct: direct !== undefined ? [direct] : [], relayed: inputs.relayedDevices.map((device) => ({ @@ -59,12 +61,14 @@ export function createTopologySnapshotTracker( sources: Readonly, ): TopologySnapshotTracker { let firstAdvertisedPeer: DeviceId | null = null; + return { recordAdvert: (device) => { firstAdvertisedPeer ??= device; }, compute: () => { const authenticatedPeer = sources.getAuthenticatedPeer(); + return computeTopologyPeers({ ...(authenticatedPeer !== undefined ? { authenticatedPeer } : {}), firstAdvertisedPeer, diff --git a/ts/packages/core/src/domain/topology.ts b/ts/packages/core/src/domain/topology.ts index 2000b924..4b597f5f 100644 --- a/ts/packages/core/src/domain/topology.ts +++ b/ts/packages/core/src/domain/topology.ts @@ -39,6 +39,7 @@ export function readTopologyPeers( return undefined; } const parsed = topologyPeersSchema.safeParse(raw); + return parsed.success ? parsed.data : undefined; } @@ -77,6 +78,7 @@ export function assembleTopologyGraph( }); } } + return { nodes, edges }; } @@ -102,6 +104,7 @@ export async function sendTopologyGet( if (!parsed.success) { throw new Error("topology.get response was malformed"); } + return parsed.data; } @@ -117,6 +120,7 @@ export function createTopologyGetHandler( const parsed = topologyGetSchema.safeParse(incoming.command.params); if (!parsed.success) { await incoming.respond({ result: "error", code: "malformed" }); + return; } await incoming.respond({ result: "ok", peers: session.getTopologyPeers() }); diff --git a/ts/packages/core/src/domain/webrtc-signaling.ts b/ts/packages/core/src/domain/webrtc-signaling.ts index 3d76845e..90114f11 100644 --- a/ts/packages/core/src/domain/webrtc-signaling.ts +++ b/ts/packages/core/src/domain/webrtc-signaling.ts @@ -161,9 +161,11 @@ export function rtcIceCandidateInitFromWire( /** A fresh, independent, monotonically increasing negotiation-id source starting at 0, extracted as its own pure function so id allocation is testable without an RTCPeerConnection or any transport. */ export function createNegotiationIdAllocator(): () => number { let next = 0; + return (): number => { const id = next; next += 1; + return id; }; } @@ -180,6 +182,7 @@ export async function authorizeIncomingOffer( if (!verdict.ok) { return false; } + return ( verdict.claims.capability === WEBRTC_SIGNAL_VERB && verdict.claims.scope.kind === WEBRTC_SIGNAL_SCOPE.kind diff --git a/ts/packages/core/src/ports/clock.ts b/ts/packages/core/src/ports/clock.ts index 5510a8c6..f425dca2 100644 --- a/ts/packages/core/src/ports/clock.ts +++ b/ts/packages/core/src/ports/clock.ts @@ -1,4 +1,6 @@ -// Injected time source, never pulled from global Date.now() directly in domain logic -- lets token-expiry and revocation-timestamp checks be tested deterministically and lets a consumer substitute a synchronised network clock later without touching domain code. +/** + * Injected time source, never pulled from global Date.now() directly in domain logic -- lets token-expiry and revocation-timestamp checks be tested deterministically and lets a consumer substitute a synchronised network clock later without touching domain code. + */ export interface Clock { /** Current time as Unix milliseconds, matching every uint timestamp field in the protocol (token-claims.expires, revocation-entry.revoked-at, handshake params, etc.). */ now: () => number; diff --git a/ts/packages/core/src/ports/storage.ts b/ts/packages/core/src/ports/storage.ts index 4e3dcbb0..7c540126 100644 --- a/ts/packages/core/src/ports/storage.ts +++ b/ts/packages/core/src/ports/storage.ts @@ -1,4 +1,6 @@ -// A minimal async key/value contract for whatever this package needs to persist (revocation entries, data-domain oplog entries) -- local and remote implementations interchangeable behind it, per the org's storage-boundary rules. Values are opaque bytes, not a specific serialisation: callers encode/decode with whatever schema the stored data actually has (a revocation-entry, a data-domain entry, etc.), matching the protocol's own treatment of data-domain entries as opaque to the wire format. +/** + * A minimal async key/value contract for whatever this package needs to persist (revocation entries, data-domain oplog entries) -- local and remote implementations interchangeable behind it, per the org's storage-boundary rules. Values are opaque bytes, not a specific serialisation: callers encode/decode with whatever schema the stored data actually has (a revocation-entry, a data-domain entry, etc.), matching the protocol's own treatment of data-domain entries as opaque to the wire format. + */ export interface KeyValueStorage { get: (key: string) => Promise | undefined>; set: (key: string, value: Uint8Array) => Promise; diff --git a/ts/packages/core/stryker.config.ts b/ts/packages/core/stryker.config.ts index f59195e2..1e3dda03 100644 --- a/ts/packages/core/stryker.config.ts +++ b/ts/packages/core/stryker.config.ts @@ -1,8 +1,8 @@ import type { PartialStrykerOptions } from "@stryker-mutator/api/core"; -// A real .ts file, not stryker.config.mjs with a `@type` JSDoc annotation (Stryker's own documented pattern): Stryker's own config loader is a plain `import()` under the hood (ConfigReader#importJSConfig in @stryker-mutator/core) with no opinion at all about the extension it is given, and Node's own ESM loader on this workspace's pinned version (.tool-versions: nodejs 26) strips a .ts file's type syntax natively with no flag and no loader hook -- so `stryker run stryker.config.ts` resolves straight through that native support, with no shim file, and Stryker's own option validation still runs afterward exactly as it would for a .js config. -// -// testRunner is "command", not "@stryker-mutator/vitest-runner", despite this package running on vitest: vitest-runner@10.0.0 crashes on init against vitest@5.0.0 with "TypeError: Converting circular structure to JSON" while serialising vitest's own resolved config (VitestTestRunner.init, vitest-test-runner.js), a confirmed upstream incompatibility with no fix or compatible version pairing available (see stryker-mutator/stryker-js's vitest-runner issue tracker; independently corroborated by Chris0Jeky/Taskdeck#3038 hitting the identical "0.00 tests per mutant" symptom against the same vitest major). The command runner re-runs `pnpm test` as a subprocess per mutant instead of driving vitest in-process, so it loses per-test coverage analysis (every mutant re-runs the whole suite rather than only its covering tests) but actually produces a real result. Revisit once vitest-runner ships a fix for this. +/* A real .ts file, not stryker.config.mjs with a `@type` JSDoc annotation (Stryker's own documented pattern): Stryker's own config loader is a plain `import()` under the hood (ConfigReader#importJSConfig in @stryker-mutator/core) with no opinion at all about the extension it is given, and Node's own ESM loader on this workspace's pinned version (.tool-versions: nodejs 26) strips a .ts file's type syntax natively with no flag and no loader hook -- so `stryker run stryker.config.ts` resolves straight through that native support, with no shim file, and Stryker's own option validation still runs afterward exactly as it would for a .js config. + + testRunner is "command", not "@stryker-mutator/vitest-runner", despite this package running on vitest: vitest-runner@10.0.0 crashes on init against vitest@5.0.0 with "TypeError: Converting circular structure to JSON" while serialising vitest's own resolved config (VitestTestRunner.init, vitest-test-runner.js), a confirmed upstream incompatibility with no fix or compatible version pairing available (see stryker-mutator/stryker-js's vitest-runner issue tracker; independently corroborated by Chris0Jeky/Taskdeck#3038 hitting the identical "0.00 tests per mutant" symptom against the same vitest major). The command runner re-runs `pnpm test` as a subprocess per mutant instead of driving vitest in-process, so it loses per-test coverage analysis (every mutant re-runs the whole suite rather than only its covering tests) but actually produces a real result. Revisit once vitest-runner ships a fix for this. */ const config: PartialStrykerOptions = { packageManager: "pnpm", // src/generated/protocol.ts is cddl.js output, not hand-written logic -- mutating it would just generate noise against code nobody edits directly, exactly the exclusion the issue that added this config asked for. diff --git a/ts/packages/core/test/bulk.unit.test.ts b/ts/packages/core/test/bulk.unit.test.ts index 8fc779c0..1cd52f36 100644 --- a/ts/packages/core/test/bulk.unit.test.ts +++ b/ts/packages/core/test/bulk.unit.test.ts @@ -32,12 +32,14 @@ const DIGEST_BYTE_LENGTH = 32; function transferIdFromFillHex(fillHex: string): Uint8Array { const out = new Uint8Array(TRANSFER_ID_BYTE_LENGTH); out.fill(Number.parseInt(fillHex, HEX_RADIX)); + return out; } function digestFromFillHex(fillHex: string): Uint8Array { const out = new Uint8Array(DIGEST_BYTE_LENGTH); out.fill(Number.parseInt(fillHex, HEX_RADIX)); + return out; } @@ -49,7 +51,9 @@ const DEFAULT_WINDOW = 4096; /** A one-directional, wake-on-push frame queue -- the same async-iterator-with-a-wait-queue shape mesh-session-fixtures.ts's own FakeConnection uses internally, factored out here so linkedConnections() below can compose two of them into a genuine bidirectional pair. */ class FakeInbound { private readonly queue: Frame[] = []; + private readonly waiters: (() => void)[] = []; + private ended = false; push(frame: Frame): void { @@ -75,14 +79,14 @@ class FakeInbound { } private async next(): Promise> { - for (;;) { - const value = this.queue.shift(); - if (value !== undefined) return { value, done: false }; - if (this.ended) return { value: undefined, done: true }; - await new Promise((resolve) => { - this.waiters.push(resolve); - }); - } + const value = this.queue.shift(); + if (value !== undefined) return { value, done: false }; + if (this.ended) return { value: undefined, done: true }; + await new Promise((resolve) => { + this.waiters.push(resolve); + }); + + return this.next(); } } @@ -98,25 +102,30 @@ function linkedConnections(): { const senderConn: Connection = { send: async (frame) => { toReceiver.push(frame); + return Promise.resolve(); }, receive: () => toSender.stream(), close: async () => { toSender.end(); + return Promise.resolve(); }, }; const receiverConn: Connection = { send: async (frame) => { toSender.push(frame); + return Promise.resolve(); }, receive: () => toReceiver.stream(), close: async () => { toReceiver.end(); + return Promise.resolve(); }, }; + return { senderConn, receiverConn, @@ -163,10 +172,12 @@ function sessionDispatchingTo( scope, respond: async (o): Promise => { resolveOutcome(o); + return Promise.resolve(); }, }; await handler(incoming); + return responded; }, } as unknown as MeshSession; @@ -178,6 +189,7 @@ function chunkedSource( return (fromSeq: number) => ({ [Symbol.asyncIterator](): AsyncIterator> { let i = fromSeq; + return { next: async (): Promise>> => { const value = data[i]; @@ -185,6 +197,7 @@ function chunkedSource( return Promise.resolve({ value: undefined, done: true }); } i += 1; + return Promise.resolve({ value, done: false }); }, }; @@ -201,6 +214,7 @@ function deferred(): { const promise = new Promise((r) => { resolve = r; }); + return { promise, resolve }; } @@ -212,6 +226,7 @@ function concat(chunks: readonly Uint8Array[]): Uint8Array { out.set(chunk, offset); offset += chunk.length; } + return out; } @@ -314,8 +329,12 @@ describe("bulk sender/receiver, end to end", () => { const idHex = "01".repeat(TRANSFER_ID_BYTE_LENGTH); const reconstructed = new Uint8Array(expected.length); let offset = 0; - for (let seq = 0; seq < chunks.length; seq += 1) { - const stored = await storage.get(`bulk/${idHex}/chunk/${String(seq)}`); + const storedChunks = await Promise.all( + chunks.map(async (_, seq) => + storage.get(`bulk/${idHex}/chunk/${String(seq)}`), + ), + ); + for (const stored of storedChunks) { expect(stored).toBeDefined(); if (stored !== undefined) { reconstructed.set(stored, offset); @@ -373,6 +392,7 @@ describe("bulk sender/receiver, end to end", () => { scope: TEST_SCOPE, respond: async (o): Promise => { secondOutcome = o; + return Promise.resolve(); }, }; @@ -426,7 +446,8 @@ describe("bulk sender/receiver, end to end", () => { await receiver.onFrame(receiverConn, { type: "bulk-end", "transfer-id": transferId, - digest: digestFromFillHex("ff"), // deliberately wrong + // deliberately wrong + digest: digestFromFillHex("ff"), }); expect(await complete.promise).toEqual({ ok: false, chunkCount: 1 }); @@ -473,6 +494,7 @@ describe("bulk sender/receiver, end to end", () => { if (frame.type === "bulk-data" && frame.seq === 1) { sawSecondChunkSent = true; link1.endReceiverSide(); + return; } await receiver.onFrame(link1.receiverConn, frame); @@ -517,8 +539,12 @@ describe("bulk sender/receiver, end to end", () => { const idHex = "04".repeat(TRANSFER_ID_BYTE_LENGTH); const reconstructed = new Uint8Array(expected.length); let offset = 0; - for (let seq = 0; seq < chunks.length; seq += 1) { - const stored = await storage.get(`bulk/${idHex}/chunk/${String(seq)}`); + const storedChunks = await Promise.all( + chunks.map(async (_, seq) => + storage.get(`bulk/${idHex}/chunk/${String(seq)}`), + ), + ); + for (const stored of storedChunks) { expect(stored).toBeDefined(); if (stored !== undefined) { reconstructed.set(stored, offset); diff --git a/ts/packages/core/test/byte-stream-connection.unit.test.ts b/ts/packages/core/test/byte-stream-connection.unit.test.ts index 7eb7fc6a..80e752c4 100644 --- a/ts/packages/core/test/byte-stream-connection.unit.test.ts +++ b/ts/packages/core/test/byte-stream-connection.unit.test.ts @@ -20,6 +20,7 @@ const NOT_CBOR_BYTE = 0xff; function duplexPair(): [ByteStream, ByteStream] { const toSecond = new TransformStream(); const toFirst = new TransformStream(); + return [ { readable: toFirst.readable, writable: toSecond.writable }, { readable: toSecond.readable, writable: toFirst.writable }, @@ -32,6 +33,7 @@ async function firstFrame( for await (const frame of frames) { return frame; } + return undefined; } diff --git a/ts/packages/core/test/capability-grant.unit.test.ts b/ts/packages/core/test/capability-grant.unit.test.ts index 44b4834a..ca6e8d2a 100644 --- a/ts/packages/core/test/capability-grant.unit.test.ts +++ b/ts/packages/core/test/capability-grant.unit.test.ts @@ -41,6 +41,7 @@ async function generateEs256Identity(): Promise { const publicKeyBytes = new Uint8Array( await webcrypto.subtle.exportKey("raw", keyPair.publicKey), ); + return createNodeIdentity(keyPair.privateKey, publicKeyBytes, ES256); } @@ -55,6 +56,7 @@ const neverRevoked: RevocationCheck = { let issuedTokenIds = 0; function nextTokenId(): Uint8Array { issuedTokenIds += 1; + return Uint8Array.from([issuedTokenIds]); } @@ -97,6 +99,7 @@ async function signToken( payload, ]); const signature = await identity.sign(toBeSigned); + return [protectedHeader, {}, payload, signature]; } @@ -117,6 +120,7 @@ function fakeIncoming( scope, respond, }; + return { incoming, respond }; } @@ -213,6 +217,7 @@ describe("createCapabilityGrantHandler", () => { granterDevice: granter.deviceId, onGrant, }); + return { handle, granter, recipient, onGrant }; } @@ -236,6 +241,7 @@ describe("createCapabilityGrantHandler", () => { expires: overrides?.expires ?? NOW_MS + HOUR_MS, }); if (!verdict.ok) throw new Error(`mint failed: ${verdict.reason}`); + return verdict.token; } @@ -478,6 +484,7 @@ describe("createCapabilityGrantHandler", () => { const token = await validGrantToken(granter, recipient); const respond = vi.fn(async (): Promise => { calls.push("respond"); + return Promise.resolve(); }); const handle = createCapabilityGrantHandler({ @@ -535,6 +542,7 @@ describe("round trip: sendCapabilityGrant against createCapabilityGrantHandler", async (command): Promise => { const { incoming, respond } = fakeIncoming(command, TEST_SCOPE); await handle(incoming); + return respond.mock.calls[0]?.[0] as ManageOutcome; }, ); diff --git a/ts/packages/core/test/capability-request.unit.test.ts b/ts/packages/core/test/capability-request.unit.test.ts index 6c826f82..cf29124e 100644 --- a/ts/packages/core/test/capability-request.unit.test.ts +++ b/ts/packages/core/test/capability-request.unit.test.ts @@ -43,6 +43,7 @@ async function generateEs256Identity(): Promise { const publicKeyBytes = new Uint8Array( await webcrypto.subtle.exportKey("raw", keyPair.publicKey), ); + return createNodeIdentity(keyPair.privateKey, publicKeyBytes, ES256); } @@ -59,8 +60,13 @@ function fakeSession(): MeshSession { function fakeIncoming( command: ManageCommand, - scope: Readonly = TEST_SCOPE, - token?: CapabilityToken, + { + scope = TEST_SCOPE, + token, + }: Readonly<{ + scope?: Readonly; + token?: CapabilityToken; + }> = {}, ): { incoming: IncomingManageRequest; respond: ReturnType } { const respond = vi.fn(async (): Promise => Promise.resolve()); const incoming: IncomingManageRequest = { @@ -70,6 +76,7 @@ function fakeIncoming( ...(token !== undefined ? { token } : {}), respond, }; + return { incoming, respond }; } @@ -227,6 +234,7 @@ describe("createCapabilityRequestHandler", () => { : {}), onRequest, }); + return { handle, identity, bearer, onRequest }; } @@ -422,7 +430,7 @@ describe("createCapabilityRequestHandler", () => { }); const { incoming } = fakeIncoming( buildCapabilityRequestCommand(TEST_CAPABILITY), - TEST_SCOPE, + { scope: TEST_SCOPE }, ); await handle(incoming); @@ -454,8 +462,7 @@ describe("createCapabilityRequestHandler", () => { const token = await mintedRequestToken(identity, bearer.deviceId); const { incoming, respond } = fakeIncoming( buildCapabilityRequestCommand(TEST_CAPABILITY), - TEST_SCOPE, - token, + { scope: TEST_SCOPE, token }, ); await handle(incoming); expect(onRequest).toHaveBeenCalledTimes(1); @@ -471,11 +478,10 @@ describe("createCapabilityRequestHandler", () => { const wrongScope = await mintedRequestToken(identity, bearer.deviceId, { scope: { kind: "room", path: "another-room" }, }); - const first = fakeIncoming( - buildCapabilityRequestCommand(TEST_CAPABILITY), - TEST_SCOPE, - wrongVerb, - ); + const first = fakeIncoming(buildCapabilityRequestCommand(TEST_CAPABILITY), { + scope: TEST_SCOPE, + token: wrongVerb, + }); await handle(first.incoming); expect(first.respond).toHaveBeenCalledWith({ result: "error", @@ -483,8 +489,7 @@ describe("createCapabilityRequestHandler", () => { }); const second = fakeIncoming( buildCapabilityRequestCommand(TEST_CAPABILITY), - TEST_SCOPE, - wrongScope, + { scope: TEST_SCOPE, token: wrongScope }, ); await handle(second.incoming); expect(second.respond).toHaveBeenCalledWith({ @@ -506,8 +511,7 @@ describe("createCapabilityRequestHandler", () => { }); const { incoming, respond } = fakeIncoming( buildCapabilityRequestCommand(TEST_CAPABILITY), - TEST_SCOPE, - token, + { scope: TEST_SCOPE, token }, ); await handle(incoming); expect(respond).toHaveBeenCalledWith({ @@ -526,8 +530,7 @@ describe("createCapabilityRequestHandler", () => { const token = await mintedRequestToken(identity, bearer.deviceId); const { incoming, respond } = fakeIncoming( buildCapabilityRequestCommand(TEST_CAPABILITY), - TEST_SCOPE, - token, + { scope: TEST_SCOPE, token }, ); await handle(incoming); if (seen === undefined) throw new Error("expected one request event"); @@ -561,6 +564,7 @@ describe("createCapabilityRequestHandler", () => { : {}), }); if (!verdict.ok) throw new Error(`mint failed: ${verdict.reason}`); + return verdict.token; } }); @@ -585,7 +589,7 @@ describe("round trip: requestCapability against createCapabilityRequestHandler", }); const { incoming, respond } = fakeIncoming( buildCapabilityRequestCommand(TEST_CAPABILITY), - TEST_SCOPE, + { scope: TEST_SCOPE }, ); await handle(incoming); diff --git a/ts/packages/core/test/conformance.unit.test.ts b/ts/packages/core/test/conformance.unit.test.ts index 3a91459c..ce815fe9 100644 --- a/ts/packages/core/test/conformance.unit.test.ts +++ b/ts/packages/core/test/conformance.unit.test.ts @@ -36,6 +36,7 @@ interface VectorFile { function isVectorFile(value: unknown): value is VectorFile { if (typeof value !== "object" || value === null) return false; if (!("vectors" in value) || !Array.isArray(value.vectors)) return false; + return value.vectors.every( (v: unknown) => typeof v === "object" && @@ -55,6 +56,7 @@ function readVectors(filename: string): Vector[] { if (!isVectorFile(raw)) { throw new Error(`${filename} is not a valid vector file`); } + return raw.vectors; } @@ -101,6 +103,7 @@ describe("token vectors decode and re-encode byte-exactly through capabilityToke function isAdvertVector(value: Readonly): value is AdvertVector { if (!("signing_input_hex" in value) || !("verifies" in value)) return false; + return ( typeof value.signing_input_hex === "string" && typeof value.verifies === "boolean" diff --git a/ts/packages/core/test/device-id.unit.test.ts b/ts/packages/core/test/device-id.unit.test.ts index 34d1413b..e79d3f18 100644 --- a/ts/packages/core/test/device-id.unit.test.ts +++ b/ts/packages/core/test/device-id.unit.test.ts @@ -7,7 +7,8 @@ import { } from "../src/domain/device-id.js"; import { deviceIdFromFillHex } from "./hex.js"; -const SHA256_BYTE_LENGTH = 32; // device-id = SHA-256(identity-key.public-key) +// device-id = SHA-256(identity-key.public-key) +const SHA256_BYTE_LENGTH = 32; describe("deviceIdToHex", () => { it("encodes a fill-byte device-id as its repeated hex pair", () => { @@ -30,13 +31,16 @@ describe("deviceIdFromHex", () => { }); it("rejects a string that isn't exactly 64 lowercase hex characters", () => { - expect(() => - deviceIdFromHex("11".repeat(SHA256_BYTE_LENGTH - 1)), - ).toThrow(); // too short - expect(() => - deviceIdFromHex(`${"11".repeat(SHA256_BYTE_LENGTH)}gg`), - ).toThrow(); // non-hex characters - expect(() => deviceIdFromHex("AA".repeat(SHA256_BYTE_LENGTH))).toThrow(); // uppercase, not the canonical lowercase form + expect( + () => deviceIdFromHex("11".repeat(SHA256_BYTE_LENGTH - 1)), + // too short + ).toThrow(); + expect( + () => deviceIdFromHex(`${"11".repeat(SHA256_BYTE_LENGTH)}gg`), + // non-hex characters + ).toThrow(); + // uppercase, not the canonical lowercase form + expect(() => deviceIdFromHex("AA".repeat(SHA256_BYTE_LENGTH))).toThrow(); }); }); diff --git a/ts/packages/core/test/encrypted-notice.integration.test.ts b/ts/packages/core/test/encrypted-notice.integration.test.ts index 0c7edf1e..104e2b8c 100644 --- a/ts/packages/core/test/encrypted-notice.integration.test.ts +++ b/ts/packages/core/test/encrypted-notice.integration.test.ts @@ -66,6 +66,7 @@ async function generateEs256Identity(): Promise { false, ["deriveBits"], ); + return createNodeIdentity( keyPair.privateKey, publicKeyBytes, @@ -81,6 +82,7 @@ async function deriveSharedSecretOrThrow( if (identity.deriveSharedSecret === undefined) { throw new Error("identity must expose deriveSharedSecret"); } + return identity.deriveSharedSecret(peer.identityKey); } @@ -98,6 +100,7 @@ async function ownerRekeys( keyEpoch, }); const wrapped = await wrapContentKey(wrappingKey, contentKey); + return { command: buildRoomRekeyCommand(keyEpoch, wrapped), ownerKeyCopy: contentKey, @@ -132,6 +135,7 @@ async function mintRoomMemberToken( delegationsRemaining: 0, }); if (!verdict.ok) throw new Error(`mint failed: ${verdict.reason}`); + return verdict.token; } diff --git a/ts/packages/core/test/erasure-coding.unit.test.ts b/ts/packages/core/test/erasure-coding.unit.test.ts index 53a9aef7..b8d3c93d 100644 --- a/ts/packages/core/test/erasure-coding.unit.test.ts +++ b/ts/packages/core/test/erasure-coding.unit.test.ts @@ -60,16 +60,22 @@ describe("encodeShards / decodeShards", () => { expect(shards).toHaveLength(K3_N5.totalShards); // Every K-subset must reconstruct: the erasure guarantee itself. - for (let drop1 = 0; drop1 < K3_N5.totalShards; drop1 += 1) { - for (let drop2 = drop1 + 1; drop2 < K3_N5.totalShards; drop2 += 1) { + const dropPairs = Array.from({ length: K3_N5.totalShards }, (_, drop1) => + Array.from( + { length: K3_N5.totalShards - drop1 - 1 }, + (__, offset) => [drop1, drop1 + 1 + offset] as const, + ), + ).flat(); + await Promise.all( + dropPairs.map(async ([drop1, drop2]) => { const decoded = await decodeShards( survivorsOf(shards, drop1, drop2), K3_N5, data.length, ); expect(decoded).toEqual(data); - } - } + }), + ); }); it("round-trips a K=N configuration (pure splitting, no parity)", async () => { @@ -105,7 +111,8 @@ describe("encodeShards / decodeShards", () => { }); it("pads the last shard: input not a multiple of K still reconstructs to the exact original length", async () => { - const data = text("abcde"); // not a multiple of K3_N5.dataShards + // not a multiple of K3_N5.dataShards + const data = text("abcde"); const shards = await encodeShards(data, K3_N5); const decoded = await decodeShards( survivorsOf(shards, 1, 2), @@ -113,7 +120,8 @@ describe("encodeShards / decodeShards", () => { data.length, ); expect(decoded).toEqual(data); - expect(decoded).toHaveLength(data.length); // not the padded length + // not the padded length + expect(decoded).toHaveLength(data.length); }); it("rejects fewer than K shards outright", async () => { @@ -143,7 +151,8 @@ describe("encodeShards / decodeShards", () => { totalShards: K2_N2.totalShards, }), ).rejects.toThrow(/at least.*data/i); - const BEYOND_FIELD = 256; // GF(2^8) holds 255 shard rows; 256 deliberately exceeds it + // GF(2^8) holds 255 shard rows; 256 deliberately exceeds it + const BEYOND_FIELD = 256; await expect( encodeShards(data, { dataShards: BEYOND_FIELD, @@ -208,18 +217,21 @@ describe("encodeShards / decodeShards", () => { [SHARD_1, SHARD_3], [SHARD_2, SHARD_3], ]; - for (const dropped of DROP_SETS) { - const decoded = await decodeShards( - survivorsOf(shards, ...dropped), - K2_N4, - data.length, - ); - expect(decoded).toEqual(data); - } + await Promise.all( + DROP_SETS.map(async (dropped) => { + const decoded = await decodeShards( + survivorsOf(shards, ...dropped), + K2_N4, + data.length, + ); + expect(decoded).toEqual(data); + }), + ); }); it("produces the byte-pinned cross-language vector: K=2 N=3 over 'wire-mesh' (the Rust port must match exactly)", async () => { - const data = text("wire-mesh"); // 9 bytes: halves are "wire-" and "mesh" + one pad zero + // 9 bytes: halves are "wire-" and "mesh" + one pad zero + const data = text("wire-mesh"); const shards = await encodeShards(data, K2_N3); expect(shards[0]).toEqual(text("wire-")); expect(shards[1]).toEqual(meshPadded()); diff --git a/ts/packages/core/test/grant-candidates.unit.test.ts b/ts/packages/core/test/grant-candidates.unit.test.ts index 0e8d2233..70d1ca43 100644 --- a/ts/packages/core/test/grant-candidates.unit.test.ts +++ b/ts/packages/core/test/grant-candidates.unit.test.ts @@ -15,7 +15,8 @@ import { syntheticAdvertProof } from "./synthetic-advert.js"; const ROOM_SCOPE: CapabilityScope = { kind: "room", path: "team/general" }; const ROOM_MEMBER = "room:member"; -const DEVICE_ID_HEX_LENGTH = 64; // 32 bytes, hex-encoded -- matches device-id.ts's own DEVICE_ID_HEX_LENGTH +// 32 bytes, hex-encoded -- matches device-id.ts's own DEVICE_ID_HEX_LENGTH +const DEVICE_ID_HEX_LENGTH = 64; const DEVICE_A = deviceIdFromHex("a".repeat(DEVICE_ID_HEX_LENGTH)); const DEVICE_B = deviceIdFromHex("b".repeat(DEVICE_ID_HEX_LENGTH)); diff --git a/ts/packages/core/test/group-key.unit.test.ts b/ts/packages/core/test/group-key.unit.test.ts index 119ced20..cfd6d776 100644 --- a/ts/packages/core/test/group-key.unit.test.ts +++ b/ts/packages/core/test/group-key.unit.test.ts @@ -11,9 +11,12 @@ import { wrapContentKey, } from "../src/domain/group-key.js"; -const DEVICE_ID_HEX_LENGTH = 32; // 64 hex chars = 32-byte device-id, hex-encoded (room-path's own owner-hex component) -const SHARED_SECRET_BYTE_LENGTH = 32; // a P-256 ECDH shared secret's own byte length, standing in for a real one in these tests -const CONTENT_KEY_BYTE_LENGTH = 32; // AES-256 +// 64 hex chars = 32-byte device-id, hex-encoded (room-path's own owner-hex component) +const DEVICE_ID_HEX_LENGTH = 32; +// a P-256 ECDH shared secret's own byte length, standing in for a real one in these tests +const SHARED_SECRET_BYTE_LENGTH = 32; +// AES-256 +const CONTENT_KEY_BYTE_LENGTH = 32; const LOW_BYTE_MASK = 0xff; const ROOM_PATH = `${"aa".repeat(DEVICE_ID_HEX_LENGTH)}/general`; diff --git a/ts/packages/core/test/hex.ts b/ts/packages/core/test/hex.ts index f07c8dd3..f385f4e4 100644 --- a/ts/packages/core/test/hex.ts +++ b/ts/packages/core/test/hex.ts @@ -11,6 +11,7 @@ export function bytesFromHex(hex: string): Uint8Array { HEX_RADIX, ); } + return out; } @@ -18,5 +19,6 @@ export function bytesFromHex(hex: string): Uint8Array { export function deviceIdFromFillHex(fillHex: string): Uint8Array { const out = new Uint8Array(SHA256_BYTE_LENGTH); out.fill(Number.parseInt(fillHex, HEX_RADIX)); + return out; } diff --git a/ts/packages/core/test/hub-mailbox.unit.test.ts b/ts/packages/core/test/hub-mailbox.unit.test.ts index 0b63e919..facac15d 100644 --- a/ts/packages/core/test/hub-mailbox.unit.test.ts +++ b/ts/packages/core/test/hub-mailbox.unit.test.ts @@ -44,6 +44,7 @@ function setup(limits: MailboxLimits = generousLimits) { mailbox.handle(sender, frame, (reason) => { refusals.push(reason); }); + return { storage, handle, refusals }; } diff --git a/ts/packages/core/test/mesh-session-advert-verification.unit.test.ts b/ts/packages/core/test/mesh-session-advert-verification.unit.test.ts index 5aab2040..7d11219c 100644 --- a/ts/packages/core/test/mesh-session-advert-verification.unit.test.ts +++ b/ts/packages/core/test/mesh-session-advert-verification.unit.test.ts @@ -26,6 +26,7 @@ const EVENTS_THROUGH_CONNECT = 3; function isDirectoryEntry(value: unknown): value is DirectoryEntry { if (typeof value !== "object" || value === null) return false; if (!("device" in value) || !("advert" in value)) return false; + return ( value.device instanceof Uint8Array && typeof value.advert === "object" && @@ -48,6 +49,7 @@ function directoryOf(event: unknown): readonly DirectoryEntry[] { if (!isDirectoryEntryList(directory)) { throw new Error("session event's directory is not a list of entries"); } + return directory; } @@ -60,6 +62,7 @@ async function connectedSession(): Promise< const { transport, connection } = fakeTransport(); const session = createMeshSession(transport, testIdentity, testClock); await session.connect("ws://node", LOCAL_DOMAINS); + return { transport, connection, session }; } diff --git a/ts/packages/core/test/mesh-session-coordinator.unit.test.ts b/ts/packages/core/test/mesh-session-coordinator.unit.test.ts index 5b24bd5a..b65a5033 100644 --- a/ts/packages/core/test/mesh-session-coordinator.unit.test.ts +++ b/ts/packages/core/test/mesh-session-coordinator.unit.test.ts @@ -46,6 +46,7 @@ describe("coordinator-frame plumbing", () => { const iterator = session.coordinatorFrames[Symbol.asyncIterator](); const first = await iterator.next(); expect(first.done).toBe(false); + return yielded(first); })(); diff --git a/ts/packages/core/test/mesh-session-create.unit.test.ts b/ts/packages/core/test/mesh-session-create.unit.test.ts index 1a84beaa..5f6f804d 100644 --- a/ts/packages/core/test/mesh-session-create.unit.test.ts +++ b/ts/packages/core/test/mesh-session-create.unit.test.ts @@ -23,6 +23,7 @@ import { EVENTS_THROUGH_PING_ROUND_TRIP, EVENTS_THROUGH_REMOTE_HANDSHAKE, EVENTS_THROUGH_THREE_GOSSIPS, + drainEvents, EVENTS_THROUGH_TIMEOUT, FakeConnection, MS_PER_SECOND, @@ -45,6 +46,7 @@ import { withinShortWait, yielded, } from "./mesh-session-fixtures.js"; +import { repeatInSequence } from "./sequence.js"; /** Returns the one advert a gossip frame carries, having asserted it verifies under the given identity's own key: i.e. it was really signed by that node rather than merely carrying signature-shaped bytes. */ async function signedAdvertOf( @@ -57,6 +59,7 @@ async function signedAdvertOf( throw new Error("expected the gossip frame to carry one advert"); } expect(await verifyPeerAdvert(identity, advert)).toBe(true); + return advert; } @@ -345,31 +348,20 @@ describe("createMeshSession", () => { await session.connect("ws://node", ["core/data"]); await eventsDone; - // Enough frames past the window to prove the oldest were dropped, without - // making the test spend its time pushing frames nobody inspects. + /* Enough frames past the window to prove the oldest were dropped, without + making the test spend its time pushing frames nobody inspects. */ const framesPastWindow = 20; - for (let i = 0; i < FRAME_LOG_WINDOW + framesPastWindow; i++) { + await repeatInSequence(FRAME_LOG_WINDOW + framesPastWindow, async () => { await session.sendGossipUpdate(); - } - // Every send queued its own snapshot; drain the queue and keep the newest, - // since an earlier snapshot would still show the log mid-window. + }); + /* Every send queued its own snapshot; drain the queue and keep the newest, + since an earlier snapshot would still show the log mid-window. */ const eventsIterator = session.events[Symbol.asyncIterator](); - let newest: - | { frameLog: { direction: string; frame: { type: string } }[] } - | undefined; - for (;;) { - const result = await withinShortWait(eventsIterator.next()); - if (result === TIMEOUT_MARKER) break; - newest = yielded( - result as IteratorResult<{ - frameLog: { direction: string; frame: { type: string } }[]; - }>, - ); - } + const newest = await drainEvents(eventsIterator); expect(newest).toBeDefined(); expect(newest?.frameLog).toHaveLength(FRAME_LOG_WINDOW); - // The window keeps the newest frames, so the last entry is the final gossip - // send and nothing from before the window survives. + /* The window keeps the newest frames, so the last entry is the final gossip + send and nothing from before the window survives. */ expect(newest?.frameLog.at(-1)?.direction).toBe("sent"); expect(newest?.frameLog.at(-1)?.frame.type).toBe("gossip"); await session.close(); @@ -537,7 +529,8 @@ describe("createMeshSession", () => { const session = createMeshSession(transport, testIdentity, testClock); const eventsIterator = session.events[Symbol.asyncIterator](); const connectPromise = session.connect("ws://node", ["core/data"]); - await eventsIterator.next(); // connecting + // connecting + await eventsIterator.next(); await session.close(); const lateConnection = new FakeConnection(); if (dialResolver.resolve === null) @@ -575,9 +568,9 @@ describe("createMeshSession", () => { await session.connect("ws://node", ["core/data"]); connection.push(await gossipFor(identityA)); await session.close(); - for (let i = 0; i < EVENTS_THROUGH_FAILURE; i++) { + await repeatInSequence(EVENTS_THROUGH_FAILURE, async () => { await iterator.next(); - } + }); const result = await withinShortWait(iterator.next()); expect(result).toBe(TIMEOUT_MARKER); }); diff --git a/ts/packages/core/test/mesh-session-fixtures.ts b/ts/packages/core/test/mesh-session-fixtures.ts index 4f9bdc71..9836838d 100644 --- a/ts/packages/core/test/mesh-session-fixtures.ts +++ b/ts/packages/core/test/mesh-session-fixtures.ts @@ -8,13 +8,16 @@ import type { } from "../src/ports/transport.js"; import type { createMeshSession } from "../src/domain/mesh-session.js"; import { peerAdvertFor } from "./relay-hub-test-helpers.js"; +import { repeatInSequence } from "./sequence.js"; import { generateEd25519Identity } from "./tokens-fixtures.js"; import packageJson from "../package.json" with { type: "json" }; /** The same value mesh-session.ts's own OWN_VERSION reads -- asserted against here rather than a hardcoded literal so a self-advert/version.get expectation stays true regardless of what this package's own version happens to be. */ export const OWN_VERSION = packageJson.version; -// Real identities rather than device-ids invented from filler bytes: a session verifies every gossiped advert against the key it carries and drops one that does not verify (wire-mesh#225), so an advert naming a device no keypair produced would never reach the directory a test is asserting on. deviceA/B/C are the ids those identities derive, for tests that only need a device-id to point at. +/** + * Real identities rather than device-ids invented from filler bytes: a session verifies every gossiped advert against the key it carries and drops one that does not verify (wire-mesh#225), so an advert naming a device no keypair produced would never reach the directory a test is asserting on. deviceA/B/C are the ids those identities derive, for tests that only need a device-id to point at. + */ export const identityA = await generateEd25519Identity(); export const identityB = await generateEd25519Identity(); export const identityC = await generateEd25519Identity(); @@ -28,7 +31,9 @@ export const MS_PER_SECOND = 1000; export const TEST_CLOCK_NOW_MS = 1_700_000_000_000; export const testClock: Clock = { now: () => TEST_CLOCK_NOW_MS }; -// Event-stream positions: connect() emits connecting + connected, then a self-advert-sent tick, then one event per pushed frame, timeout, or failure. +/** + * Event-stream positions: connect() emits connecting + connected, then a self-advert-sent tick, then one event per pushed frame, timeout, or failure. + */ export const EVENTS_THROUGH_REMOTE_HANDSHAKE = 4; export const EVENTS_THROUGH_TIMEOUT = 4; export const EVENTS_THROUGH_THREE_GOSSIPS = 6; @@ -38,7 +43,9 @@ export const SNAPSHOT_FIRST = 100; export const SNAPSHOT_SECOND = 200; export const SNAPSHOT_UPDATED = 300; -// Reconnect-flow event-stream positions: each reconnect round emits connecting + connected(pending) + self-advert-sent, then either a further reconnecting (retrying) or closed (attempts exhausted) event. +/** + * Reconnect-flow event-stream positions: each reconnect round emits connecting + connected(pending) + self-advert-sent, then either a further reconnecting (retrying) or closed (attempts exhausted) event. + */ export const RECONNECT_DELAY_MS = 100; export const RECONNECT_MAX_ATTEMPTS = 2; /** Events up to and including the first failed dial's report: connecting, then reconnecting or closed. */ @@ -47,7 +54,9 @@ export const EVENTS_THROUGH_FIRST_RECONNECT = 4; export const EVENTS_PER_RECONNECT_ROUND = 4; export const EVENTS_THROUGH_STALE_TIMER_REGRESSION = 8; -// capability-tokens/manage-request plumbing: arbitrary distinct signature/request-id byte values, and the manage-request's own reply-timeout duration. +/** + * capability-tokens/manage-request plumbing: arbitrary distinct signature/request-id byte values, and the manage-request's own reply-timeout duration. + */ export const TEST_TOKEN_SIGNATURE_BYTE = 3; export const TEST_INCOMING_REQUEST_ID = 7; export const OVERRIDE_TOKEN_BYTE = 9; @@ -56,8 +65,11 @@ export const MANAGE_REQUEST_TIMEOUT_MS = 5000; /** An in-memory Connection the test drives: pushes arrive on the receive iteration, sends are recorded. */ export class FakeConnection { sent: Frame[] = []; + private readonly inbound: Frame[] = []; + private ended = false; + private failure: Error | null = null; /** Constructs a fake, already-authenticated connection whose own `peerDeviceId` is set from the start -- the transport-authenticated case topology self-advertisement's own `direct` field prefers over any gossip-derived guess (mesh-session.ts's directPeerDevice). Omit for the default, unauthenticated case every other test here relies on. */ @@ -67,12 +79,14 @@ export class FakeConnection { return { send: async (frame: Frame): Promise => { this.sent.push(frame); + return Promise.resolve(); }, receive: () => this.stream(), close: async (): Promise => { this.ended = true; this.wake(); + return Promise.resolve(); }, ...(this.authenticatedPeerDeviceId !== undefined @@ -119,21 +133,21 @@ export class FakeConnection { } private async nextFrame(): Promise> { - for (;;) { - const next = this.inbound.shift(); - if (next !== undefined) { - return { value: next, done: false }; - } - if (this.failure !== null) { - throw this.failure; - } - if (this.ended) { - return { value: undefined, done: true }; - } - await new Promise((resolve) => { - this.wakeWaiters.push(resolve); - }); + const next = this.inbound.shift(); + if (next !== undefined) { + return { value: next, done: false }; + } + if (this.failure !== null) { + throw this.failure; } + if (this.ended) { + return { value: undefined, done: true }; + } + await new Promise((resolve) => { + this.wakeWaiters.push(resolve); + }); + + return this.nextFrame(); } } @@ -147,11 +161,13 @@ export function fakeTransport(authenticatedPeerDeviceId?: DeviceId): { if (address !== "ws://node") { return Promise.reject(new Error(`connect to ${address} failed`)); } + return Promise.resolve(connection.connection); }, listen: async (): Promise => Promise.reject(new Error("client-only transport")), }; + return { transport, connection }; } @@ -168,11 +184,13 @@ export function multiConnectionTransport(): { } const next = new FakeConnection(); connections.push(next); + return Promise.resolve(next.connection); }, listen: async (): Promise => Promise.reject(new Error("client-only transport")), }; + return { transport, connections }; } @@ -181,6 +199,7 @@ export function yielded(result: IteratorResult): T { if (result.done === true) { throw new Error("expected the iterator to yield a value, got done: true"); } + return result.value; } @@ -208,13 +227,27 @@ export async function nthEvent( ): Promise { const iterator = session.events[Symbol.asyncIterator](); let last: unknown = null; - for (let i = 0; i < count; i++) { + await repeatInSequence(count, async () => { const result = await iterator.next(); last = result.value; - } + }); + return last; } +/** Takes events from an iterator until none arrives within the short wait, and returns the last one taken (`newest` when none did). */ +export async function drainEvents( + iterator: Readonly>, + newest?: T, +): Promise { + const result = await withinShortWait(iterator.next()); + if (result === TIMEOUT_MARKER) { + return newest; + } + + return drainEvents(iterator, yielded(result)); +} + /** A gossip frame carrying one properly signed advert for the given identity, so a session verifying it applies it rather than dropping it. */ export async function gossipFor( identity: Readonly, diff --git a/ts/packages/core/test/mesh-session-manage.unit.test.ts b/ts/packages/core/test/mesh-session-manage.unit.test.ts index cb6c4713..5002c115 100644 --- a/ts/packages/core/test/mesh-session-manage.unit.test.ts +++ b/ts/packages/core/test/mesh-session-manage.unit.test.ts @@ -295,6 +295,7 @@ describe("capability tokens and manage-request plumbing", () => { IteratorResult > => { const iterator = session.incomingManageRequests[Symbol.asyncIterator](); + return iterator.next(); })(); diff --git a/ts/packages/core/test/mesh-session-ping-pong.unit.test.ts b/ts/packages/core/test/mesh-session-ping-pong.unit.test.ts index 00979fbb..b0d70955 100644 --- a/ts/packages/core/test/mesh-session-ping-pong.unit.test.ts +++ b/ts/packages/core/test/mesh-session-ping-pong.unit.test.ts @@ -23,6 +23,7 @@ function mutableClock(startMs: number): { advanceMs: (ms: number) => void; } { let now = startMs; + return { clock: { now: () => now }, advanceMs: (ms: number): void => { diff --git a/ts/packages/core/test/mesh-session-reconnect.unit.test.ts b/ts/packages/core/test/mesh-session-reconnect.unit.test.ts index 0c4dba79..57b96d3f 100644 --- a/ts/packages/core/test/mesh-session-reconnect.unit.test.ts +++ b/ts/packages/core/test/mesh-session-reconnect.unit.test.ts @@ -23,6 +23,25 @@ import { testClock, testIdentity, } from "./mesh-session-fixtures.js"; +import { repeatInSequence } from "./sequence.js"; + +/** The latest event taken, stopping at the first whose state is closed or after `remaining` events. */ +async function eventsUntilClosed( + iterator: Readonly>, + remaining: number, +): Promise<{ state: { status: string; reason?: string } } | null> { + if (remaining === 0) { + return null; + } + const result = (await iterator.next()) as { + value: { state: { status: string; reason?: string } }; + }; + if (result.value.state.status === "closed") { + return result.value; + } + + return (await eventsUntilClosed(iterator, remaining - 1)) ?? result.value; +} describe("reconnect policy", () => { it("never reconnects when no policy is given, matching today's default behavior", async () => { @@ -88,7 +107,7 @@ describe("reconnect policy", () => { await session.connect("ws://node", ["core/data"]); // More drops than the policy allows attempts in all, each after the peer has answered. const drops = 4; - for (let drop = 0; drop < drops; drop++) { + await repeatInSequence(drops, async (drop) => { await vi.waitFor(() => { expect(connections).toHaveLength(drop + 1); }); @@ -99,7 +118,7 @@ describe("reconnect policy", () => { expect(connections[drop]?.sent.length).toBeGreaterThan(1); }); connections[drop]?.fail(new Error("dropped")); - } + }); await vi.waitFor(() => { expect(connections).toHaveLength(drops + 1); }); @@ -262,6 +281,7 @@ describe("reconnect policy", () => { } const next = new FakeConnection(); connections.push(next); + return Promise.resolve(next.connection); }, listen: async (): Promise => @@ -277,17 +297,8 @@ describe("reconnect policy", () => { await vi.advanceTimersByTimeAsync(RECONNECT_DELAY_MS); const iterator = session.events[Symbol.asyncIterator](); - let event: { state: { status: string; reason?: string } } | null = null; const MAX_EVENTS_TO_SCAN = 10; - for (let i = 0; i < MAX_EVENTS_TO_SCAN; i++) { - const result = (await iterator.next()) as { - value: { state: { status: string; reason?: string } }; - }; - event = result.value; - if (event.state.status === "closed") { - break; - } - } + const event = await eventsUntilClosed(iterator, MAX_EVENTS_TO_SCAN); expect(event?.state.status).toBe("closed"); expect(event?.state.reason).toBe("dial failed on retry"); } finally { diff --git a/ts/packages/core/test/mesh-session-redial-address.unit.test.ts b/ts/packages/core/test/mesh-session-redial-address.unit.test.ts index 4525d4ba..1b15a7e8 100644 --- a/ts/packages/core/test/mesh-session-redial-address.unit.test.ts +++ b/ts/packages/core/test/mesh-session-redial-address.unit.test.ts @@ -21,6 +21,7 @@ describe("redialAddress", () => { dialled.push(address); const fake = new FakeConnection(); fakes.push(fake); + // Only the first connection has learned a newer address; the second is dialled with it and learns nothing more. return Promise.resolve( dialled.length === 1 @@ -51,6 +52,7 @@ describe("redialAddress", () => { dialled.push(address); const fake = new FakeConnection(); fakes.push(fake); + return Promise.resolve(fake.connection); }, listen: async () => Promise.reject(new Error("client-only transport")), diff --git a/ts/packages/core/test/mesh-session-relay-data-frames.unit.test.ts b/ts/packages/core/test/mesh-session-relay-data-frames.unit.test.ts index d181cb08..6e9390b1 100644 --- a/ts/packages/core/test/mesh-session-relay-data-frames.unit.test.ts +++ b/ts/packages/core/test/mesh-session-relay-data-frames.unit.test.ts @@ -53,6 +53,7 @@ async function connectedSession(): Promise<{ incoming.push(received); } })(); + return { session, connection, incoming }; } diff --git a/ts/packages/core/test/mesh-session-routing.unit.test.ts b/ts/packages/core/test/mesh-session-routing.unit.test.ts index c5df94ad..860d1d1e 100644 --- a/ts/packages/core/test/mesh-session-routing.unit.test.ts +++ b/ts/packages/core/test/mesh-session-routing.unit.test.ts @@ -46,6 +46,7 @@ describe("relay routing", () => { `expected a frame at index ${String(index)}, got ${String(frames.length)} frames`, ); } + return frame; } @@ -148,6 +149,7 @@ describe("relay routing", () => { const incomingDone = (async (): Promise => { const iterator = session.incomingManageRequests[Symbol.asyncIterator](); const result = await iterator.next(); + return result.value as IncomingManageRequest; })(); @@ -187,6 +189,7 @@ describe("relay routing", () => { const incomingDone = (async (): Promise => { const iterator = session.incomingManageRequests[Symbol.asyncIterator](); const result = await iterator.next(); + return result.value as IncomingManageRequest; })(); @@ -212,6 +215,7 @@ describe("relay routing", () => { const incomingDone = (async (): Promise => { const iterator = session.incomingManageRequests[Symbol.asyncIterator](); const result = await iterator.next(); + return result.value as IncomingManageRequest; })(); diff --git a/ts/packages/core/test/mesh-session-secure-relay.unit.test.ts b/ts/packages/core/test/mesh-session-secure-relay.unit.test.ts index 1a4b5fd4..0c006c6c 100644 --- a/ts/packages/core/test/mesh-session-secure-relay.unit.test.ts +++ b/ts/packages/core/test/mesh-session-secure-relay.unit.test.ts @@ -63,6 +63,7 @@ async function connectedSession(): Promise<{ incoming.push(request); } })(); + return { session, connection, incoming }; } diff --git a/ts/packages/core/test/mesh-session-send-direct-manage-request.unit.test.ts b/ts/packages/core/test/mesh-session-send-direct-manage-request.unit.test.ts index 80e81430..852265f2 100644 --- a/ts/packages/core/test/mesh-session-send-direct-manage-request.unit.test.ts +++ b/ts/packages/core/test/mesh-session-send-direct-manage-request.unit.test.ts @@ -48,12 +48,14 @@ function directTransport(peerDeviceId?: DeviceId): { if (address !== DIRECT_ADDRESS) { return Promise.reject(new Error(`connect to ${address} failed`)); } + return Promise.resolve({ ...fake.connection, ...(peerDeviceId !== undefined ? { peerDeviceId } : {}), }); }, }; + return { transport, fake }; } diff --git a/ts/packages/core/test/node-fs-storage.integration.test.ts b/ts/packages/core/test/node-fs-storage.integration.test.ts index 0f72195f..256454e7 100644 --- a/ts/packages/core/test/node-fs-storage.integration.test.ts +++ b/ts/packages/core/test/node-fs-storage.integration.test.ts @@ -10,6 +10,7 @@ let tmpDirs: string[] = []; async function freshDir(): Promise { const dir = await fs.mkdtemp(path.join(os.tmpdir(), "node-fs-storage-")); tmpDirs.push(dir); + return dir; } diff --git a/ts/packages/core/test/node-identity.unit.test.ts b/ts/packages/core/test/node-identity.unit.test.ts index ca10a16a..9ffce044 100644 --- a/ts/packages/core/test/node-identity.unit.test.ts +++ b/ts/packages/core/test/node-identity.unit.test.ts @@ -38,6 +38,7 @@ async function generateEs256Identity(withEcdh: boolean): Promise { false, ["deriveBits"], ); + return createNodeIdentity( keyPair.privateKey, publicKeyBytes, diff --git a/ts/packages/core/test/notice-board.unit.test.ts b/ts/packages/core/test/notice-board.unit.test.ts index c17ed9de..80f3e777 100644 --- a/ts/packages/core/test/notice-board.unit.test.ts +++ b/ts/packages/core/test/notice-board.unit.test.ts @@ -31,6 +31,7 @@ import type { DeviceId, } from "../src/generated/protocol.js"; import type { IncomingManageRequest } from "../src/domain/mesh-session.js"; +import { inSequence } from "./sequence.js"; const ES256 = -7; const HOUR_MS = 3_600_000; @@ -64,6 +65,7 @@ async function generateEs256Identity(): Promise { false, ["deriveBits"], ); + return createNodeIdentity( keyPair.privateKey, publicKeyBytes, @@ -88,12 +90,14 @@ async function mintRoomMemberToken( delegationsRemaining: 0, }); if (!verdict.ok) throw new Error(`mint failed: ${verdict.reason}`); + return verdict.token; } /** A minimal in-memory RoomKeyStore -- the same shape an application wires from createRoomRekeyHandler's onRekey events. */ function memoryKeyStore(): RoomKeyStore { const keys = new Map>(); + return { async get(room, epoch) { return Promise.resolve(keys.get(room)?.get(epoch)); @@ -112,15 +116,23 @@ function memoryKeyStore(): RoomKeyStore { } /** Delivers the real room.rekey handler path so the member's key store is populated exactly as an application's would be. */ -async function rekeyMember( - owner: IdentityPort, - member: IdentityPort, - memberToken: CapabilityToken, - roomPath: string, - epoch: number, - contentKey: Uint8Array, - memberKeys: Readonly, -): Promise { +async function rekeyMember({ + owner, + member, + memberToken, + roomPath, + epoch, + contentKey, + memberKeys, +}: Readonly<{ + owner: IdentityPort; + member: IdentityPort; + memberToken: CapabilityToken; + roomPath: string; + epoch: number; + contentKey: Uint8Array; + memberKeys: Readonly; +}>): Promise { const sharedSecret = await owner.deriveSharedSecret?.(member.identityKey); if (sharedSecret === undefined) throw new Error("no ECDH"); const wrappingKey = await deriveWrappingKey(sharedSecret, { @@ -140,13 +152,15 @@ async function rekeyMember( revocation: createRevocationView(), ownRoomMemberToken: memberToken, onRekey: async (event: Readonly) => { - for (const [i, key] of event.contentKeys.entries()) { - await memberKeys.set( - roomPath, - event.keyEpoch - event.contentKeys.length + 1 + i, - key, - ); - } + await Promise.all( + event.contentKeys.map(async (key, i) => + memberKeys.set( + roomPath, + event.keyEpoch - event.contentKeys.length + 1 + i, + key, + ), + ), + ); }, }); await handler(incoming); @@ -231,15 +245,15 @@ describe("createNoticeBoard", () => { // Member receives epoch 1 through the real handler path. const epochKey = generateContentKey(); const memberKeys = memoryKeyStore(); - await rekeyMember( + await rekeyMember({ owner, member, memberToken, roomPath, - FIRST_EPOCH, - epochKey, + epoch: FIRST_EPOCH, + contentKey: epochKey, memberKeys, - ); + }); // Member posts encrypted to its own log. const memberStorage = createMemoryStorage(); @@ -316,15 +330,15 @@ describe("createNoticeBoard", () => { roomKeys: keyStore, }); - // Well-formed-ish COSE array bytes with no valid signature over claims: - // array(4) [ bstr(0), map(0), bstr(4), bstr(1,2,3,4) ] -- a cose-sign1 - // tuple shape whose payload is far too short to be real claims. + /* Well-formed-ish COSE array bytes with no valid signature over claims: + array(4) [ bstr(0), map(0), bstr(4), bstr(1,2,3,4) ] -- a cose-sign1 + tuple shape whose payload is far too short to be real claims. */ const CBOR_ARRAY_HEAD = 0x84; const CBOR_EMPTY_BSTR = 0x40; const CBOR_EMPTY_MAP = 0xa0; const CBOR_SHORT_BSTR = 0x44; - // Arbitrary filler bytes standing in for a bogus-signature bstr, - // spelled as text so no raw byte literals trip the magic-number rule. + /* Arbitrary filler bytes standing in for a bogus-signature bstr, + spelled as text so no raw byte literals trip the magic-number rule. */ const FILLER_BYTES = Array.from("wxyz", (c) => c.charCodeAt(0)); const garbage = Uint8Array.from([ CBOR_ARRAY_HEAD, @@ -390,6 +404,7 @@ describe("notices in a DM", () => { deviceIdToHex(first.deviceId) < deviceIdToHex(second.deviceId); const a = firstIsLower ? first : second; const b = firstIsLower ? second : first; + return { a, b, @@ -408,6 +423,7 @@ describe("notices in a DM", () => { const keys = memoryKeyStore(); await keys.set(roomPath, FIRST_EPOCH, key); const storage = createMemoryStorage(); + return { board: createNoticeBoard({ identity, @@ -454,16 +470,25 @@ describe("notices in a DM", () => { plaintext: new TextEncoder().encode("self-issued"), }); - for (const [poster, expected] of [ - [granted, true], - [selfIssued, false], - ] as const) { - const reader = await boardFor(b, roomPath, key); - for (const entry of await readEntries(poster.storage, a.deviceId, 0)) { - await reader.board.ingestPeerEntry(a.deviceId, new Uint8Array(entry)); - } - const read = await reader.board.readPeerNotices(a.deviceId, roomPath); - expect(read[0]?.verified).toBe(expected); - } + await inSequence( + [ + [granted, true], + [selfIssued, false], + ] as const, + async ([poster, expected]) => { + const reader = await boardFor(b, roomPath, key); + await inSequence( + await readEntries(poster.storage, a.deviceId, 0), + async (entry) => { + await reader.board.ingestPeerEntry( + a.deviceId, + new Uint8Array(entry), + ); + }, + ); + const read = await reader.board.readPeerNotices(a.deviceId, roomPath); + expect(read[0]?.verified).toBe(expected); + }, + ); }); }); diff --git a/ts/packages/core/test/path-trace.unit.test.ts b/ts/packages/core/test/path-trace.unit.test.ts index 030c0aaa..26660127 100644 --- a/ts/packages/core/test/path-trace.unit.test.ts +++ b/ts/packages/core/test/path-trace.unit.test.ts @@ -90,9 +90,11 @@ describe("tracePath", () => { calls: number; } { let calls = 0; + return { sendManageRequest: async (): Promise => { calls += 1; + return Promise.resolve(outcome); }, get calls(): number { diff --git a/ts/packages/core/test/pinned-address.unit.test.ts b/ts/packages/core/test/pinned-address.unit.test.ts index 756e2768..c5a7c79d 100644 --- a/ts/packages/core/test/pinned-address.unit.test.ts +++ b/ts/packages/core/test/pinned-address.unit.test.ts @@ -11,7 +11,8 @@ import { } from "../src/domain/pinned-address.js"; const HASH_BYTES = 32; -const HASH_BYTE = 0xab; // the value of each byte of HASH_HEX +// the value of each byte of HASH_HEX +const HASH_BYTE = 0xab; const HASH_HEX = "ab".repeat(HASH_BYTES); describe("pinned addresses", () => { diff --git a/ts/packages/core/test/relay-hub-authenticated-gossip.unit.test.ts b/ts/packages/core/test/relay-hub-authenticated-gossip.unit.test.ts index c4a0be81..f967c806 100644 --- a/ts/packages/core/test/relay-hub-authenticated-gossip.unit.test.ts +++ b/ts/packages/core/test/relay-hub-authenticated-gossip.unit.test.ts @@ -26,6 +26,7 @@ function connect(hub: Readonly>): { handling: Promise; } { const fake = new FakeConnection(); + return { fake, handling: hub.handleConnection(fake.connection) }; } diff --git a/ts/packages/core/test/relay-hub-extension-policy.unit.test.ts b/ts/packages/core/test/relay-hub-extension-policy.unit.test.ts index d528fa64..f2202a97 100644 --- a/ts/packages/core/test/relay-hub-extension-policy.unit.test.ts +++ b/ts/packages/core/test/relay-hub-extension-policy.unit.test.ts @@ -45,6 +45,7 @@ function connect(hub: Readonly>): { handling: Promise; } { const fake = new FakeConnection(); + return { fake, handling: hub.handleConnection(fake.connection) }; } diff --git a/ts/packages/core/test/relay-hub-fronting.unit.test.ts b/ts/packages/core/test/relay-hub-fronting.unit.test.ts index 24e74779..f4d256f3 100644 --- a/ts/packages/core/test/relay-hub-fronting.unit.test.ts +++ b/ts/packages/core/test/relay-hub-fronting.unit.test.ts @@ -30,6 +30,7 @@ async function fixture() { caller.push(client.gossip); await settle(gateway, caller); const everyone = [gateway, caller]; + return { hub, front1, front2, client, gateway, caller, handling, everyone }; } diff --git a/ts/packages/core/test/relay-hub-hibernation.unit.test.ts b/ts/packages/core/test/relay-hub-hibernation.unit.test.ts index 7e6debc4..e064f7d3 100644 --- a/ts/packages/core/test/relay-hub-hibernation.unit.test.ts +++ b/ts/packages/core/test/relay-hub-hibernation.unit.test.ts @@ -24,10 +24,17 @@ const relayPayload = bytesFromHex("deadbeef"); /** Establishes the production pairing shape on a hub: both sides gossip, then a dials b. Returns the stable Connection objects, since FakeConnection's own getter hands back a fresh object on every access. */ async function pairOver( hub: Readonly, - a: FakeConnection, - peerA: TestPeer, - b: FakeConnection, - peerB: TestPeer, + { + a, + peerA, + b, + peerB, + }: Readonly<{ + a: FakeConnection; + peerA: TestPeer; + b: FakeConnection; + peerB: TestPeer; + }>, ): Promise<{ aConnection: Connection; bConnection: Connection }> { const aConnection = a.connection; const bConnection = b.connection; @@ -39,6 +46,7 @@ async function pairOver( type: "relay-connect", "target-device": peerB.device, }); + return { aConnection, bConnection }; } @@ -48,6 +56,7 @@ function wokenHubOver( ): ReturnType { const hub = createRelayHub({ identity: hubVerifier }); hub.restoreConnections(entries); + return hub; } @@ -59,6 +68,7 @@ function restoreEntryFor( if (state === undefined) { throw new Error("connection has no exportable relay state"); } + return { connection, state }; } @@ -69,13 +79,12 @@ describe("relay state across a hub instance being torn down", () => { const peerB = await createTestPeer(); const a = new FakeConnection(); const b = new FakeConnection(); - const { aConnection, bConnection } = await pairOver( - established, + const { aConnection, bConnection } = await pairOver(established, { a, peerA, b, peerB, - ); + }); const woken = wokenHubOver([ restoreEntryFor(established, aConnection), @@ -188,7 +197,7 @@ describe("relay state across a hub instance being torn down", () => { const peerB = await createTestPeer(); const a = new FakeConnection(); const b = new FakeConnection(); - const { aConnection } = await pairOver(established, a, peerA, b, peerB); + const { aConnection } = await pairOver(established, { a, peerA, b, peerB }); const woken = wokenHubOver([restoreEntryFor(established, aConnection)]); a.sent.length = 0; @@ -216,7 +225,7 @@ describe("relay state across a hub instance being torn down", () => { const peerB = await createTestPeer(); const a = new FakeConnection(); const b = new FakeConnection(); - const { aConnection } = await pairOver(hub, a, peerA, b, peerB); + const { aConnection } = await pairOver(hub, { a, peerA, b, peerB }); expect(hub.exportConnection(aConnection)).toEqual({ device: peerA.device, @@ -286,13 +295,12 @@ describe("onConnectionStateChanged", () => { }); const a = new FakeConnection(); const b = new FakeConnection(); - const { aConnection, bConnection } = await pairOver( - hub, + const { aConnection, bConnection } = await pairOver(hub, { a, peerA, b, peerB, - ); + }); // Two gossip frames, then the relay-connect reporting the initiator and the target. expect(changed).toEqual([ diff --git a/ts/packages/core/test/relay-hub-mailbox.unit.test.ts b/ts/packages/core/test/relay-hub-mailbox.unit.test.ts index 434b80af..3d89c4ab 100644 --- a/ts/packages/core/test/relay-hub-mailbox.unit.test.ts +++ b/ts/packages/core/test/relay-hub-mailbox.unit.test.ts @@ -28,6 +28,7 @@ function connect( ): FakeConnection { const fake = new FakeConnection(); void hub.handleConnection(fake.connection); + return fake; } diff --git a/ts/packages/core/test/relay-hub-multiplexing.unit.test.ts b/ts/packages/core/test/relay-hub-multiplexing.unit.test.ts index 773571a6..bbc11f00 100644 --- a/ts/packages/core/test/relay-hub-multiplexing.unit.test.ts +++ b/ts/packages/core/test/relay-hub-multiplexing.unit.test.ts @@ -13,6 +13,7 @@ import { settle, type TestPeer, } from "./relay-hub-test-helpers.js"; +import { inSequence } from "./sequence.js"; const relayPayload = bytesFromHex("deadbeef"); @@ -25,10 +26,10 @@ interface Member { /** Gossips each member's advert in the order given, settling after each so every advert has been verified and applied before the next reaches the hub. The hub verifies before it registers, so simultaneous pushes would race and leave the registration order, and with it every catch-up frame, undetermined. */ async function gossipInOrder(members: readonly Member[]): Promise { const connections = members.map((member) => member.connection); - for (const member of members) { + await inSequence(members, async (member) => { member.connection.push(member.peer.gossip); await settle(...connections); - } + }); } /** @@ -41,6 +42,7 @@ function gossipReceivedBy( const index = adverts.indexOf(own); const before = adverts.slice(0, index); const after = adverts.slice(index + 1); + return [ ...before.map((advert) => gossipFor(advert)), ...(before.length > 0 ? [gossipForMany(...before)] : []), diff --git a/ts/packages/core/test/relay-hub-test-helpers.ts b/ts/packages/core/test/relay-hub-test-helpers.ts index bb51532b..cfc74b43 100644 --- a/ts/packages/core/test/relay-hub-test-helpers.ts +++ b/ts/packages/core/test/relay-hub-test-helpers.ts @@ -1,6 +1,6 @@ -// Shared fixtures for relay-hub.unit.test.ts and relay-hub-multiplexing.unit.test.ts -- split out under this repo's max-lines cap the same way relay-hub-multiplexing.unit.test.ts's own multi-pairing/to-device coverage was split from the general single-pairing hub behaviour it grew alongside. -// -// Every fixture peer holds a real Ed25519 identity rather than a device-id invented from filler bytes: the hub verifies each advert's signature against the key the advert carries (wire-mesh#225), so an advert naming an id no keypair produced can never be registered, and a test built on one would exercise only the rejection path. +/* Shared fixtures for relay-hub.unit.test.ts and relay-hub-multiplexing.unit.test.ts -- split out under this repo's max-lines cap the same way relay-hub-multiplexing.unit.test.ts's own multi-pairing/to-device coverage was split from the general single-pairing hub behaviour it grew alongside. + + Every fixture peer holds a real Ed25519 identity rather than a device-id invented from filler bytes: the hub verifies each advert's signature against the key the advert carries (wire-mesh#225), so an advert naming an id no keypair produced can never be registered, and a test built on one would exercise only the rejection path. */ import { deriveDeviceId, @@ -44,18 +44,23 @@ export interface Idleable { export async function settle( ...connections: readonly Readonly[] ): Promise { - do { - await tick(); - } while (!connections.every((connection) => connection.idle)); + await tick(); + if (!connections.every((connection) => connection.idle)) { + await settle(...connections); + } } /** An in-memory Connection driving the hub through the port contract: queued inbound frames the test pushes, and a record of everything the hub sends back. */ export class FakeConnection { inbound: Frame[] = []; + sent: Frame[] = []; + /** When set, every subsequent send() rejects with this error instead of recording the frame -- simulates a peer whose own connection has died from the hub's perspective, without needing a second connection class. */ sendRejection: Error | null = null; + private closed = false; + private readonly wakeWaiters: (() => void)[] = []; /** True when every frame pushed so far has been consumed by the hub loop and its handling has finished: the loop is parked waiting for the next frame (or the stream has ended), so nothing is still in flight on this connection's behalf. */ @@ -72,12 +77,14 @@ export class FakeConnection { throw this.sendRejection; } this.sent.push(frame); + return Promise.resolve(); }, receive: () => this.stream(), close: async (): Promise => { this.closed = true; this.wake(); + return Promise.resolve(); }, }; @@ -92,6 +99,7 @@ export class FakeConnection { async end(): Promise { this.closed = true; this.wake(); + return Promise.resolve(); } @@ -114,18 +122,18 @@ export class FakeConnection { } private async drain(): Promise> { - for (;;) { - const next = this.inbound.shift(); - if (next !== undefined) { - return { value: next, done: false }; - } - if (this.closed) { - return { value: undefined, done: true }; - } - await new Promise((resolve) => { - this.wakeWaiters.push(resolve); - }); + const next = this.inbound.shift(); + if (next !== undefined) { + return { value: next, done: false }; } + if (this.closed) { + return { value: undefined, done: true }; + } + await new Promise((resolve) => { + this.wakeWaiters.push(resolve); + }); + + return this.drain(); } } @@ -156,6 +164,7 @@ export async function createTestPeer( ): Promise { const identity = await generateEd25519Identity(); const advert = await peerAdvertFor(identity, snapshotSeconds); + return { identity, device: identity.deviceId, @@ -176,6 +185,7 @@ export function withBrokenSignature(advert: Readonly): PeerAdvert { throw new Error("test setup: signature has no bytes to corrupt"); } signature[0] = firstByte ^ LOW_BYTE_MASK; + return { ...advert, signature }; } @@ -201,6 +211,7 @@ export interface LinkedConnections { export function linkedConnections(): LinkedConnections { const gateway = new FakeConnection(); const upstream = new FakeConnection(); + return { gateway, upstream, @@ -208,6 +219,7 @@ export function linkedConnections(): LinkedConnections { send: async (frame) => { gateway.sent.push(frame); upstream.push(frame); + return Promise.resolve(); }, receive: () => gateway.connection.receive(), @@ -217,6 +229,7 @@ export function linkedConnections(): LinkedConnections { send: async (frame) => { upstream.sent.push(frame); gateway.push(frame); + return Promise.resolve(); }, receive: () => upstream.connection.receive(), diff --git a/ts/packages/core/test/relay-hub-uplink.unit.test.ts b/ts/packages/core/test/relay-hub-uplink.unit.test.ts index 95c0918f..a3b6a382 100644 --- a/ts/packages/core/test/relay-hub-uplink.unit.test.ts +++ b/ts/packages/core/test/relay-hub-uplink.unit.test.ts @@ -54,6 +54,7 @@ async function topology(): Promise { await settle(...everyone); behindConnection.push(behind.gossip); await settle(...everyone); + return { gateway, upstream, diff --git a/ts/packages/core/test/relay-hub.unit.test.ts b/ts/packages/core/test/relay-hub.unit.test.ts index 1efc59e8..3d2e0a8f 100644 --- a/ts/packages/core/test/relay-hub.unit.test.ts +++ b/ts/packages/core/test/relay-hub.unit.test.ts @@ -23,14 +23,18 @@ const orphanPayload = bytesFromHex("aa"); /** A Connection whose receive() stream delivers pushed frames until rejectNow(), then rejects -- the mid-stream hostile-input failure the real adapter produces for undecodable bytes. */ class RejectingAfterFramesConnection { inbound: Frame[] = []; + sent: Frame[] = []; + private rejection: Error | null = null; + private readonly wakeWaiters: (() => void)[] = []; get connection(): Readonly { return { send: async (frame: Frame): Promise => { this.sent.push(frame); + return Promise.resolve(); }, receive: () => this.stream(), @@ -69,18 +73,18 @@ class RejectingAfterFramesConnection { } private async step(): Promise> { - for (;;) { - const next = this.inbound.shift(); - if (next !== undefined) { - return { value: next, done: false }; - } - if (this.rejection !== null) { - throw this.rejection; - } - await new Promise((resolve) => { - this.wakeWaiters.push(resolve); - }); + const next = this.inbound.shift(); + if (next !== undefined) { + return { value: next, done: false }; } + if (this.rejection !== null) { + throw this.rejection; + } + await new Promise((resolve) => { + this.wakeWaiters.push(resolve); + }); + + return this.step(); } } diff --git a/ts/packages/core/test/relay-peer.ts b/ts/packages/core/test/relay-peer.ts index ee1259ff..4b0ea642 100644 --- a/ts/packages/core/test/relay-peer.ts +++ b/ts/packages/core/test/relay-peer.ts @@ -1,5 +1,6 @@ // The far end of a relay pairing, for tests that drive a MeshSession over a FakeConnection: a real identity that speaks the secure channel (spec/secure-channel.cddl) the way a peer reached through a hub does, so a test can deliver a frame "from" it and read what the session sent it without a hub in the middle. +import { vi } from "vitest"; import { tryDecodeFrame, wrapRelayData } from "../src/adapters/frame-codec.js"; import { deviceIdToHex } from "../src/domain/device-id.js"; import { @@ -16,14 +17,11 @@ import type { } from "../src/generated/protocol.js"; import type { IdentityPort } from "../src/ports/identity.js"; import type { FakeConnection } from "./mesh-session-fixtures.js"; +import { inSequence } from "./sequence.js"; const POLL_INTERVAL_MS = 1; - -async function sleep(): Promise { - return new Promise((resolve) => { - setTimeout(resolve, POLL_INTERVAL_MS); - }); -} +/** How long a peer waits for the session to send something: vitest's own default test timeout, so a missing frame fails here with its own message rather than as a timed out test. */ +const WAIT_TIMEOUT_MS = 5_000; function relayData(frame: Frame): RelayDataFrame | undefined { return frame.type === "relay-data" ? frame : undefined; @@ -35,11 +33,13 @@ function nestedFor(frame: Frame, device: DeviceId): Frame | null { const target = wrapped?.["to-device"]; if (wrapped === undefined || target === undefined) return null; if (deviceIdToHex(target) !== deviceIdToHex(device)) return null; + return tryDecodeFrame(wrapped.payload); } export class RelayPeer { private channel: SecureChannel | undefined; + private consumed = 0; constructor( @@ -55,19 +55,24 @@ export class RelayPeer { private hellosSent(): SecureHelloFrame[] { return this.link.sent.flatMap((sent) => { const inner = nestedFor(sent, this.deviceId); + return inner?.type === "secure-hello" ? [inner] : []; }); } /** The hello the session sent this peer after the first `skip`, waiting for it to appear. */ private async sessionHello(skip = 0): Promise { - for (;;) { - const hello = this.hellosSent()[skip]; - if (hello !== undefined) { + return vi.waitFor( + () => { + const hello = this.hellosSent()[skip]; + if (hello === undefined) { + throw new Error("the session has not sent its hello yet"); + } + return hello; - } - await sleep(); - } + }, + { interval: POLL_INTERVAL_MS, timeout: WAIT_TIMEOUT_MS }, + ); } private async complete( @@ -135,21 +140,28 @@ export class RelayPeer { if (this.channel === undefined) { throw new Error("open the channel first"); } - for (;;) { - const opened: Frame[] = []; - const sealed = this.link.sent.flatMap((frame) => { - const inner = nestedFor(frame, this.deviceId); - return inner?.type === "secure-data" ? [inner] : []; - }); - for (const data of sealed.slice(this.consumed)) { - const inner = await this.channel.open(data); - if (inner !== undefined) opened.push(inner); - } - if (opened.length > 0) { + const { channel } = this; + + return vi.waitFor( + async () => { + const opened: Frame[] = []; + const sealed = this.link.sent.flatMap((frame) => { + const inner = nestedFor(frame, this.deviceId); + + return inner?.type === "secure-data" ? [inner] : []; + }); + await inSequence(sealed.slice(this.consumed), async (data) => { + const inner = await channel.open(data); + if (inner !== undefined) opened.push(inner); + }); + if (opened.length === 0) { + throw new Error("the session has sent no frame yet"); + } this.consumed = sealed.length; + return opened; - } - await sleep(); - } + }, + { interval: POLL_INTERVAL_MS, timeout: WAIT_TIMEOUT_MS }, + ); } } diff --git a/ts/packages/core/test/revocation-view.unit.test.ts b/ts/packages/core/test/revocation-view.unit.test.ts index 693d762e..f33e8869 100644 --- a/ts/packages/core/test/revocation-view.unit.test.ts +++ b/ts/packages/core/test/revocation-view.unit.test.ts @@ -6,7 +6,8 @@ import { createRevocationView } from "../src/domain/revocation-view.js"; import type { IdentityPort } from "../src/ports/identity.js"; const ES256 = -7; -const LOW_BYTE_MASK = 0xff; // XOR operand keeping the corrupted byte within one octet when tampering with a signature in this test +// XOR operand keeping the corrupted byte within one octet when tampering with a signature in this test +const LOW_BYTE_MASK = 0xff; function buf(bytes: Uint8Array | ArrayLike): Uint8Array { return Uint8Array.from(bytes); @@ -25,6 +26,7 @@ async function generateEs256Identity(): Promise { const publicKeyBytes = new Uint8Array( await webcrypto.subtle.exportKey("raw", keyPair.publicKey), ); + return createNodeIdentity(keyPair.privateKey, publicKeyBytes, ES256); } diff --git a/ts/packages/core/test/room-notice-creation.unit.test.ts b/ts/packages/core/test/room-notice-creation.unit.test.ts index ddec9bba..6acb74ce 100644 --- a/ts/packages/core/test/room-notice-creation.unit.test.ts +++ b/ts/packages/core/test/room-notice-creation.unit.test.ts @@ -36,6 +36,7 @@ async function mintRoomMemberToken( delegationsRemaining: 0, }); if (!verdict.ok) throw new Error(`mint failed: ${verdict.reason}`); + return verdict.token; } @@ -140,9 +141,9 @@ describe("createRoomNotice", () => { content: new TextEncoder().encode("hi"), refs: [{ id: priorNoticeId, relation: "reply" }], validUntil: VALID_UNTIL_MS, - // An encrypted notice: content-type carries the +aes256gcm suffix (room.cddl - // obligation 7), so key-epoch must be present alongside it -- the pairing - // verifyRoomNotice's key_epoch_mismatch check requires. + /* An encrypted notice: content-type carries the +aes256gcm suffix (room.cddl + obligation 7), so key-epoch must be present alongside it -- the pairing + verifyRoomNotice's key_epoch_mismatch check requires. */ contentType: encryptedContentType("text/plain"), keyEpoch: KEY_EPOCH, }); diff --git a/ts/packages/core/test/room-notice-fixtures.ts b/ts/packages/core/test/room-notice-fixtures.ts index f65180c2..cb6c560f 100644 --- a/ts/packages/core/test/room-notice-fixtures.ts +++ b/ts/packages/core/test/room-notice-fixtures.ts @@ -20,6 +20,7 @@ let issuedNoticeIds = 0; /** A fresh, distinct notice-id per call -- the tests only need each notice to be distinguishable from the others, not any particular byte value. */ export function nextNoticeId(): Uint8Array { issuedNoticeIds += 1; + return buf([issuedNoticeIds]); } @@ -68,5 +69,6 @@ export async function signRoomNotice( payload, ]); const signature = await identity.sign(toBeSigned); + return [protectedHeader, {}, payload, signature]; } diff --git a/ts/packages/core/test/room-notice-verification.unit.test.ts b/ts/packages/core/test/room-notice-verification.unit.test.ts index 0179b52d..a59add39 100644 --- a/ts/packages/core/test/room-notice-verification.unit.test.ts +++ b/ts/packages/core/test/room-notice-verification.unit.test.ts @@ -24,7 +24,8 @@ import { signRoomNotice } from "./room-notice-fixtures.js"; const NOW_MS = 1_893_456_000_000; const EXPIRES_MS = NOW_MS + HOUR_MS; -const INVALID_CBOR_BYTE = 0xff; // not a valid CBOR major-type/length prefix combination -- decode() throws on it +// not a valid CBOR major-type/length prefix combination -- decode() throws on it +const INVALID_CBOR_BYTE = 0xff; const FAR_FUTURE_HOURS = 1_000; const LONG_AFTER_POSTING_HOURS = 10; @@ -45,6 +46,7 @@ async function mintRoomMemberToken( delegationsRemaining: 0, }); if (!verdict.ok) throw new Error(`mint failed: ${verdict.reason}`); + return verdict.token; } diff --git a/ts/packages/core/test/room-rekey.unit.test.ts b/ts/packages/core/test/room-rekey.unit.test.ts index 4c415732..035b6bca 100644 --- a/ts/packages/core/test/room-rekey.unit.test.ts +++ b/ts/packages/core/test/room-rekey.unit.test.ts @@ -64,6 +64,7 @@ async function generateEs256Identity(): Promise { false, ["deriveBits"], ); + return createNodeIdentity( keyPair.privateKey, publicKeyBytes, @@ -75,6 +76,7 @@ async function generateEs256Identity(): Promise { let issuedTokenIds = 0; function nextTokenId(): Uint8Array { issuedTokenIds += 1; + return Uint8Array.from([issuedTokenIds]); } @@ -94,6 +96,7 @@ async function mintRoomMemberToken( delegationsRemaining: 0, }); if (!verdict.ok) throw new Error(`mint failed: ${verdict.reason}`); + return verdict.token; } @@ -112,6 +115,7 @@ function fakeIncoming( scope, respond, }; + return { incoming, respond }; } @@ -166,16 +170,23 @@ describe("createRoomRekeyHandler", () => { ownRoomMemberToken: memberToken, onRekey, }); + return { owner, member, roomPath, handler, onRekey }; } - async function ownerWraps( - owner: IdentityPort, - member: IdentityPort, - roomPath: string, - keyEpoch: number, - contentKey: Uint8Array, - ): Promise { + async function ownerWraps({ + owner, + member, + roomPath, + keyEpoch, + contentKey, + }: Readonly<{ + owner: IdentityPort; + member: IdentityPort; + roomPath: string; + keyEpoch: number; + contentKey: Uint8Array; + }>): Promise { if (owner.deriveSharedSecret === undefined) { throw new Error("owner identity must expose deriveSharedSecret"); } @@ -184,13 +195,20 @@ describe("createRoomRekeyHandler", () => { room: roomPath, keyEpoch, }); + return wrapContentKey(wrappingKey, contentKey); } it("unwraps a single-epoch (bare bstr) room.rekey and reports it via onRekey", async () => { const { owner, member, roomPath, handler, onRekey } = await setUp(); const contentKey = generateContentKey(); - const wrapped = await ownerWraps(owner, member, roomPath, 1, contentKey); + const wrapped = await ownerWraps({ + owner, + member, + roomPath, + keyEpoch: 1, + contentKey, + }); const { incoming, respond } = fakeIncoming( buildRoomRekeyCommand(1, wrapped), { kind: "room", path: roomPath }, @@ -210,8 +228,20 @@ describe("createRoomRekeyHandler", () => { const { owner, member, roomPath, handler, onRekey } = await setUp(); const epoch1Key = generateContentKey(); const epoch2Key = generateContentKey(); - const wrapped1 = await ownerWraps(owner, member, roomPath, 1, epoch1Key); - const wrapped2 = await ownerWraps(owner, member, roomPath, 2, epoch2Key); + const wrapped1 = await ownerWraps({ + owner, + member, + roomPath, + keyEpoch: 1, + contentKey: epoch1Key, + }); + const wrapped2 = await ownerWraps({ + owner, + member, + roomPath, + keyEpoch: 2, + contentKey: epoch2Key, + }); const { incoming } = fakeIncoming( buildRoomRekeyCommand(2, [wrapped1, wrapped2]), { kind: "room", path: roomPath }, @@ -245,7 +275,13 @@ describe("createRoomRekeyHandler", () => { onRekey, }); const contentKey = generateContentKey(); - const wrapped = await ownerWraps(owner, member, roomPath, 1, contentKey); + const wrapped = await ownerWraps({ + owner, + member, + roomPath, + keyEpoch: 1, + contentKey, + }); const { incoming, respond } = fakeIncoming( buildRoomRekeyCommand(1, wrapped), { kind: "room", path: roomPath }, @@ -265,7 +301,13 @@ describe("createRoomRekeyHandler", () => { const { member, roomPath, handler, onRekey } = await setUp(); const attacker = await generateEs256Identity(); const contentKey = generateContentKey(); - const wrapped = await ownerWraps(attacker, member, roomPath, 1, contentKey); + const wrapped = await ownerWraps({ + owner: attacker, + member, + roomPath, + keyEpoch: 1, + contentKey, + }); const { incoming, respond } = fakeIncoming( buildRoomRekeyCommand(1, wrapped), { kind: "room", path: roomPath }, @@ -287,13 +329,13 @@ describe("createRoomRekeyHandler", () => { "other", ); const contentKey = generateContentKey(); - const wrapped = await ownerWraps( + const wrapped = await ownerWraps({ owner, member, - otherRoomPath, - 1, + roomPath: otherRoomPath, + keyEpoch: 1, contentKey, - ); + }); const { incoming, respond } = fakeIncoming( buildRoomRekeyCommand(1, wrapped), { kind: "room", path: otherRoomPath }, @@ -349,7 +391,8 @@ describe("createRoomRekeyHandler", () => { it("refuses when the local identity has no deriveSharedSecret (e.g. an Ed25519-only identity)", async () => { const owner = await generateEs256Identity(); - const member = await generateEs256Identity(); // ECDH-capable, but the handler below is built with a plain, non-ECDH identity for the *recipient* role + // ECDH-capable, but the handler below is built with a plain, non-ECDH identity for the *recipient* role + const member = await generateEs256Identity(); const roomPath = ownerNamedRoomPath( deviceIdToHex(owner.deviceId), "general", @@ -381,7 +424,13 @@ describe("createRoomRekeyHandler", () => { onRekey, }); const contentKey = generateContentKey(); - const wrapped = await ownerWraps(owner, member, roomPath, 1, contentKey); + const wrapped = await ownerWraps({ + owner, + member, + roomPath, + keyEpoch: 1, + contentKey, + }); const { incoming, respond } = fakeIncoming( buildRoomRekeyCommand(1, wrapped), { kind: "room", path: roomPath }, @@ -396,13 +445,13 @@ describe("createRoomRekeyHandler", () => { expect(onRekey).not.toHaveBeenCalled(); }); - // The DM bootstrap scenario wire-mesh#36's consumer work surfaced: in a DM, - // each participant's own held token chains to the OTHER side (I approve - // your join request, minting a grant rooted at me, held by you). The - // rekey handler must accept that root for a DM path -- both participants - // are named, equal authorities in the path itself, and the handler's real - // binding is the ECDH unwrap (a forged rekey needs the root's private key - // to produce unwrappable ciphertext), not the room.send-grade root rule. + /* The DM bootstrap scenario wire-mesh#36's consumer work surfaced: in a DM, + each participant's own held token chains to the OTHER side (I approve + your join request, minting a grant rooted at me, held by you). The + rekey handler must accept that root for a DM path -- both participants + are named, equal authorities in the path itself, and the handler's real + binding is the ECDH unwrap (a forged rekey needs the root's private key + to produce unwrappable ciphertext), not the room.send-grade root rule. */ async function dmSetup(): Promise<{ alice: IdentityPort; bob: IdentityPort; @@ -416,13 +465,14 @@ describe("createRoomRekeyHandler", () => { const lower = aHex < bHex ? a : b; const higher = aHex < bHex ? b : a; const path = dmRoomPath(aHex, bHex); + return { alice: lower, bob: higher, roomPath: path }; } it("unwraps a DM epoch-1 rekey sent by the lower participant, whose root the higher side's own token chains to", async () => { const { alice, bob, roomPath } = await dmSetup(); - // bob's own token: granted by alice (the DM convention -- each side's - // held token is rooted at the other). + /* bob's own token: granted by alice (the DM convention -- each side's + held token is rooted at the other). */ const bobToken = await mintRoomMemberToken(alice, bob, roomPath); const onRekey = vi.fn<(event: Readonly) => void>(); const handler = createRoomRekeyHandler({ @@ -433,7 +483,13 @@ describe("createRoomRekeyHandler", () => { onRekey, }); const contentKey = generateContentKey(); - const wrapped = await ownerWraps(alice, bob, roomPath, 1, contentKey); + const wrapped = await ownerWraps({ + owner: alice, + member: bob, + roomPath, + keyEpoch: 1, + contentKey, + }); const { incoming, respond } = fakeIncoming( buildRoomRekeyCommand(1, wrapped), { kind: "room", path: roomPath }, @@ -452,8 +508,8 @@ describe("createRoomRekeyHandler", () => { it("still refuses a DM rekey rooted at a stranger outside the path", async () => { const { alice, bob, roomPath } = await dmSetup(); const stranger = await generateEs256Identity(); - // bob's token granted by a stranger -- NOT a path participant. Even the - // rekey-scoped either-participant rule must refuse this root. + /* bob's token granted by a stranger -- NOT a path participant. Even the + rekey-scoped either-participant rule must refuse this root. */ const bobToken = await mintRoomMemberToken(stranger, bob, roomPath); const onRekey = vi.fn<(event: Readonly) => void>(); const handler = createRoomRekeyHandler({ @@ -464,10 +520,16 @@ describe("createRoomRekeyHandler", () => { onRekey, }); const contentKey = generateContentKey(); - // alice wraps (a genuine path participant) -- but bob's verified token - // roots at the stranger, so the derivation target is the stranger's key - // and the unwrap must fail. - const wrapped = await ownerWraps(alice, bob, roomPath, 1, contentKey); + /* alice wraps (a genuine path participant) -- but bob's verified token + roots at the stranger, so the derivation target is the stranger's key + and the unwrap must fail. */ + const wrapped = await ownerWraps({ + owner: alice, + member: bob, + roomPath, + keyEpoch: 1, + contentKey, + }); const { incoming, respond } = fakeIncoming( buildRoomRekeyCommand(1, wrapped), { kind: "room", path: roomPath }, diff --git a/ts/packages/core/test/room-token-verification.unit.test.ts b/ts/packages/core/test/room-token-verification.unit.test.ts index 01a57ba3..e26ae11b 100644 --- a/ts/packages/core/test/room-token-verification.unit.test.ts +++ b/ts/packages/core/test/room-token-verification.unit.test.ts @@ -29,6 +29,7 @@ async function generateEs256Identity(): Promise { const publicKeyBytes = new Uint8Array( await webcrypto.subtle.exportKey("raw", keyPair.publicKey), ); + return createNodeIdentity(keyPair.privateKey, publicKeyBytes, ES256); } @@ -39,6 +40,7 @@ function fixedClock(atMs: number): Clock { let issuedTokenIds = 0; function nextTokenId(): Uint8Array { issuedTokenIds += 1; + return buf([issuedTokenIds]); } @@ -62,6 +64,7 @@ async function mintRoomMemberToken( delegationsRemaining: 0, }); if (!verdict.ok) throw new Error(`mint failed: ${verdict.reason}`); + return verdict.token; } diff --git a/ts/packages/core/test/secure-channel.unit.test.ts b/ts/packages/core/test/secure-channel.unit.test.ts index 1fdd0455..703a6408 100644 --- a/ts/packages/core/test/secure-channel.unit.test.ts +++ b/ts/packages/core/test/secure-channel.unit.test.ts @@ -12,6 +12,7 @@ import type { SecureHelloFrame, } from "../src/generated/protocol.js"; import type { IdentityPort } from "../src/ports/identity.js"; +import { inSequence } from "./sequence.js"; import { generateEd25519Identity, generateEs256Identity, @@ -51,12 +52,14 @@ async function connect( helloAtB.peer, ); if (atA === undefined || atB === undefined) throw new Error("no channel"); + return { atA, atB }; } function flipFirstByte(bytes: Uint8Array): Uint8Array { const copy = Uint8Array.from(bytes); copy[0] = (copy[0] ?? 0) ^ LOW_BYTE_MASK; + return copy; } @@ -65,15 +68,19 @@ describe("the handshake", () => { const one = await generateEd25519Identity(); const two = await generateEs256Identity(); - for (const [a, b] of [ - [one, two], - [two, one], - ] as const) { - const { atA, atB } = await connect(a, b); - - expect(await atB.open(await atA.seal(request))).toEqual(request); - expect(await atA.open(await atB.seal(request))).toEqual(request); - } + await Promise.all( + ( + [ + [one, two], + [two, one], + ] as const + ).map(async ([a, b]) => { + const { atA, atB } = await connect(a, b); + + expect(await atB.open(await atA.seal(request))).toEqual(request); + expect(await atA.open(await atB.seal(request))).toEqual(request); + }), + ); }); it("names the peer by the device-id its own identity key hashes to", async () => { @@ -273,9 +280,9 @@ describe("a channel", () => { expect(sealed.map((frame) => frame.counter)).toEqual( sealed.map((_frame, index) => index), ); - for (const frame of sealed) { + await inSequence(sealed, async (frame) => { expect(await atB.open(frame)).toEqual(request); - } + }); }); it("does not put the frame's content in what it sends", async () => { diff --git a/ts/packages/core/test/sequence.ts b/ts/packages/core/test/sequence.ts new file mode 100644 index 00000000..9080f598 --- /dev/null +++ b/ts/packages/core/test/sequence.ts @@ -0,0 +1,23 @@ +/** + * Runs `step` for each item, starting each only once the one before it has finished: the ordered counterpart to `Promise.all`, for steps that depend on the effects of the earlier ones or must not overlap. + */ +export async function inSequence( + items: readonly T[], + step: (item: T, index: number) => Promise, +): Promise { + await items.reduce(async (previous, item, index) => { + await previous; + await step(item, index); + }, Promise.resolve()); +} + +/** `inSequence` over the indices 0 to `times - 1`. */ +export async function repeatInSequence( + times: number, + step: (index: number) => Promise, +): Promise { + await inSequence( + Array.from({ length: times }, (_, index) => index), + async (index) => step(index), + ); +} diff --git a/ts/packages/core/test/shard-manifest.unit.test.ts b/ts/packages/core/test/shard-manifest.unit.test.ts index 11aa2de0..b4368d04 100644 --- a/ts/packages/core/test/shard-manifest.unit.test.ts +++ b/ts/packages/core/test/shard-manifest.unit.test.ts @@ -95,7 +95,8 @@ describe("shard manifest entry codec", () => { }); it("rejects malformed entry bytes loudly", () => { - const INVALID_CBOR_BYTE = 0xff; // not a valid CBOR major-type/length prefix + // not a valid CBOR major-type/length prefix + const INVALID_CBOR_BYTE = 0xff; expect(() => decodeShardManifest(Uint8Array.from([INVALID_CBOR_BYTE])), ).toThrow(); diff --git a/ts/packages/core/test/sharded-delivery.unit.test.ts b/ts/packages/core/test/sharded-delivery.unit.test.ts index f47f23aa..f41ba989 100644 --- a/ts/packages/core/test/sharded-delivery.unit.test.ts +++ b/ts/packages/core/test/sharded-delivery.unit.test.ts @@ -39,6 +39,7 @@ function text(value: string): Uint8Array { function identityFor(fillHex: string): IdentityPort { const deviceId = deviceIdFromFillHex(fillHex); + return { deviceId, identityKey: { alg: -7, "public-key": new Uint8Array() }, @@ -51,7 +52,9 @@ function identityFor(fillHex: string): IdentityPort { /** A one-directional, wake-on-push frame queue, mirroring bulk.unit.test.ts's own FakeInbound -- kept file-local rather than shared, matching that file's own convention of not factoring these fixtures out further. */ class FakeInbound { private readonly queue: Frame[] = []; + private readonly waiters: (() => void)[] = []; + private ended = false; push(frame: Frame): void { @@ -77,14 +80,14 @@ class FakeInbound { } private async next(): Promise> { - for (;;) { - const value = this.queue.shift(); - if (value !== undefined) return { value, done: false }; - if (this.ended) return { value: undefined, done: true }; - await new Promise((resolve) => { - this.waiters.push(resolve); - }); - } + const value = this.queue.shift(); + if (value !== undefined) return { value, done: false }; + if (this.ended) return { value: undefined, done: true }; + await new Promise((resolve) => { + this.waiters.push(resolve); + }); + + return this.next(); } } @@ -98,25 +101,30 @@ function linkedConnections(): { const senderConn: Connection = { send: async (frame) => { toReceiver.push(frame); + return Promise.resolve(); }, receive: () => toSender.stream(), close: async () => { toSender.end(); + return Promise.resolve(); }, }; const receiverConn: Connection = { send: async (frame) => { toSender.push(frame); + return Promise.resolve(); }, receive: () => toReceiver.stream(), close: async () => { toReceiver.end(); + return Promise.resolve(); }, }; + return { senderConn, receiverConn }; } @@ -139,10 +147,12 @@ function sessionDispatchingTo( scope, respond: async (o): Promise => { resolveOutcome(o); + return Promise.resolve(); }, }; await handler(incoming); + return responded; }, } as unknown as MeshSession; @@ -153,6 +163,7 @@ function deferred(): { promise: Promise; resolve: (value: T) => void } { const promise = new Promise((r) => { resolve = r; }); + return { promise, resolve }; } @@ -180,6 +191,7 @@ function holderAcceptingChannel( await holder.onFrame(receiverConn, frame); } })(); + return { channel: { connection: senderConn, session }, accepted: accepted.promise, @@ -204,6 +216,7 @@ function readerChannel( } })(); const session = sessionDispatchingTo(openHandler); + return { holderChannel: { connection: senderConn, session } }; } @@ -231,6 +244,7 @@ describe("distributeShardedPayload", () => { nextTransferId: (i) => { const id = new Uint8Array(TRANSFER_ID_BYTE_LENGTH); id.fill(i + 1); + return id; }, scope: TEST_SCOPE, @@ -310,6 +324,7 @@ describe("end-to-end sharded delivery: distribute, hold, serve, collect, reconst nextTransferId: (i) => { const id = new Uint8Array(TRANSFER_ID_BYTE_LENGTH); id.fill(i + 1); + return id; }, scope: TEST_SCOPE, @@ -336,14 +351,12 @@ describe("end-to-end sharded delivery: distribute, hold, serve, collect, reconst await holderA.serveShard( readerLinkA.holderChannel, Uint8Array.from(locationA["transfer-id"]), - readerDevice, - TEST_SCOPE, + { targetDevice: readerDevice, scope: TEST_SCOPE }, ); await holderB.serveShard( readerLinkB.holderChannel, Uint8Array.from(locationB["transfer-id"]), - readerDevice, - TEST_SCOPE, + { targetDevice: readerDevice, scope: TEST_SCOPE }, ); const reconstructed = await collector.result; @@ -374,6 +387,7 @@ describe("end-to-end sharded delivery: distribute, hold, serve, collect, reconst nextTransferId: (i) => { const id = new Uint8Array(TRANSFER_ID_BYTE_LENGTH); id.fill(i + 1); + return id; }, scope: TEST_SCOPE, @@ -399,14 +413,12 @@ describe("end-to-end sharded delivery: distribute, hold, serve, collect, reconst await holderA.serveShard( readerLinkA.holderChannel, Uint8Array.from(locationA["transfer-id"]), - readerDevice, - TEST_SCOPE, + { targetDevice: readerDevice, scope: TEST_SCOPE }, ); await holderB.serveShard( readerLinkB.holderChannel, Uint8Array.from(locationB["transfer-id"]), - readerDevice, - TEST_SCOPE, + { targetDevice: readerDevice, scope: TEST_SCOPE }, ); const recovered = await collector.result; @@ -440,6 +452,7 @@ describe("end-to-end sharded delivery: distribute, hold, serve, collect, reconst openerDevice: deviceIdFromFillHex("01"), decide: async (d) => { decision = d.kind; + return Promise.resolve(); }, }; diff --git a/ts/packages/core/test/tcp-transport-token-bytes.integration.test.ts b/ts/packages/core/test/tcp-transport-token-bytes.integration.test.ts index d10da39a..695281ef 100644 --- a/ts/packages/core/test/tcp-transport-token-bytes.integration.test.ts +++ b/ts/packages/core/test/tcp-transport-token-bytes.integration.test.ts @@ -84,10 +84,10 @@ describe("createTcpTransport byte-string re-encoding", () => { } const [receivedProtectedHeader, , receivedPayload] = receivedFrame.token; - // The actual defect: re-encoding a received byte string the way COSE's Sig1-to-be-signed - // reconstruction does must reproduce exactly what encoding the original, pre-send byte string - // produces -- a Buffer-corrupted decode instead serializes it as an object, so this comparison - // is where the bug actually manifested. + /* The actual defect: re-encoding a received byte string the way COSE's Sig1-to-be-signed + reconstruction does must reproduce exactly what encoding the original, pre-send byte string + produces -- a Buffer-corrupted decode instead serializes it as an object, so this comparison + is where the bug actually manifested. */ const originalEncoded = encode( ["Signature1", protectedHeader, new Uint8Array(0), payload], cdeEncodeOptions, diff --git a/ts/packages/core/test/tcp-transport.integration.test.ts b/ts/packages/core/test/tcp-transport.integration.test.ts index d9cb29db..f90d55c2 100644 --- a/ts/packages/core/test/tcp-transport.integration.test.ts +++ b/ts/packages/core/test/tcp-transport.integration.test.ts @@ -35,6 +35,7 @@ describe("createTcpTransport", () => { for await (const frame of connection.receive()) { frames.push(frame); } + return frames; })().then( () => { diff --git a/ts/packages/core/test/threshold-dkg.integration.test.ts b/ts/packages/core/test/threshold-dkg.integration.test.ts index 7b9db996..3bef13af 100644 --- a/ts/packages/core/test/threshold-dkg.integration.test.ts +++ b/ts/packages/core/test/threshold-dkg.integration.test.ts @@ -33,6 +33,7 @@ const ALG_ED25519 = -8; function deviceId(byte: number): DeviceId { const bytes = new Uint8Array(DEVICE_ID_LENGTH); bytes[DEVICE_ID_LENGTH - 1] = byte; + return bytes; } @@ -55,6 +56,7 @@ function createRelayBus(): { inbox = { waiters: [], backlog: [] }; inboxes.set(hex, inbox); } + return inbox; } @@ -70,6 +72,7 @@ function createRelayBus(): { } const requestId = nextRequestId; nextRequestId += 1; + return new Promise((resolve) => { const incoming: IncomingManageRequest = { requestId, @@ -78,6 +81,7 @@ function createRelayBus(): { fromDevice: self, respond: async (outcome: ManageOutcome): Promise => { resolve(outcome); + return Promise.resolve(); }, }; diff --git a/ts/packages/core/test/threshold-identity.unit.test.ts b/ts/packages/core/test/threshold-identity.unit.test.ts index 7fd297df..87fe3345 100644 --- a/ts/packages/core/test/threshold-identity.unit.test.ts +++ b/ts/packages/core/test/threshold-identity.unit.test.ts @@ -34,6 +34,7 @@ const ARBITRARY_MESSAGE = new Uint8Array([1, 2, 1]); function deviceId(byte: number): DeviceId { const bytes = new Uint8Array(DEVICE_ID_LENGTH); bytes[DEVICE_ID_LENGTH - 1] = byte; + return bytes; } @@ -65,6 +66,7 @@ function runDkg(deviceIds: readonly DeviceId[]): Participant[] { const othersRound1 = round1Entries.filter( (e) => !sameDeviceId(e.deviceId, p.deviceId), ); + return { deviceId: p.deviceId, ...dkgRound2(p.secretPackage, othersRound1), @@ -94,6 +96,7 @@ function runDkg(deviceIds: readonly DeviceId[]): Participant[] { if (!inbox) { throw new Error("test fixture: missing inbox for a DKG participant"); } + return { deviceId: p.deviceId, round3: dkgRound3(p.secretPackage, othersRound1, inbox), @@ -133,9 +136,11 @@ function inProcessCoordinator( const { nonces, commitments: commitmentBytes } = signingRound1Commit(keyPackage); noncesByDevice.set(hexOf(id), nonces); + return { deviceId: id, value: commitmentBytes }; }); sessions.set(sessionId.toString(), { message, noncesByDevice }); + return Promise.resolve(commitments); }, async signRound(sessionId, commitments): Promise { @@ -156,13 +161,16 @@ function inProcessCoordinator( "test coordinator: missing key package or nonces for a committed participant", ); } - session.noncesByDevice.delete(hexOf(id)); // one-shot, matching NonceStore.take's own contract + // one-shot, matching NonceStore.take's own contract + session.noncesByDevice.delete(hexOf(id)); + return { deviceId: id, value: signingRound2Sign(nonces, signingPackage, keyPackage), }; }, ); + return Promise.resolve(shares); }, }; @@ -238,7 +246,8 @@ describe("threshold-identity: createThresholdIdentity", () => { } const underThreshold: ThresholdCoordinator = { - commitRound: async () => Promise.resolve([]), // nobody committed + // nobody committed + commitRound: async () => Promise.resolve([]), signRound: async () => Promise.resolve([]), }; const identity = await createThresholdIdentity( diff --git a/ts/packages/core/test/threshold-network-coordinator.integration.test.ts b/ts/packages/core/test/threshold-network-coordinator.integration.test.ts index fde55377..ba9adc88 100644 --- a/ts/packages/core/test/threshold-network-coordinator.integration.test.ts +++ b/ts/packages/core/test/threshold-network-coordinator.integration.test.ts @@ -45,6 +45,7 @@ const THIRD_DEVICE_BYTE = 3; function deviceId(byte: number): DeviceId { const bytes = new Uint8Array(DEVICE_ID_LENGTH); bytes[DEVICE_ID_LENGTH - 1] = byte; + return bytes; } @@ -76,6 +77,7 @@ function runDkg(deviceIds: readonly DeviceId[]): DkgParticipant[] { const othersRound1 = round1Entries.filter( (e) => !sameDeviceId(e.deviceId, p.deviceId), ); + return { deviceId: p.deviceId, ...dkgRound2(p.secretPackage, othersRound1), @@ -103,6 +105,7 @@ function runDkg(deviceIds: readonly DeviceId[]): DkgParticipant[] { if (!inbox) { throw new Error("test fixture: missing DKG inbox"); } + return { deviceId: p.deviceId, round3: dkgRound3(p.secretPackage, othersRound1, inbox), @@ -130,6 +133,7 @@ function createFakeBus(): { inbox = { waiters: [], backlog: [] }; inboxes.set(hex, inbox); } + return inbox; } @@ -146,6 +150,7 @@ function createFakeBus(): { } const requestId = nextRequestId; nextRequestId += 1; + return new Promise((resolve) => { const incoming: IncomingManageRequest = { requestId, @@ -154,6 +159,7 @@ function createFakeBus(): { ...(token !== undefined ? { token } : {}), respond: async (outcome: ManageOutcome): Promise => { resolve(outcome); + return Promise.resolve(); }, }; diff --git a/ts/packages/core/test/threshold-network.unit.test.ts b/ts/packages/core/test/threshold-network.unit.test.ts index 5031aa75..f2992ecb 100644 --- a/ts/packages/core/test/threshold-network.unit.test.ts +++ b/ts/packages/core/test/threshold-network.unit.test.ts @@ -36,6 +36,7 @@ const GROUP_KEY_FILL_BYTE = 7; function deviceId(byte: number): DeviceId { const bytes = new Uint8Array(DEVICE_ID_LENGTH); bytes[DEVICE_ID_LENGTH - 1] = byte; + return bytes; } @@ -99,13 +100,13 @@ describe("threshold-network: command builders and type guards", () => { }); it("buildKeygenRound1Command carries the fresh-DKG shape with proof-of-knowledge, no existing-group-key", () => { - const command = buildKeygenRound1Command( - FRESH_DKG_SESSION_ID, - THRESHOLD, - [deviceId(1), deviceId(2)], - [new Uint8Array([1])], - { proofOfKnowledge: new Uint8Array([GROUP_DEVICE_BYTE]) }, - ); + const command = buildKeygenRound1Command({ + sessionId: FRESH_DKG_SESSION_ID, + threshold: THRESHOLD, + participants: [deviceId(1), deviceId(2)], + commitment: [new Uint8Array([1])], + proofOfKnowledge: new Uint8Array([GROUP_DEVICE_BYTE]), + }); expect(command.verb).toBe(THRESHOLD_KEYGEN_VERB); if (!isThresholdKeygenRound1(command.params)) { throw new Error("expected threshold.keygen-round1 params"); @@ -118,13 +119,13 @@ describe("threshold-network: command builders and type guards", () => { it("buildKeygenRound1Command carries the reshare shape with existing-group-key, no proof-of-knowledge required", () => { const groupKey = new Uint8Array(DEVICE_ID_LENGTH).fill(GROUP_KEY_FILL_BYTE); - const command = buildKeygenRound1Command( - RESHARE_SESSION_ID, - THRESHOLD, - [deviceId(1)], - [new Uint8Array([1])], - { existingGroupKey: groupKey }, - ); + const command = buildKeygenRound1Command({ + sessionId: RESHARE_SESSION_ID, + threshold: THRESHOLD, + participants: [deviceId(1)], + commitment: [new Uint8Array([1])], + existingGroupKey: groupKey, + }); expect(command.verb).toBe(THRESHOLD_RESHARE_VERB); if (!isThresholdKeygenRound1(command.params)) { throw new Error("expected threshold.keygen-round1 params"); diff --git a/ts/packages/core/test/threshold-participant.unit.test.ts b/ts/packages/core/test/threshold-participant.unit.test.ts index 9570bd49..6d6a680e 100644 --- a/ts/packages/core/test/threshold-participant.unit.test.ts +++ b/ts/packages/core/test/threshold-participant.unit.test.ts @@ -28,6 +28,7 @@ const SESSION_ID = 1n; function deviceId(byte: number): DeviceId { const bytes = new Uint8Array(DEVICE_ID_LENGTH); bytes[DEVICE_ID_LENGTH - 1] = byte; + return bytes; } @@ -43,6 +44,7 @@ async function sendOnce( ...incoming, respond: async (outcome) => { resolve(outcome); + return Promise.resolve(); }, }); @@ -55,6 +57,7 @@ function participantHarness(): { } { const waiters: ((request: IncomingManageRequest) => void)[] = []; const backlog: IncomingManageRequest[] = []; + return { push: (incoming) => { const waiter = waiters.shift(); @@ -140,7 +143,8 @@ describe("threshold-participant: fail-closed paths", () => { startThresholdParticipant({ session: harness.session, personalIdentity: personal, - keyPackagesByGroup: new Map(), // no group known + // no group known + keyPackagesByGroup: new Map(), nonceStore: createMemoryNonceStore(), clock, revocation: neverRevoked, diff --git a/ts/packages/core/test/threshold-reshare.integration.test.ts b/ts/packages/core/test/threshold-reshare.integration.test.ts index 1887c139..0bd7d6fd 100644 --- a/ts/packages/core/test/threshold-reshare.integration.test.ts +++ b/ts/packages/core/test/threshold-reshare.integration.test.ts @@ -37,6 +37,7 @@ const ALG_ED25519 = -8; function deviceId(byte: number): DeviceId { const bytes = new Uint8Array(DEVICE_ID_LENGTH); bytes[DEVICE_ID_LENGTH - 1] = byte; + return bytes; } @@ -59,6 +60,7 @@ function createRelayBus(): { inbox = { waiters: [], backlog: [] }; inboxes.set(hex, inbox); } + return inbox; } @@ -74,6 +76,7 @@ function createRelayBus(): { } const requestId = nextRequestId; nextRequestId += 1; + return new Promise((resolve) => { const incoming: IncomingManageRequest = { requestId, @@ -82,6 +85,7 @@ function createRelayBus(): { fromDevice: self, respond: async (outcome: ManageOutcome): Promise => { resolve(outcome); + return Promise.resolve(); }, }; @@ -120,8 +124,10 @@ describe("threshold-dkg: contributeThresholdReshare + joinThresholdReshare", () it("reshares a T=2-of-3 group to a different T=2-of-3 committee (one device dropped, one added), preserving the group key and producing shares that actually sign", async () => { const alice = deviceId(1); const bob = deviceId(SECOND_DEVICE_BYTE); - const carol = deviceId(THIRD_DEVICE_BYTE); // leaving - const dave = deviceId(FOURTH_DEVICE_BYTE); // joining + // leaving + const carol = deviceId(THIRD_DEVICE_BYTE); + // joining + const dave = deviceId(FOURTH_DEVICE_BYTE); const dkgBus = createRelayBus(); // carol's own DKG result is intentionally unused past this point -- she is the survivor who leaves in the reshare below and never runs joinThresholdReshare. @@ -192,6 +198,7 @@ describe("threshold-dkg: contributeThresholdReshare + joinThresholdReshare", () : {}), }), ]); + return joinResult; } diff --git a/ts/packages/core/test/threshold-share-envelope.unit.test.ts b/ts/packages/core/test/threshold-share-envelope.unit.test.ts index 8055c652..f83b3313 100644 --- a/ts/packages/core/test/threshold-share-envelope.unit.test.ts +++ b/ts/packages/core/test/threshold-share-envelope.unit.test.ts @@ -24,6 +24,7 @@ const XOR_MASK = 0xff; function deviceId(byte: number): Uint8Array { const bytes = new Uint8Array(DEVICE_ID_LENGTH); bytes[DEVICE_ID_LENGTH - 1] = byte; + return bytes; } diff --git a/ts/packages/core/test/threshold-wasm.unit.test.ts b/ts/packages/core/test/threshold-wasm.unit.test.ts index 590036bb..f3ea6b4b 100644 --- a/ts/packages/core/test/threshold-wasm.unit.test.ts +++ b/ts/packages/core/test/threshold-wasm.unit.test.ts @@ -38,6 +38,7 @@ const THIRD_DEVICE_BYTE = 3; function deviceId(byte: number): DeviceId { const bytes = new Uint8Array(DEVICE_ID_LENGTH); bytes[DEVICE_ID_LENGTH - 1] = byte; + return bytes; } @@ -54,6 +55,7 @@ function forDevice( if (!found) { throw new Error("test fixture: no entry for the given device-id"); } + return found; } @@ -79,6 +81,7 @@ function runDkg(deviceIds: readonly DeviceId[]): Participant[] { const othersRound1 = round1Entries.filter( (e) => !sameDeviceId(e.deviceId, p.deviceId), ); + return { deviceId: p.deviceId, ...dkgRound2(p.secretPackage, othersRound1), @@ -108,6 +111,7 @@ function runDkg(deviceIds: readonly DeviceId[]): Participant[] { if (!inbox) { throw new Error("test fixture: missing inbox for a DKG participant"); } + return { deviceId: p.deviceId, round1: forDevice(round1ByDevice, p.deviceId), @@ -197,6 +201,7 @@ function twoSigners( "test fixture: expected at least two DKG participants to sign", ); } + return [alice, bob]; } @@ -368,6 +373,7 @@ describe("threshold-wasm: reshare", () => { "test fixture: missing received-shares bucket for a new participant", ); } + return bucket; } diff --git a/ts/packages/core/test/tls-cert-fixture.ts b/ts/packages/core/test/tls-cert-fixture.ts index 652f8b97..d5b19808 100644 --- a/ts/packages/core/test/tls-cert-fixture.ts +++ b/ts/packages/core/test/tls-cert-fixture.ts @@ -52,6 +52,7 @@ export function generateCertFixture(): CertFixture { ...Buffer.from(jwk.x, "base64url"), ...Buffer.from(jwk.y, "base64url"), ]); + return { certificatePem, privateKeyPem, rawPublicKey }; } finally { rmSync(dir, { recursive: true, force: true }); diff --git a/ts/packages/core/test/token-delegation-narrowing.unit.test.ts b/ts/packages/core/test/token-delegation-narrowing.unit.test.ts index e8e81261..66b29847 100644 --- a/ts/packages/core/test/token-delegation-narrowing.unit.test.ts +++ b/ts/packages/core/test/token-delegation-narrowing.unit.test.ts @@ -144,7 +144,8 @@ describe("verifyCapabilityToken - delegation narrowing", () => { bearer: delegate.deviceId, capability: "exec:pty", scope: { kind: "folder", path: "/work/subdir" }, - expires: rootExpiry + HOUR_MS, // wider than the parent -- must be rejected + // wider than the parent -- must be rejected + expires: rootExpiry + HOUR_MS, parent: encodeBuf(root), }; const payload = encodeBuf(claims); @@ -259,6 +260,7 @@ describe("verifyCapabilityToken - delegation narrowing", () => { payload, ]); const signature = await identity.sign(toBeSigned); + return [protectedHeader, {}, payload, signature]; } @@ -274,6 +276,7 @@ describe("verifyCapabilityToken - delegation narrowing", () => { expires: now + 2 * HOUR_MS, }); const delegate = await generateEs256Identity(); + return signDelegated(bearerIdentity, { tokenId: nextTokenId(), bearer: delegate.deviceId, @@ -599,6 +602,7 @@ describe("verifyCapabilityToken - delegation narrowing", () => { expires: now + 2 * HOUR_MS, }); const delegate = await generateEs256Identity(); + return signDelegated(bearerIdentity, { tokenId: nextTokenId(), bearer: delegate.deviceId, diff --git a/ts/packages/core/test/token-grant-authorisation.unit.test.ts b/ts/packages/core/test/token-grant-authorisation.unit.test.ts index 91768a5b..4a4d9616 100644 --- a/ts/packages/core/test/token-grant-authorisation.unit.test.ts +++ b/ts/packages/core/test/token-grant-authorisation.unit.test.ts @@ -72,6 +72,7 @@ describe("verifyCapabilityToken - authorised-by", () => { ? { delegationsRemaining: overrides.delegationsRemaining } : {}), }); + return { token, tokenId }; } @@ -430,21 +431,27 @@ describe("verifyCapabilityToken - authorised-by", () => { }); it("refuses a chain deeper than the bound", async () => { - let token = await signToken(owner, { + const root = signToken(owner, { tokenId: nextTokenId(), bearer: deputy.deviceId, scope: WORK_SCOPE, expires: NOW_MS + (DEPTH_CAP + 2) * HOUR_MS, }); - for (let hop = 0; hop < DEPTH_CAP; hop += 1) { - token = await signToken(deputy, { - tokenId: nextTokenId(), - bearer: thirdParty.deviceId, - scope: WORK_SCOPE, - expires: NOW_MS + (DEPTH_CAP - hop) * HOUR_MS, - parent: encodeBuf(token), - }); - } + // Each hop is signed over the token before it, so they are chained rather than run together. + const token = await Array.from( + { length: DEPTH_CAP }, + (_, hop) => hop, + ).reduce( + async (parent, hop) => + signToken(deputy, { + tokenId: nextTokenId(), + bearer: thirdParty.deviceId, + scope: WORK_SCOPE, + expires: NOW_MS + (DEPTH_CAP - hop) * HOUR_MS, + parent: encodeBuf(await parent), + }), + root, + ); const verdict = await verifyCapabilityToken(token, options()); @@ -485,6 +492,7 @@ describe("mintCapabilityToken - authorisedBy", () => { : {}), }); if (!verdict.ok) throw new Error(`mint failed: ${verdict.reason}`); + return verdict.token; } @@ -539,19 +547,21 @@ describe("mintCapabilityToken - authorisedBy", () => { reason: "authorisation_exceeds_authoriser", }, ]; - for (const testCase of cases) { - const verdict = await mintCapabilityToken({ - identity: deputy, - clock: fixedClock(NOW_MS), - tokenId: nextTokenId(), - bearer: thirdParty.deviceId, - capability: testCase.capability, - scope: testCase.scope, - expires: testCase.expires, - authorisedBy: grant, - }); - expect(verdict).toEqual({ ok: false, reason: testCase.reason }); - } + await Promise.all( + cases.map(async (testCase) => { + const verdict = await mintCapabilityToken({ + identity: deputy, + clock: fixedClock(NOW_MS), + tokenId: nextTokenId(), + bearer: thirdParty.deviceId, + capability: testCase.capability, + scope: testCase.scope, + expires: testCase.expires, + authorisedBy: grant, + }); + expect(verdict).toEqual({ ok: false, reason: testCase.reason }); + }), + ); }); it("refuses a mint the authoriser's subject-mode conditions bar, and one by a non-bearer of the authoriser", async () => { diff --git a/ts/packages/core/test/token-predicate-conditions.unit.test.ts b/ts/packages/core/test/token-predicate-conditions.unit.test.ts index 3c6828a7..3b72df7f 100644 --- a/ts/packages/core/test/token-predicate-conditions.unit.test.ts +++ b/ts/packages/core/test/token-predicate-conditions.unit.test.ts @@ -54,6 +54,7 @@ async function signTokenWithRawConditions( payload, ]); const signature = await identity.sign(toBeSigned); + return [protectedHeader, {}, payload, signature]; } @@ -110,6 +111,7 @@ describe("token-claims.conditions -- the generic predicate-list evaluator", () = function isThresholdPayload(payload: unknown): payload is ThresholdPayload { if (typeof payload !== "object" || payload === null) return false; if (!("value" in payload) || !("threshold" in payload)) return false; + return ( typeof payload.value === "number" && typeof payload.threshold === "number" ); @@ -121,6 +123,7 @@ describe("token-claims.conditions -- the generic predicate-list evaluator", () = if (!isThresholdPayload(payload)) { return { found: false }; } + return { found: true, value: { kind: "boolean", value: payload.value > payload.threshold }, diff --git a/ts/packages/core/test/token-revocation-authorization.unit.test.ts b/ts/packages/core/test/token-revocation-authorization.unit.test.ts index d70745b8..6824fc1b 100644 --- a/ts/packages/core/test/token-revocation-authorization.unit.test.ts +++ b/ts/packages/core/test/token-revocation-authorization.unit.test.ts @@ -89,7 +89,8 @@ describe("verifyCapabilityToken -- revocation via a delegated manage:revoke auth const tokenId = nextTokenId(); const token = await mintTargetToken(tokenId); const authorization = await mintRevokeAuthorization({ - authScope: { kind: "folder" }, // whole-kind root, wider than /work + // whole-kind root, wider than /work + authScope: { kind: "folder" }, }); const entry: RevocationClaims = { "token-id": tokenId, diff --git a/ts/packages/core/test/tokens-fixtures.ts b/ts/packages/core/test/tokens-fixtures.ts index 65689bca..911a33f9 100644 --- a/ts/packages/core/test/tokens-fixtures.ts +++ b/ts/packages/core/test/tokens-fixtures.ts @@ -17,17 +17,27 @@ import type { RevocationCheck } from "../src/domain/tokens.js"; export const ES256 = -7; export const EDDSA = -8; export const HOUR_MS = 3_600_000; -export const REVOKED_SHORTLY_BEFORE_NOW_MS = 1_000; // revoked-at sits just before `now` in these tests -- the value only needs to be in the past, not any particular distance -export const P256_SIGNATURE_BYTE_LENGTH = 64; // raw ECDSA P-256 signature length -export const DEVICE_ID_BYTE_LENGTH = 32; // SHA-256 digest length +/** + * revoked-at sits just before `now` in these tests -- the value only needs to be in the past, not any particular distance + */ +export const REVOKED_SHORTLY_BEFORE_NOW_MS = 1_000; +// raw ECDSA P-256 signature length +export const P256_SIGNATURE_BYTE_LENGTH = 64; +// SHA-256 digest length +export const DEVICE_ID_BYTE_LENGTH = 32; export const ROOM_MEMBER_ROOM_PATH = - "aa".repeat(DEVICE_ID_BYTE_LENGTH) + "/general"; // a syntactically valid owner-named room-path; the tests below never verify path ownership against a real device-id, only scope-narrowing between parent and child -export const LOW_BYTE_MASK = 0xff; // XOR operand keeping the corrupted byte within one octet when tampering with a signature in tests + // a syntactically valid owner-named room-path; the tests below never verify path ownership against a real device-id, only scope-narrowing between parent and child + "aa".repeat(DEVICE_ID_BYTE_LENGTH) + "/general"; +/** + * XOR operand keeping the corrupted byte within one octet when tampering with a signature in tests + */ +export const LOW_BYTE_MASK = 0xff; let issuedTokenIds = 0; /** A fresh, distinct token-id per call -- the tests only need each token to be distinguishable from the others, not any particular byte value. */ export function nextTokenId(): Uint8Array { issuedTokenIds += 1; + return buf([issuedTokenIds]); } @@ -51,6 +61,7 @@ export async function generateEs256Identity(): Promise { const publicKeyBytes = new Uint8Array( await webcrypto.subtle.exportKey("raw", keyPair.publicKey), ); + return createNodeIdentity(keyPair.privateKey, publicKeyBytes, ES256); } @@ -65,6 +76,7 @@ export async function generateEd25519Identity(): Promise { const publicKeyBytes = new Uint8Array( await webcrypto.subtle.exportKey("raw", keyPair.publicKey), ); + return createNodeIdentity(keyPair.privateKey, publicKeyBytes, EDDSA); } @@ -114,6 +126,7 @@ export async function signRevocationEntry( payload, ]); const signature = await identity.sign(toBeSigned); + return [protectedHeader, {}, payload, signature]; } @@ -173,5 +186,6 @@ export async function signToken( payload, ]); const signature = await identity.sign(toBeSigned); + return [protectedHeader, {}, payload, signature]; } diff --git a/ts/packages/core/test/topology.unit.test.ts b/ts/packages/core/test/topology.unit.test.ts index 21973b3a..83da1223 100644 --- a/ts/packages/core/test/topology.unit.test.ts +++ b/ts/packages/core/test/topology.unit.test.ts @@ -57,6 +57,7 @@ function fakeIncoming(command: ManageCommand): { scope, respond, }; + return { incoming, respond }; } diff --git a/ts/packages/core/test/tsdown-config.unit.test.ts b/ts/packages/core/test/tsdown-config.unit.test.ts index ddbb150e..c1aabee6 100644 --- a/ts/packages/core/test/tsdown-config.unit.test.ts +++ b/ts/packages/core/test/tsdown-config.unit.test.ts @@ -11,6 +11,7 @@ function isNeverBundled(id: string): boolean { if (!Array.isArray(neverBundle)) { throw new Error("tsdown.config.ts must list its neverBundle patterns"); } + return neverBundle.some( (pattern) => pattern instanceof RegExp && pattern.test(id), ); diff --git a/ts/packages/core/test/webrtc-signaling.unit.test.ts b/ts/packages/core/test/webrtc-signaling.unit.test.ts index 3f91869a..16be3415 100644 --- a/ts/packages/core/test/webrtc-signaling.unit.test.ts +++ b/ts/packages/core/test/webrtc-signaling.unit.test.ts @@ -33,7 +33,8 @@ import { const NEGOTIATION_ID = 42; const TEST_SDP = "v=0\r\no=- 0 0 IN IP4 127.0.0.1\r\n"; -const DEVICE_ID_BYTE_LENGTH = 32; // SHA-256 digest length +// SHA-256 digest length +const DEVICE_ID_BYTE_LENGTH = 32; const DEVICE_B_FILL_BYTE = 0x22; describe("createNegotiationIdAllocator", () => { diff --git a/ts/packages/core/tsdown.config.ts b/ts/packages/core/tsdown.config.ts index acd25fc9..19ccb613 100644 --- a/ts/packages/core/tsdown.config.ts +++ b/ts/packages/core/tsdown.config.ts @@ -3,17 +3,18 @@ import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; import { defineConfig } from "tsdown"; -// Every real module gets its own build entry rather than a re-exporting index.ts -- the barrel-policy lint rule requires importing straight from the module that owns each export. src/generated/protocol.ts is committed, machine-generated output (see generate.ts); it's built here like any other module (real code that needs to compile and bundle correctly) but excluded from eslint's own strict authored-code rules (see eslint.config.ts's ignores) since rules like no-use-before-define don't make sense for its z.lazy()-wrapped mutually-recursive schemas. -// -// Entries are discovered by glob rather than listed, so adding a module to src/ needs no edit here: it is built, and tsdown generates its package.json exports entry (`exports: true` below rewrites the exports map from the entries, alphabetically sorted, on every build). The committed map is that output, and CI fails any change whose build leaves the working tree dirty, so an entry that was added, removed or moved without regenerating the map is caught on the pull request instead of at release time. -// -// The public surface is everything under src/ except the modules named here. These are internal: each is consumed only by other modules in this package, is bundled into the entries that use it, and is deliberately not a subpath consumers can import. Naming the private ones (rather than the public ones) makes public the default, which is what the barrel policy above already assumes, and turns a stale name into a config-load error below instead of a silent gap. +/* Every real module gets its own build entry rather than a re-exporting index.ts -- the barrel-policy lint rule requires importing straight from the module that owns each export. src/generated/protocol.ts is committed, machine-generated output (see generate.ts); it's built here like any other module (real code that needs to compile and bundle correctly) but excluded from eslint's own strict authored-code rules (see eslint.config.ts's ignores) since rules like no-use-before-define don't make sense for its z.lazy()-wrapped mutually-recursive schemas. + + Entries are discovered by glob rather than listed, so adding a module to src/ needs no edit here: it is built, and tsdown generates its package.json exports entry (`exports: true` below rewrites the exports map from the entries, alphabetically sorted, on every build). The committed map is that output, and CI fails any change whose build leaves the working tree dirty, so an entry that was added, removed or moved without regenerating the map is caught on the pull request instead of at release time. + + The public surface is everything under src/ except the modules named here. These are internal: each is consumed only by other modules in this package, is bundled into the entries that use it, and is deliberately not a subpath consumers can import. Naming the private ones (rather than the public ones) makes public the default, which is what the barrel policy above already assumes, and turns a stale name into a config-load error below instead of a silent gap. */ const INTERNAL_MODULES = [ "src/adapters/memory-nonce-store.ts", "src/adapters/threshold-dkg.ts", "src/adapters/threshold-network-coordinator.ts", "src/adapters/threshold-participant.ts", "src/domain/own-version.ts", + "src/domain/sequential.ts", "src/domain/relay-channels.ts", "src/domain/relay-pairing.ts", "src/domain/secure-channel.ts", @@ -40,11 +41,11 @@ export default defineConfig({ exports: true, attw: { profile: "node16" }, clean: true, - // wasm-dist/wire_mesh_threshold_wasm.js (threshold-wasm.ts's own import) is wasm-bindgen's generated CommonJS glue, which loads its .wasm binary via a `${__dirname}/...` path at its OWN call site. Bundling would inline that line into dist/adapters/threshold-wasm.{mjs,cjs}, where __dirname resolves to dist/adapters/ instead of wasm-dist/ -- and for the ESM output specifically, rolldown's CJS-interop shim does not define __dirname at all, breaking the import outright (confirmed: `node --input-type=module` against the bundled .mjs throws "__dirname is not defined"). Keeping the import external instead means it is resolved at its own real, unbundled path at runtime, identical in both src/ (before build) and dist/ (after build) layouts since wasm-dist/ sits two directories up from both src/adapters/ and dist/adapters/ alike -- package.json's own "files" field ships wasm-dist/ alongside dist/ in the published tarball for exactly this reason. - // - // mesh-session.ts's own "../../package.json" import (OWN_VERSION) needs the identical treatment for a different reason: bundling would inline today's committed version string ("0.0.0" -- semantic-release/npm only rewrites package.json in the tarball it publishes, AFTER this package has already been built by CI), so every real release would report the wrong, stale version forever. Confirmed by inspecting the bundled output before adding this line: `var version = "0.0.0";` was baked directly into dist/domain/mesh-session.{mjs,cjs}. Kept external instead, this reads package.json fresh off disk (Node's own JSON-module loader for the ESM build, plain `require()`'s always-JSON-parsed reads for the CJS build) at actual runtime, against whichever package.json the published tarball -- or a bundler resolving this package's own node_modules copy for a browser consumer -- really carries. - // - // The wasm-dist pattern names the glue module itself and is anchored at the end of the id. neverBundle patterns are tested against absolute module ids, so an unanchored /\/wasm-dist\// would also match every module of a checkout whose own path merely contains a wasm-dist directory (a git worktree named wasm-dist, say): nothing would be bundled and the declaration build would fail with MISSING_EXPORT errors. + /* wasm-dist/wire_mesh_threshold_wasm.js (threshold-wasm.ts's own import) is wasm-bindgen's generated CommonJS glue, which loads its .wasm binary via a `${__dirname}/...` path at its OWN call site. Bundling would inline that line into dist/adapters/threshold-wasm.{mjs,cjs}, where __dirname resolves to dist/adapters/ instead of wasm-dist/ -- and for the ESM output specifically, rolldown's CJS-interop shim does not define __dirname at all, breaking the import outright (confirmed: `node --input-type=module` against the bundled .mjs throws "__dirname is not defined"). Keeping the import external instead means it is resolved at its own real, unbundled path at runtime, identical in both src/ (before build) and dist/ (after build) layouts since wasm-dist/ sits two directories up from both src/adapters/ and dist/adapters/ alike -- package.json's own "files" field ships wasm-dist/ alongside dist/ in the published tarball for exactly this reason. + + mesh-session.ts's own "../../package.json" import (OWN_VERSION) needs the identical treatment for a different reason: bundling would inline today's committed version string ("0.0.0" -- semantic-release/npm only rewrites package.json in the tarball it publishes, AFTER this package has already been built by CI), so every real release would report the wrong, stale version forever. Confirmed by inspecting the bundled output before adding this line: `var version = "0.0.0";` was baked directly into dist/domain/mesh-session.{mjs,cjs}. Kept external instead, this reads package.json fresh off disk (Node's own JSON-module loader for the ESM build, plain `require()`'s always-JSON-parsed reads for the CJS build) at actual runtime, against whichever package.json the published tarball -- or a bundler resolving this package's own node_modules copy for a browser consumer -- really carries. + + The wasm-dist pattern names the glue module itself and is anchored at the end of the id. neverBundle patterns are tested against absolute module ids, so an unanchored /\/wasm-dist\// would also match every module of a checkout whose own path merely contains a wasm-dist directory (a git worktree named wasm-dist, say): nothing would be bundled and the declaration build would fail with MISSING_EXPORT errors. */ deps: { neverBundle: [ /\/wasm-dist\/wire_mesh_threshold_wasm\.js$/, From f077f8358b3f3bff582d647c8c1c720acbdbceac Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sat, 10 Oct 2026 10:13:33 +0100 Subject: [PATCH 7/7] refactor(web-console): satisfy the eslint-config 3 rules Trailing comments move above the code they describe, consecutive line comments become block comments and statements are separated as the shared configuration requires. The console is a private app, so its own signatures change where the rules ask: sendRoomMessage, requestToJoin, mintRoomInviteGrant and useRoomMessaging take their optional arguments as objects. Concurrency, each item classified: - The grant, message and name stores read their values together. Independent and side-effect-free: reads of distinct keys, processed afterwards in key order. - Revocation entries are verified and recorded together. Independent: each is verified on its own and recorded under its token id, whose entries are unordered by contract, and the unreadable list keeps key order. - The held request permission is chosen by judging candidates one at a time in order and stopping at the first valid one, so later candidates are never judged. Sequential. - Conversations are restored one room at a time, so the rooms before one whose list fails are already restored, and rekey keys are written in ascending epoch order. Sequential. - The announcement stream is read in order into one array. The IndexedDB and WebRTC adapters no longer assign to properties of their parameters, and test helpers take optional arguments as objects. --- ts/packages/web-console/live-check/harness.ts | 9 ++ .../web-console/live-check/wire-gossip.ts | 6 +- ts/packages/web-console/playwright.config.ts | 8 +- .../playwright.containers.config.ts | 4 +- .../web-console/playwright.offline.config.ts | 4 +- ts/packages/web-console/src/App.tsx | 10 +- ts/packages/web-console/src/activity.ts | 5 + .../src/adapters/dial-transport.ts | 1 + .../src/adapters/identity-backup.ts | 5 + .../src/adapters/indexeddb-storage.ts | 14 ++- .../src/adapters/web-crypto-identity.ts | 25 ++-- .../src/adapters/webrtc-transport.ts | 11 +- .../src/adapters/websocket-transport.ts | 6 + .../src/adapters/webtransport-transport.ts | 36 ++++-- .../web-console/src/certificate-memory.ts | 1 + .../web-console/src/certificate-trust.ts | 3 + .../src/components/ActivityLog.tsx | 1 + .../src/components/CertificateTrustPanel.tsx | 2 + .../src/components/ConnectionHealth.tsx | 2 + .../src/components/ConnectionPanel.tsx | 4 + .../src/components/ConversationList.tsx | 1 + .../src/components/DiscoveredPeersPanel.tsx | 1 + .../src/components/GrantDetails.tsx | 1 + .../web-console/src/components/GrantTable.tsx | 1 + .../src/components/GrantsPanel.tsx | 2 + .../src/components/IdentitySection.tsx | 1 + .../src/components/ImportGrantForm.tsx | 1 + .../src/components/MessageList.tsx | 1 + .../src/components/MintGrantForm.tsx | 2 + .../src/components/NoticesView.tsx | 3 + .../src/components/PeerNamesProvider.tsx | 1 + .../web-console/src/components/RoomPanel.tsx | 2 + .../src/components/RttSparkline.tsx | 2 + .../src/components/SearchPanel.tsx | 2 + .../src/components/SelfNameField.tsx | 1 + .../src/components/UnreadableRevocations.tsx | 1 + ts/packages/web-console/src/conversations.ts | 12 ++ .../web-console/src/default-hub-address.ts | 1 + ts/packages/web-console/src/dial-address.ts | 1 + .../web-console/src/discover-local-node.ts | 2 + .../web-console/src/format-duration.ts | 3 + ts/packages/web-console/src/grant-store.ts | 10 +- ts/packages/web-console/src/grants.ts | 5 + .../src/hooks/use-certificate-trust.ts | 3 + .../src/hooks/use-connection-health.ts | 1 + .../web-console/src/hooks/use-grants.ts | 4 + .../src/hooks/use-mesh-session-events.ts | 1 + ts/packages/web-console/src/hooks/use-now.ts | 2 + .../web-console/src/hooks/use-peer-names.ts | 1 + .../src/hooks/use-room-messaging.ts | 109 ++++++++++-------- ts/packages/web-console/src/ice-servers.ts | 6 +- ts/packages/web-console/src/in-order.ts | 12 ++ ts/packages/web-console/src/local-network.ts | 6 + ts/packages/web-console/src/message-store.ts | 11 +- ts/packages/web-console/src/mint-grant.ts | 3 + ts/packages/web-console/src/name-store.ts | 11 +- ts/packages/web-console/src/notices.ts | 9 +- ts/packages/web-console/src/peer-names.ts | 4 + .../web-console/src/persistent-room-state.ts | 2 + ts/packages/web-console/src/pwa-manifest.ts | 4 +- ts/packages/web-console/src/request-demux.ts | 2 + .../web-console/src/request-permission.ts | 24 ++-- .../web-console/src/revocation-store.ts | 26 +++-- ts/packages/web-console/src/room-client.ts | 45 ++++++-- ts/packages/web-console/src/search.ts | 2 + .../web-console/src/webrtc-negotiation.ts | 8 +- .../test/App.revocations.unit.test.tsx | 1 + .../web-console/test/App.unit.test.tsx | 5 + .../test/ConnectionHealth.unit.test.tsx | 1 + .../test/ConnectionPanel.unit.test.tsx | 1 + .../test/ConversationList.unit.test.tsx | 15 ++- .../test/IdentityPanel.unit.test.tsx | 1 + .../test/IdentitySection.unit.test.tsx | 1 + .../test/MintGrantForm.unit.test.tsx | 2 + .../test/NoticesView.unit.test.tsx | 6 +- .../web-console/test/PeerName.unit.test.tsx | 1 + .../test/PeerNamesProvider.unit.test.tsx | 1 + .../web-console/test/RoomPanel.unit.test.tsx | 22 ++-- .../test/RttSparkline.unit.test.tsx | 1 + .../test/SearchPanel.unit.test.tsx | 11 +- .../test/StackedTable.unit.test.tsx | 1 + .../test/UnreadableRevocations.unit.test.tsx | 1 + ts/packages/web-console/test/app-harness.tsx | 7 ++ .../web-console/test/capability-services.ts | 1 + .../test/certificate-trust.unit.test.ts | 1 + .../test/conversations.unit.test.ts | 1 + .../offline.e2e.spec.ts | 18 ++- .../e2e-containers/two-containers.e2e.spec.ts | 1 + .../test/e2e/identity-grants.e2e.spec.ts | 2 + .../test/e2e/notices-offline.e2e.spec.ts | 1 + .../test/e2e/onboarding-mobile.e2e.spec.ts | 10 +- .../test/e2e/room-messaging.e2e.spec.ts | 8 +- .../web-console/test/e2e/webrtc.e2e.spec.ts | 27 +++-- .../webtransport-address-refresh.e2e.spec.ts | 15 ++- .../test/e2e/webtransport-support.ts | 3 + .../web-console/test/fake-rtc-data-channel.ts | 3 + .../web-console/test/fake-websocket.ts | 4 + .../web-console/test/grant-store.unit.test.ts | 1 + .../web-console/test/grants.unit.test.ts | 1 + ts/packages/web-console/test/hex.ts | 2 + .../test/identity-backup.unit.test.ts | 2 + .../test/jsdom-mantine-polyfills.ts | 2 + .../test/local-network.unit.test.ts | 20 ++-- .../web-console/test/mint-grant.unit.test.ts | 6 +- .../web-console/test/names-harness.tsx | 1 + .../web-console/test/notices.unit.test.ts | 62 ++++++---- .../test/request-demux.unit.test.ts | 1 + .../test/request-permission.unit.test.ts | 7 +- .../web-console/test/room-client.unit.test.ts | 70 +++++------ .../web-console/test/search.unit.test.ts | 2 + ts/packages/web-console/test/sequence.ts | 23 ++++ .../test/use-certificate-trust.unit.test.tsx | 1 + .../test/use-connection-health.unit.test.tsx | 3 + .../web-console/test/use-grants.unit.test.tsx | 4 + .../use-room-messaging-relay.unit.test.tsx | 58 ++++++---- .../test/use-room-messaging.unit.test.tsx | 9 +- .../test/web-crypto-identity.unit.test.ts | 6 +- .../test/webrtc-negotiation.unit.test.ts | 30 +++-- .../test/webrtc-transport.unit.test.ts | 8 +- .../websocket-transport.integration.test.ts | 7 +- .../test/webtransport-transport.unit.test.ts | 5 + ts/packages/web-console/vite.config.ts | 5 +- 122 files changed, 740 insertions(+), 279 deletions(-) create mode 100644 ts/packages/web-console/src/in-order.ts create mode 100644 ts/packages/web-console/test/sequence.ts diff --git a/ts/packages/web-console/live-check/harness.ts b/ts/packages/web-console/live-check/harness.ts index a171e85f..b72337ec 100644 --- a/ts/packages/web-console/live-check/harness.ts +++ b/ts/packages/web-console/live-check/harness.ts @@ -32,6 +32,7 @@ function wireFromGossipFrame(frame: Readonly): WireGossip { if (frame.type !== "gossip") { throw new Error(`expected a gossip frame, got ${frame.type}`); } + return { type: "gossip", peers: frame.peers.map((peer) => ({ @@ -75,6 +76,7 @@ function summarizeFrame( "verb" in params && typeof params.verb === "string" ? params.verb : undefined; + return { direction, type: frame.type, @@ -84,6 +86,7 @@ function summarizeFrame( if (frame.type === "manage-response") { return { direction, type: frame.type, result: frame.outcome.result }; } + return { direction, type: frame.type }; } @@ -91,6 +94,7 @@ function registerConnection(connection: Readonly): string { const id = String(nextConnectionId); nextConnectionId += 1; connections.set(id, connection); + return id; } @@ -99,6 +103,7 @@ function requireConnection(id: string): Connection { if (connection === undefined) { throw new Error(`unknown connection id ${id}`); } + return connection; } @@ -165,6 +170,7 @@ window.harness = { } })(); await session.connect(address, []); + return Array.from(identity.deviceId); }, async initiate(targetDevice: readonly number[]): Promise { @@ -174,6 +180,7 @@ window.harness = { const connection = await negotiatorRef.initiate( Uint8Array.from(targetDevice), ); + return registerConnection(connection); }, frameSummary(): FrameSummaryEntry[] { @@ -186,6 +193,7 @@ window.harness = { if (existing !== undefined) { return existing; } + return new Promise((resolve) => { incomingWaiters.push(resolve); }); @@ -204,6 +212,7 @@ window.harness = { if (result.done === true) { throw new Error("connection ended before a frame arrived"); } + return wireFromGossipFrame(result.value); }, async closeConnection(connectionId: string): Promise { diff --git a/ts/packages/web-console/live-check/wire-gossip.ts b/ts/packages/web-console/live-check/wire-gossip.ts index fb8d47e7..bf37018c 100644 --- a/ts/packages/web-console/live-check/wire-gossip.ts +++ b/ts/packages/web-console/live-check/wire-gossip.ts @@ -1,6 +1,6 @@ -// The one definition of the gossip frame's JSON-serialisable shape, shared by the browser harness that converts to and from it and by the Playwright spec that builds one to send. -// -// It lives in its own module, carrying no runtime code and no global declaration, so both sides can import it without either TypeScript program pulling in the other's globals. Kept separate because the two used to hold their own hand-written copies: peer-advert gained its identity-key and signature (wire-mesh#225), the harness's copy was updated, the spec's was not, and the mismatch surfaced only as a runtime failure in the end-to-end run. +/* The one definition of the gossip frame's JSON-serialisable shape, shared by the browser harness that converts to and from it and by the Playwright spec that builds one to send. + + It lives in its own module, carrying no runtime code and no global declaration, so both sides can import it without either TypeScript program pulling in the other's globals. Kept separate because the two used to hold their own hand-written copies: peer-advert gained its identity-key and signature (wire-mesh#225), the harness's copy was updated, the spec's was not, and the mismatch surfaced only as a runtime failure in the end-to-end run. */ /** A gossip frame as it crosses the page.evaluate boundary, with every byte string carried as a plain number array, since page.evaluate's arguments and return values must be JSON-serialisable. A narrow (de)serialisation for exactly the one frame shape the live-check exchanges, never a generic Frame codec. */ export interface WireGossip { diff --git a/ts/packages/web-console/playwright.config.ts b/ts/packages/web-console/playwright.config.ts index b237ff1b..e29c6dd9 100644 --- a/ts/packages/web-console/playwright.config.ts +++ b/ts/packages/web-console/playwright.config.ts @@ -1,9 +1,13 @@ import { defineConfig } from "@playwright/test"; -// A fixed, dedicated port pair for the e2e run (never the dev-time 8787/5173 pair) so a developer's own `pnpm dev` session never collides with CI or a concurrent local test run. +/** + * A fixed, dedicated port pair for the e2e run (never the dev-time 8787/5173 pair) so a developer's own `pnpm dev` session never collides with CI or a concurrent local test run. + */ export const RELAY_PORT = 8790; export const VITE_PORT = 8798; -// A second hub that also holds other devices' logs while they are offline, for the spec that has one device away while the other writes. +/** + * A second hub that also holds other devices' logs while they are offline, for the spec that has one device away while the other writes. + */ export const MAILBOX_RELAY_PORT = 8791; export const MAILBOX_RELAY_ADDRESS = `ws://127.0.0.1:${String(MAILBOX_RELAY_PORT)}`; const MAILBOX_DIR = "test-results/mailbox"; diff --git a/ts/packages/web-console/playwright.containers.config.ts b/ts/packages/web-console/playwright.containers.config.ts index ef4f5381..8e6642de 100644 --- a/ts/packages/web-console/playwright.containers.config.ts +++ b/ts/packages/web-console/playwright.containers.config.ts @@ -1,6 +1,8 @@ import { defineConfig } from "@playwright/test"; -// The spec run inside each browser container of test/e2e-containers/compose.yaml. No web server is started: the hub is a container of its own and serves the console. Chrome's default candidate handling is left alone (no flag hides or reveals local addresses), which is what a device on a real network runs with. The hub serves https with a certificate made for the run, so the browsers accept it. +/** + * The spec run inside each browser container of test/e2e-containers/compose.yaml. No web server is started: the hub is a container of its own and serves the console. Chrome's default candidate handling is left alone (no flag hides or reveals local addresses), which is what a device on a real network runs with. The hub serves https with a certificate made for the run, so the browsers accept it. + */ export default defineConfig({ testDir: "./test/e2e-containers", // Outside the mounted workspace, so a run leaves nothing behind in it. diff --git a/ts/packages/web-console/playwright.offline.config.ts b/ts/packages/web-console/playwright.offline.config.ts index 3f6b8e76..5eaf8994 100644 --- a/ts/packages/web-console/playwright.offline.config.ts +++ b/ts/packages/web-console/playwright.offline.config.ts @@ -1,6 +1,8 @@ import { defineConfig } from "@playwright/test"; -// The spec run inside each browser container of test/e2e-containers-offline/compose.yaml. The browser is launched by the spec itself, with a profile directory that survives between the two phases of a run, so the service worker installed in the first is still there in the second. +/** + * The spec run inside each browser container of test/e2e-containers-offline/compose.yaml. The browser is launched by the spec itself, with a profile directory that survives between the two phases of a run, so the service worker installed in the first is still there in the second. + */ export default defineConfig({ testDir: "./test/e2e-containers-offline", outputDir: "/tmp/test-results", diff --git a/ts/packages/web-console/src/App.tsx b/ts/packages/web-console/src/App.tsx index 138e0d82..2f86cc5a 100644 --- a/ts/packages/web-console/src/App.tsx +++ b/ts/packages/web-console/src/App.tsx @@ -148,13 +148,13 @@ export function App({ () => ({ revocation: revocations.view, grants }), [revocations, grants], ); - const roomMessaging = useRoomMessaging( + const roomMessaging = useRoomMessaging({ identity, clock, messageStore, roomStorage, capabilities, - ); + }); const [selectedPath, setSelectedPath] = useState(); // The conversation shown is the one the user picked, falling back to the first while nothing is picked or the picked one no longer exists. const selectedConversation = @@ -204,6 +204,7 @@ export function App({ const isClosed = (): boolean => closedRef.current; useEffect(() => { closedRef.current = false; + return () => { closedRef.current = true; for (const entry of connectionsRef.current) { @@ -274,6 +275,7 @@ export function App({ claimants.add(key); claimedDevices.current.set(candidateAddress, claimants); } + return new Promise((resolve) => { setDiscovered((current) => [ ...current, @@ -291,6 +293,7 @@ export function App({ function resolveDiscovered(key: string, approved: boolean): void { setDiscovered((current) => { current.find((entry) => entry.key === key)?.resolve(approved); + return current.filter((entry) => entry.key !== key); }); } @@ -334,6 +337,7 @@ export function App({ onExpansionDeclined: forgetClaims, onExpansionFailed: forgetClaims, }); + return session; } @@ -371,6 +375,7 @@ export function App({ await session.close(); throw new Error("the console was closed while the dial was connecting"); } + return session; } @@ -442,6 +447,7 @@ export function App({ connection.session.sendRevocationAnnounce([entry]), ), ); + return { attempted: outcomes.length, reached: outcomes.filter((outcome) => outcome.status === "fulfilled") diff --git a/ts/packages/web-console/src/activity.ts b/ts/packages/web-console/src/activity.ts index 71ab5968..ee6a217d 100644 --- a/ts/packages/web-console/src/activity.ts +++ b/ts/packages/web-console/src/activity.ts @@ -86,6 +86,7 @@ export function describeFrame( if (frame.type === "relay-data") { const other = direction === "sent" ? frame["to-device"] : frame["from-device"]; + return [ { kind: "relay", @@ -106,6 +107,7 @@ export function describeFrame( }, ]; } + return []; } @@ -155,6 +157,7 @@ function describeState( connection(`Handshake rejected (${state.handshake.reason})`); } } + return entries; } @@ -186,6 +189,7 @@ export function advanceActivity( return false; } seen.add(entry.peer); + return true; }), ]; @@ -194,6 +198,7 @@ export function advanceActivity( key: `activity-${String(tracker.issued + index)}`, at, })); + return { tracker: { frames: event.frameLog.length, diff --git a/ts/packages/web-console/src/adapters/dial-transport.ts b/ts/packages/web-console/src/adapters/dial-transport.ts index 0e217596..517e94d3 100644 --- a/ts/packages/web-console/src/adapters/dial-transport.ts +++ b/ts/packages/web-console/src/adapters/dial-transport.ts @@ -12,6 +12,7 @@ export function createDialTransport( ): Transport { const webSocket = createBrowserTransport(); const webTransport = createBrowserWebTransportTransport(memory); + return { connect: async (address) => isPinnedAddress(address) diff --git a/ts/packages/web-console/src/adapters/identity-backup.ts b/ts/packages/web-console/src/adapters/identity-backup.ts index 1db0d3b1..35343803 100644 --- a/ts/packages/web-console/src/adapters/identity-backup.ts +++ b/ts/packages/web-console/src/adapters/identity-backup.ts @@ -49,12 +49,14 @@ function bytesFromHex(hex: string): Uint8Array { HEX_RADIX, ); } + return bytes; } function fromBase64Url(text: string): Uint8Array { try { const binary = atob(text.replace(/-/g, "+").replace(/_/g, "/")); + return Uint8Array.from(binary, (char) => char.charCodeAt(0)); } catch { throw new Error("the backup's key is not valid base64url"); @@ -84,6 +86,7 @@ function privateP256From( ) { throw new Error("the backup does not hold a P-256 private key"); } + return { kty, crv, x, y, d }; } @@ -160,6 +163,7 @@ export async function parseIdentityBackup( throw new Error("the backup's device-id does not match its public key"); } await assertKeyPair(privateJwk, point); + return { format: IDENTITY_BACKUP_FORMAT, version: IDENTITY_BACKUP_VERSION, @@ -183,6 +187,7 @@ export function createIdentityBackupService( if (!isStoredIdentityEnvelope(envelope)) { throw new Error("stored identity envelope is malformed"); } + return { format: IDENTITY_BACKUP_FORMAT, version: IDENTITY_BACKUP_VERSION, diff --git a/ts/packages/web-console/src/adapters/indexeddb-storage.ts b/ts/packages/web-console/src/adapters/indexeddb-storage.ts index d97518d3..eb6b2b64 100644 --- a/ts/packages/web-console/src/adapters/indexeddb-storage.ts +++ b/ts/packages/web-console/src/adapters/indexeddb-storage.ts @@ -19,6 +19,7 @@ interface StoredRecord { function isStoredRecord(value: unknown): value is StoredRecord { if (typeof value !== "object" || value === null) return false; if (!("key" in value) || !("value" in value)) return false; + return typeof value.key === "string" && value.value instanceof Uint8Array; } @@ -28,12 +29,12 @@ function isStringKey(key: IDBValidKey): key is string { async function requestToPromise(request: IDBRequest): Promise { return new Promise((resolve, reject) => { - request.onsuccess = () => { + request.addEventListener("success", () => { resolve(request.result); - }; - request.onerror = () => { + }); + request.addEventListener("error", () => { reject(request.error ?? new Error("IndexedDB request failed")); - }; + }); }); } @@ -48,10 +49,12 @@ async function getRecord( const result: unknown = request.result; if (result === undefined) { resolve(undefined); + return; } if (!isStoredRecord(result)) { reject(new Error("stored record is malformed")); + return; } resolve(result); @@ -94,6 +97,7 @@ export async function createIndexedDbStorage( return { async get(key) { const record = await getRecord(store("readonly"), key); + return record?.value; }, async set(key, value) { @@ -113,10 +117,12 @@ export async function createIndexedDbStorage( const allKeys = await requestToPromise( store("readonly").getAllKeys(range), ); + return allKeys.map((key) => { if (!isStringKey(key)) { throw new Error("stored key is not a string"); } + return key; }); }, diff --git a/ts/packages/web-console/src/adapters/web-crypto-identity.ts b/ts/packages/web-console/src/adapters/web-crypto-identity.ts index 547c2314..68cc8bbd 100644 --- a/ts/packages/web-console/src/adapters/web-crypto-identity.ts +++ b/ts/packages/web-console/src/adapters/web-crypto-identity.ts @@ -59,6 +59,7 @@ export async function verifyWithPublicKey( signature: Uint8Array, ): Promise { const cryptoKey = await importPublicKey(key); + return crypto.subtle.verify( algParams(key.alg), cryptoKey, @@ -92,6 +93,7 @@ export async function createWebCryptoIdentity(): Promise { keyPair.privateKey, toBufferSource(message), ); + return new Uint8Array(signature); }, verify: verifyWithPublicKey, @@ -109,6 +111,7 @@ export function isStoredIdentityEnvelope( ): value is StoredIdentityEnvelope { if (typeof value !== "object" || value === null) return false; if (!("privateJwk" in value) || !("publicKeyRaw" in value)) return false; + return ( typeof value.privateJwk === "object" && value.privateJwk !== null && @@ -127,6 +130,7 @@ async function generateStoredIdentityEnvelope(): Promise const publicKeyRaw = new Uint8Array( await crypto.subtle.exportKey("raw", keyPair.publicKey), ); + return { privateJwk, publicKeyRaw }; } @@ -151,13 +155,13 @@ export async function identityFromStoredEnvelope( }; const deviceId = await deriveDeviceId(envelope.publicKeyRaw); - // The same scalar imported a second time under ECDH for room.rekey's - // deriveSharedSecret -- possible here precisely because the persisted - // envelope already re-imports from JWK (the one-time export happened at - // generation), so no additional extractability is granted. The source - // JWK's own alg/key_ops describe its ECDSA usage; strip both (importKey - // validates requested usage against key_ops when present) and re-declare - // deriveBits, which is all ECDH needs from the curve point. + /* The same scalar imported a second time under ECDH for room.rekey's + deriveSharedSecret -- possible here precisely because the persisted + envelope already re-imports from JWK (the one-time export happened at + generation), so no additional extractability is granted. The source + JWK's own alg/key_ops describe its ECDSA usage; strip both (importKey + validates requested usage against key_ops when present) and re-declare + deriveBits, which is all ECDH needs from the curve point. */ const ecdhJwk: JsonWebKey = { ...envelope.privateJwk, key_ops: ["deriveBits"], @@ -180,6 +184,7 @@ export async function identityFromStoredEnvelope( privateKey, toBufferSource(message), ); + return new Uint8Array(signature); }, verify: verifyWithPublicKey, @@ -197,12 +202,14 @@ export async function identityFromStoredEnvelope( false, [], ); - const SHARED_SECRET_BIT_LENGTH = 256; // P-256's own field size + // P-256's own field size + const SHARED_SECRET_BIT_LENGTH = 256; const bits = await crypto.subtle.deriveBits( { name: "ECDH", public: peerPublicKey }, ecdhPrivateKey, SHARED_SECRET_BIT_LENGTH, ); + return new Uint8Array(bits); }, }; @@ -218,6 +225,7 @@ export async function createPersistedWebCryptoIdentity( if (!isStoredIdentityEnvelope(decoded)) { throw new Error("stored identity envelope is malformed"); } + return identityFromStoredEnvelope(decoded); } const envelope = await generateStoredIdentityEnvelope(); @@ -225,5 +233,6 @@ export async function createPersistedWebCryptoIdentity( IDENTITY_STORAGE_KEY, new Uint8Array(encode(envelope, cdeEncodeOptions)), ); + return identityFromStoredEnvelope(envelope); } diff --git a/ts/packages/web-console/src/adapters/webrtc-transport.ts b/ts/packages/web-console/src/adapters/webrtc-transport.ts index 8825163f..4aaf606b 100644 --- a/ts/packages/web-console/src/adapters/webrtc-transport.ts +++ b/ts/packages/web-console/src/adapters/webrtc-transport.ts @@ -1,6 +1,6 @@ -// A Connection implementation over an already-connected RTCDataChannel, the same message convention as the WebSocket adapter: RTCDataChannel delivers one whole message per send() (message-based, not a byte stream, in its default reliable/ordered mode -- the same guarantee WebSocket messages already give the CBOR framing here), so one send() call carries exactly one CBOR-encoded frame with no length prefix. Undecodable bytes are a connection-level failure (the receive iteration rejects and the channel closes), matching the WebSocket adapter's treatment of hostile wire input; a decodable frame that fails schema validation is dropped rather than disconnecting. This module does no signaling of its own -- it only wraps a channel that has already reached "open", handed to it by webrtc-negotiation.ts once offer/answer/ICE negotiation completes. -// -// Exactly like createBrowserTransport's connect() sets ws.binaryType before calling wrapWebSocket, the caller here (webrtc-negotiation.ts) must set channel.binaryType = "arraybuffer" explicitly -- not relied on as an unstated default -- on the still-mutable channel before handing it to wrapRtcDataChannel. This adapter takes a Readonly channel and never mutates it itself. +/* A Connection implementation over an already-connected RTCDataChannel, the same message convention as the WebSocket adapter: RTCDataChannel delivers one whole message per send() (message-based, not a byte stream, in its default reliable/ordered mode -- the same guarantee WebSocket messages already give the CBOR framing here), so one send() call carries exactly one CBOR-encoded frame with no length prefix. Undecodable bytes are a connection-level failure (the receive iteration rejects and the channel closes), matching the WebSocket adapter's treatment of hostile wire input; a decodable frame that fails schema validation is dropped rather than disconnecting. This module does no signaling of its own -- it only wraps a channel that has already reached "open", handed to it by webrtc-negotiation.ts once offer/answer/ICE negotiation completes. + + Exactly like createBrowserTransport's connect() sets ws.binaryType before calling wrapWebSocket, the caller here (webrtc-negotiation.ts) must set channel.binaryType = "arraybuffer" explicitly -- not relied on as an unstated default -- on the still-mutable channel before handing it to wrapRtcDataChannel. This adapter takes a Readonly channel and never mutates it itself. */ import type { Frame } from "wire-mesh-core/generated/protocol"; import type { Connection } from "wire-mesh-core/ports/transport"; @@ -41,6 +41,7 @@ export function wrapRtcDataChannel( // Only binary messages carry frames; a text message is a protocol violation on this connection, same class as undecodable bytes. failAll(new Error("expected a binary data channel message")); channel.close(); + return; } let frame: Frame; @@ -56,6 +57,7 @@ export function wrapRtcDataChannel( : new Error(`frame body failed to decode: ${String(error)}`), ); channel.close(); + return; } const waiter = waiters.shift(); @@ -87,6 +89,7 @@ export function wrapRtcDataChannel( if (ended) { return Promise.resolve({ value: undefined, done: true }); } + return new Promise((resolve, reject) => { waiters.push({ resolve, reject }); }); @@ -100,11 +103,13 @@ export function wrapRtcDataChannel( ); } channel.send(messageFromFrame(frame)); + return Promise.resolve(); }, receive: () => receiveStream, async close(): Promise { channel.close(); + return Promise.resolve(); }, }; diff --git a/ts/packages/web-console/src/adapters/websocket-transport.ts b/ts/packages/web-console/src/adapters/websocket-transport.ts index 0092747e..77be401b 100644 --- a/ts/packages/web-console/src/adapters/websocket-transport.ts +++ b/ts/packages/web-console/src/adapters/websocket-transport.ts @@ -48,6 +48,7 @@ export function wrapWebSocket(ws: Readonly): Connection { // Only binary messages carry frames; a text message is a protocol violation on this connection, same class as undecodable bytes. failAll(new Error("expected a binary WebSocket message")); ws.close(CLOSE_PROTOCOL_ERROR, "protocol error"); + return; } let frame: Frame; @@ -63,6 +64,7 @@ export function wrapWebSocket(ws: Readonly): Connection { : new Error(`frame body failed to decode: ${String(error)}`), ); ws.close(CLOSE_PROTOCOL_ERROR, "protocol error"); + return; } const waiter = waiters.shift(); @@ -94,6 +96,7 @@ export function wrapWebSocket(ws: Readonly): Connection { if (ended) { return Promise.resolve({ value: undefined, done: true }); } + return new Promise((resolve, reject) => { waiters.push({ resolve, reject }); }); @@ -105,11 +108,13 @@ export function wrapWebSocket(ws: Readonly): Connection { return Promise.reject(new Error("connection is closed")); } ws.send(messageFromFrame(frame)); + return Promise.resolve(); }, receive: () => receiveStream, async close(): Promise { ws.close(CLOSE_NORMAL); + return Promise.resolve(); }, }; @@ -124,6 +129,7 @@ export function createBrowserTransport(): Transport { } const ws = new WebSocket(url); ws.binaryType = "arraybuffer"; + return new Promise((resolve, reject) => { ws.addEventListener("open", () => { resolve(); diff --git a/ts/packages/web-console/src/adapters/webtransport-transport.ts b/ts/packages/web-console/src/adapters/webtransport-transport.ts index 221268ad..e911b0bf 100644 --- a/ts/packages/web-console/src/adapters/webtransport-transport.ts +++ b/ts/packages/web-console/src/adapters/webtransport-transport.ts @@ -23,6 +23,27 @@ export const STREAM_OPEN_TIMEOUT_MS = 8000; const LISTEN_UNSUPPORTED = "the web console is a client only: it cannot listen"; +/** Reads a stream of byte chunks to its end, in order. */ +async function readChunks( + reader: Readonly>, +): Promise { + const chunks: Uint8Array[] = []; + const readNext = async (): Promise => { + const chunk = await reader.read(); + if (chunk.done) { + return; + } + if (!(chunk.value instanceof Uint8Array)) { + throw new Error("the announcement carried something other than bytes"); + } + chunks.push(chunk.value); + await readNext(); + }; + await readNext(); + + return chunks; +} + /** Everything the node sent on the stream it opened for the purpose, joined. */ async function readAnnouncement( session: Readonly, @@ -38,18 +59,8 @@ async function readAnnouncement( if (!(opened instanceof ReadableStream)) { throw new Error("the announcement stream is not a readable stream"); } - const chunks: Uint8Array[] = []; const reader: ReadableStreamDefaultReader = opened.getReader(); - for (;;) { - const chunk = await reader.read(); - if (chunk.done) { - break; - } - if (!(chunk.value instanceof Uint8Array)) { - throw new Error("the announcement carried something other than bytes"); - } - chunks.push(chunk.value); - } + const chunks = await readChunks(reader); const joined = new Uint8Array( chunks.reduce((total, chunk) => total + chunk.length, 0), ); @@ -58,6 +69,7 @@ async function readAnnouncement( joined.set(chunk, offset); offset += chunk.length; } + return decodeCertificateHashes(joined); } @@ -82,6 +94,7 @@ async function openStream( "this browser cannot open a stream to this node: it is waiting for flow-control credit the node never grants, which is how Safari behaves with the node's HTTP/3 library (fails-components/webtransport#490); reach the node over wss:// instead", ); } + return opened; } finally { clearTimeout(timer); @@ -126,6 +139,7 @@ export function createBrowserWebTransportTransport( await memory.remember(node, hashes); }) .catch(() => undefined); + return { ...connection, redialAddress: () => diff --git a/ts/packages/web-console/src/certificate-memory.ts b/ts/packages/web-console/src/certificate-memory.ts index d49b67d2..d9a57ebf 100644 --- a/ts/packages/web-console/src/certificate-memory.ts +++ b/ts/packages/web-console/src/certificate-memory.ts @@ -24,6 +24,7 @@ export function createCertificateMemory( return { recall: async (node) => { const stored = await storage.get(KEY_PREFIX + node); + return stored === undefined ? [] : decodeCertificateHashes(stored); }, remember: async (node, hashes) => { diff --git a/ts/packages/web-console/src/certificate-trust.ts b/ts/packages/web-console/src/certificate-trust.ts index 19dd8b2f..52887d34 100644 --- a/ts/packages/web-console/src/certificate-trust.ts +++ b/ts/packages/web-console/src/certificate-trust.ts @@ -36,6 +36,7 @@ export function assessCertificates( if (trusted.length > 0) { return { kind: "known", node, trusted }; } + return { kind: "changed", node, remembered, presented }; } @@ -48,6 +49,7 @@ export function presentedCertificates( } try { const { url, sha256 } = parsePinnedAddress(address); + return { node: new URL(url).host, sha256: [...sha256] }; } catch { // Malformed pins are not something to trust or remember. The dial that follows rejects the address with the parser's own message, which the connection's status line reports. @@ -99,5 +101,6 @@ export function formatFingerprint(sha256Hex: string): string { sha256Hex.slice(index, index + HEX_DIGITS_PER_BYTE).toUpperCase(), ); } + return pairs.join(":"); } diff --git a/ts/packages/web-console/src/components/ActivityLog.tsx b/ts/packages/web-console/src/components/ActivityLog.tsx index d931e6c1..a4a33db0 100644 --- a/ts/packages/web-console/src/components/ActivityLog.tsx +++ b/ts/packages/web-console/src/components/ActivityLog.tsx @@ -22,6 +22,7 @@ export function ActivityLog({ frameLog, }: Readonly): React.JSX.Element { const [raw, setRaw] = useState(false); + return ( <> ): React.JSX.Element { const { assessment, decide } = prompt; const changed = assessment.kind === "changed"; + return ( {prompts.map((prompt) => ( diff --git a/ts/packages/web-console/src/components/ConnectionHealth.tsx b/ts/packages/web-console/src/components/ConnectionHealth.tsx index dbbdd0a3..66bc6c57 100644 --- a/ts/packages/web-console/src/components/ConnectionHealth.tsx +++ b/ts/packages/web-console/src/components/ConnectionHealth.tsx @@ -33,6 +33,7 @@ function ReconnectStatus({ ? undefined : statusSince + policy.delayMs(state.attempt); const due = retryAt === undefined ? 0 : retryAt - now; + return ( {due > 0 ? `Retrying in ${formatRemaining(due)}` : "Retrying now"}{" "} @@ -62,6 +63,7 @@ export function ConnectionHealth({ if (state.status !== "connected") { return null; } + return ( diff --git a/ts/packages/web-console/src/components/ConnectionPanel.tsx b/ts/packages/web-console/src/components/ConnectionPanel.tsx index e1bc0163..c3e26ece 100644 --- a/ts/packages/web-console/src/components/ConnectionPanel.tsx +++ b/ts/packages/web-console/src/components/ConnectionPanel.tsx @@ -38,6 +38,7 @@ export function deviceHex(device: Uint8Array): string { for (const byte of device) { hex += byte.toString(HEX_RADIX).padStart(2, "0"); } + return hex; } @@ -55,6 +56,7 @@ function describeHandshake(event: Readonly): string { case "rejected": return ` · handshake rejected (${event.state.handshake.reason})`; } + return ""; } @@ -72,6 +74,7 @@ function describeStatus(event: Readonly): string { case "idle": return "idle"; } + return ""; } @@ -135,6 +138,7 @@ export function ConnectionPanel({ }, ); } + return () => { current.value = false; }; diff --git a/ts/packages/web-console/src/components/ConversationList.tsx b/ts/packages/web-console/src/components/ConversationList.tsx index cbb6e3a9..09503fd5 100644 --- a/ts/packages/web-console/src/components/ConversationList.tsx +++ b/ts/packages/web-console/src/components/ConversationList.tsx @@ -19,6 +19,7 @@ export function ConversationList({ if (conversations.length === 0) { return null; } + return ( diff --git a/ts/packages/web-console/src/components/DiscoveredPeersPanel.tsx b/ts/packages/web-console/src/components/DiscoveredPeersPanel.tsx index 91bf68d2..074553d3 100644 --- a/ts/packages/web-console/src/components/DiscoveredPeersPanel.tsx +++ b/ts/packages/web-console/src/components/DiscoveredPeersPanel.tsx @@ -44,6 +44,7 @@ export function DiscoveredPeersPanel({ if (peers.length === 0) { return null; } + return ( diff --git a/ts/packages/web-console/src/components/GrantDetails.tsx b/ts/packages/web-console/src/components/GrantDetails.tsx index 7b4bf235..66dab366 100644 --- a/ts/packages/web-console/src/components/GrantDetails.tsx +++ b/ts/packages/web-console/src/components/GrantDetails.tsx @@ -25,6 +25,7 @@ export function GrantDetails({ }: Readonly<{ row: GrantRow }>): React.JSX.Element { const { claims } = row; const code = encodeGrantCode(row.token); + return ( diff --git a/ts/packages/web-console/src/components/GrantTable.tsx b/ts/packages/web-console/src/components/GrantTable.tsx index c212afb0..98eba55b 100644 --- a/ts/packages/web-console/src/components/GrantTable.tsx +++ b/ts/packages/web-console/src/components/GrantTable.tsx @@ -34,6 +34,7 @@ export function GrantTable({ const [inspected, setInspected] = useState(undefined); // The grant whose revocation is awaiting confirmation: revoking cannot be undone. const [revoking, setRevoking] = useState(undefined); + return (
diff --git a/ts/packages/web-console/src/components/GrantsPanel.tsx b/ts/packages/web-console/src/components/GrantsPanel.tsx index aa1ef47e..4d570d3a 100644 --- a/ts/packages/web-console/src/components/GrantsPanel.tsx +++ b/ts/packages/web-console/src/components/GrantsPanel.tsx @@ -43,6 +43,7 @@ function describeRevocation( text: `Sent to ${String(reached)} of ${String(attempted)} connected nodes. This console no longer honours the grant.`, }; } + return { color: "green", title: "Revoked", @@ -62,6 +63,7 @@ export function GrantsPanel({ revocation === undefined ? undefined : describeRevocation(revocation); const held = grants.rows.filter((row) => row.direction === "held"); const issued = grants.rows.filter((row) => row.direction === "issued"); + return ( diff --git a/ts/packages/web-console/src/components/IdentitySection.tsx b/ts/packages/web-console/src/components/IdentitySection.tsx index 900eddc7..5f632c96 100644 --- a/ts/packages/web-console/src/components/IdentitySection.tsx +++ b/ts/packages/web-console/src/components/IdentitySection.tsx @@ -37,6 +37,7 @@ export function IdentitySection({ revocations, announce: announceRevocation, }); + return ( diff --git a/ts/packages/web-console/src/components/ImportGrantForm.tsx b/ts/packages/web-console/src/components/ImportGrantForm.tsx index 03c51864..7ca7ed36 100644 --- a/ts/packages/web-console/src/components/ImportGrantForm.tsx +++ b/ts/packages/web-console/src/components/ImportGrantForm.tsx @@ -15,6 +15,7 @@ export function ImportGrantForm({ const [outcome, setOutcome] = useState( undefined, ); + return (
{ diff --git a/ts/packages/web-console/src/components/MessageList.tsx b/ts/packages/web-console/src/components/MessageList.tsx index 6443479b..cfb52cb4 100644 --- a/ts/packages/web-console/src/components/MessageList.tsx +++ b/ts/packages/web-console/src/components/MessageList.tsx @@ -61,6 +61,7 @@ export function MessageList({ const sent = message.direction === "sent"; const startsRun = index === 0 || messages[index - 1]?.direction !== message.direction; + return ( ): string | undefined { if (entry.plaintext === undefined) return undefined; + return new TextDecoder().decode(entry.plaintext); } @@ -20,6 +21,7 @@ export function NoticesView({ } const items = notices.map((entry, index) => { const text = textOf(entry); + return ( {text !== undefined ? ( @@ -32,6 +34,7 @@ export function NoticesView({ ); }); + return ( {items} diff --git a/ts/packages/web-console/src/components/PeerNamesProvider.tsx b/ts/packages/web-console/src/components/PeerNamesProvider.tsx index 6154e477..c1f73fa3 100644 --- a/ts/packages/web-console/src/components/PeerNamesProvider.tsx +++ b/ts/packages/web-console/src/components/PeerNamesProvider.tsx @@ -99,6 +99,7 @@ export function PeerNamesProvider({ next.set(hex, name); } } + return next ?? current; }); }, diff --git a/ts/packages/web-console/src/components/RoomPanel.tsx b/ts/packages/web-console/src/components/RoomPanel.tsx index 7a85f562..978bad71 100644 --- a/ts/packages/web-console/src/components/RoomPanel.tsx +++ b/ts/packages/web-console/src/components/RoomPanel.tsx @@ -28,6 +28,7 @@ export interface RoomPanelProps { function routeLabel(via: ConversationView["via"]): string { if (via === "direct") return "direct"; if (via === "hub") return "via hub"; + return "offline"; } @@ -39,6 +40,7 @@ export function RoomPanel({ onDiscard, }: Readonly): React.JSX.Element { const names = usePeerNames(); + return ( diff --git a/ts/packages/web-console/src/components/RttSparkline.tsx b/ts/packages/web-console/src/components/RttSparkline.tsx index 7d529a9e..858e36a5 100644 --- a/ts/packages/web-console/src/components/RttSparkline.tsx +++ b/ts/packages/web-console/src/components/RttSparkline.tsx @@ -24,6 +24,7 @@ function describe(samples: readonly RttSample[]): string { ? "No round trips measured yet" : "Last ping got no pong"; } + return `Round trip ${String(Math.round(last))} ms`; } @@ -46,6 +47,7 @@ export function RttSparkline({ .filter((point) => point.y !== undefined) .map((point) => `${point.x.toFixed(1)},${String(point.y)}`) .join(" "); + return (
[conversation.roomPath, conversation]), ); + return ( { const conversation = conversationOf.get(result.roomPath); + return ( (undefined); const draft = edit ?? selfName ?? ""; + return (
{ diff --git a/ts/packages/web-console/src/components/UnreadableRevocations.tsx b/ts/packages/web-console/src/components/UnreadableRevocations.tsx index d527bdda..d3b9f5cd 100644 --- a/ts/packages/web-console/src/components/UnreadableRevocations.tsx +++ b/ts/packages/web-console/src/components/UnreadableRevocations.tsx @@ -24,6 +24,7 @@ export function UnreadableRevocations({ if (unreadable.length === 0) { return null; } + return ( bytesToHex(m.messageId))); const added = incoming.filter((m) => !known.has(bytesToHex(m.messageId))); if (added.length === 0) return existing; + return [...existing, ...added].sort(compareMessages); } @@ -122,6 +124,7 @@ function update( ): ReadonlyMap { const entry = state.get(roomPath); if (entry === undefined) return state; + return new Map(state).set(roomPath, change(entry)); } @@ -153,6 +156,7 @@ function withRoutes( ): ConversationInternal { const via: ConversationVia | undefined = direct !== undefined ? "direct" : relay !== undefined ? "hub" : undefined; + return { ...entry, direct, @@ -171,6 +175,7 @@ export function reduceConversations( const entry = state.get(action.roomPath) ?? blank(action.roomPath, action.participants); + // A new direct connection starts with an empty notice board; the token and any pending request belong to the conversation and outlive the connection they arrived on. return new Map(state).set(action.roomPath, { ...withRoutes(entry, action.session, entry.relay), @@ -182,6 +187,7 @@ export function reduceConversations( const entry = state.get(action.roomPath) ?? blank(action.roomPath, action.participants); + return new Map(state).set( action.roomPath, withRoutes(entry, entry.direct, action.route), @@ -200,6 +206,7 @@ export function reduceConversations( ); } } + return next; } case "restored": { @@ -210,6 +217,7 @@ export function reduceConversations( messages: mergeMessages(entry.messages, action.messages), })); } + return new Map(state).set(action.roomPath, { ...blank(action.roomPath, action.participants), messages: mergeMessages([], action.messages), @@ -224,6 +232,7 @@ export function reduceConversations( return update(state, action.roomPath, (entry) => { const messages = mergeMessages(entry.messages, [action.message]); const isNew = messages !== entry.messages; + return { ...entry, messages, @@ -267,6 +276,7 @@ export function reduceConversations( token: action.token, })); } + return state; } @@ -279,6 +289,7 @@ export function participantsOf( if (parsed.kind === "dm") { return parsed.participants.filter((hex) => hex !== ownDeviceHex); } + return parsed.owner === ownDeviceHex ? [] : [parsed.owner]; } @@ -288,6 +299,7 @@ export function participantLabel( names: Readonly<{ labelOf: (deviceHex: string) => PeerLabel }>, ): string { if (view.participants.length === 0) return view.roomPath; + return view.participants .map((hex) => labelText(names.labelOf(hex))) .join(", "); diff --git a/ts/packages/web-console/src/default-hub-address.ts b/ts/packages/web-console/src/default-hub-address.ts index b8fedcdc..196b0022 100644 --- a/ts/packages/web-console/src/default-hub-address.ts +++ b/ts/packages/web-console/src/default-hub-address.ts @@ -6,6 +6,7 @@ export function sameOriginHubAddress( location: Readonly>, ): string { const wsProtocol = location.protocol === "https:" ? "wss:" : "ws:"; + return `${wsProtocol}//${location.host}`; } diff --git a/ts/packages/web-console/src/dial-address.ts b/ts/packages/web-console/src/dial-address.ts index efc0edb4..f8b5b861 100644 --- a/ts/packages/web-console/src/dial-address.ts +++ b/ts/packages/web-console/src/dial-address.ts @@ -31,5 +31,6 @@ export function toDialAddress(address: string): string { const url = new URL(address); url.protocol = websocketScheme; url.hash = ""; + return url.toString(); } diff --git a/ts/packages/web-console/src/discover-local-node.ts b/ts/packages/web-console/src/discover-local-node.ts index c0c8cded..36770276 100644 --- a/ts/packages/web-console/src/discover-local-node.ts +++ b/ts/packages/web-console/src/discover-local-node.ts @@ -16,6 +16,7 @@ export async function probeLocalNode( ): Promise { try { await fetch(`http://127.0.0.1:${String(port)}/`, { mode: "no-cors" }); + return `ws://127.0.0.1:${String(port)}`; } catch { return undefined; @@ -29,5 +30,6 @@ export async function discoverLocalNode( if (isLocalOrigin(host)) { return Promise.resolve(undefined); } + return probeLocalNode(); } diff --git a/ts/packages/web-console/src/format-duration.ts b/ts/packages/web-console/src/format-duration.ts index 5bdb1e58..1eaf9286 100644 --- a/ts/packages/web-console/src/format-duration.ts +++ b/ts/packages/web-console/src/format-duration.ts @@ -14,18 +14,21 @@ function coarseDuration(durationMs: number): string | undefined { if (minutes < MINUTES_PER_HOUR) return `${String(minutes)} min`; const hours = Math.floor(minutes / MINUTES_PER_HOUR); if (hours < HOURS_PER_DAY) return `${String(hours)} h`; + return `${String(Math.floor(hours / HOURS_PER_DAY))} d`; } /** `elapsedMs` as a coarse "N unit ago", or "just now" under a second. A negative value (a peer's clock running ahead of this one) reads as "just now" too, since a peer cannot have been seen in the future. */ export function formatAgo(elapsedMs: number): string { const duration = coarseDuration(elapsedMs); + return duration === undefined ? "just now" : `${duration} ago`; } /** `remainingMs` as a coarse "in N unit", or "in under a second" below a second. */ export function formatUntil(remainingMs: number): string { const duration = coarseDuration(remainingMs); + return duration === undefined ? "in under a second" : `in ${duration}`; } diff --git a/ts/packages/web-console/src/grant-store.ts b/ts/packages/web-console/src/grant-store.ts index 9c609cc1..6b9bd7fd 100644 --- a/ts/packages/web-console/src/grant-store.ts +++ b/ts/packages/web-console/src/grant-store.ts @@ -66,6 +66,7 @@ export function createGrantStore( storage: Readonly, ): GrantStore { const listeners = new Set<() => void>(); + return { async record(direction, token, at) { const claims = decodeGrantClaims(token); @@ -82,8 +83,11 @@ export function createGrantStore( }, async list() { const records: GrantRecord[] = []; - for (const key of await storage.keys(KEY_PREFIX)) { - const value = await storage.get(key); + const keys = await storage.keys(KEY_PREFIX); + const entries = await Promise.all( + keys.map(async (key) => ({ key, value: await storage.get(key) })), + ); + for (const { key, value } of entries) { if (value === undefined) continue; const stored: unknown = decode(value, cdeDecodeOptions); if (!isStoredGrant(stored)) { @@ -105,10 +109,12 @@ export function createGrantStore( recordedAt: stored.recordedAt, }); } + return records.sort((a, b) => b.recordedAt - a.recordedAt); }, subscribe(listener) { listeners.add(listener); + return () => { listeners.delete(listener); }; diff --git a/ts/packages/web-console/src/grants.ts b/ts/packages/web-console/src/grants.ts index 2ee248ec..d08513ce 100644 --- a/ts/packages/web-console/src/grants.ts +++ b/ts/packages/web-console/src/grants.ts @@ -29,6 +29,7 @@ export function decodeGrantClaims( return undefined; } const parsed = tokenClaimsSchema.safeParse(decoded); + return parsed.success ? parsed.data : undefined; } @@ -37,6 +38,7 @@ function toBase64Url(bytes: Uint8Array): string { for (const byte of bytes) { binary += String.fromCharCode(byte); } + return btoa(binary) .replace(/\+/g, "-") .replace(/\//g, "_") @@ -50,6 +52,7 @@ function fromBase64Url(text: string): Uint8Array { for (let index = 0; index < binary.length; index++) { bytes[index] = binary.charCodeAt(index); } + return bytes; } @@ -80,6 +83,7 @@ export function decodeGrantCode(code: string): CapabilityToken { if (!parsed.success || decodeGrantClaims(parsed.data) === undefined) { throw new Error("the grant code does not carry a capability token"); } + return parsed.data; } @@ -92,6 +96,7 @@ export async function grantStatus( options: Readonly, ): Promise { const verdict = await verifyCapabilityToken(token, options); + return verdict.ok ? { kind: "valid" } : { kind: "invalid", reason: verdict.reason }; diff --git a/ts/packages/web-console/src/hooks/use-certificate-trust.ts b/ts/packages/web-console/src/hooks/use-certificate-trust.ts index 5a8efe1f..04522fcb 100644 --- a/ts/packages/web-console/src/hooks/use-certificate-trust.ts +++ b/ts/packages/web-console/src/hooks/use-certificate-trust.ts @@ -74,6 +74,7 @@ export function useCertificateTrust( ); useEffect(() => { const open = refusals.current; + return () => { for (const { refuse } of [...open.values()]) { refuse(); @@ -117,6 +118,7 @@ export function useCertificateTrust( setPrompts((current) => [...current, { key, assessment, decide }]); }); pending.current.set(question, { promptKey: key, decision }); + return decision; }, [], @@ -145,6 +147,7 @@ export function useCertificateTrust( return undefined; } await memory.remember(presented.node, presented.sha256); + return address; }, [memory, askUser], diff --git a/ts/packages/web-console/src/hooks/use-connection-health.ts b/ts/packages/web-console/src/hooks/use-connection-health.ts index ffeedbcc..7fc12f3f 100644 --- a/ts/packages/web-console/src/hooks/use-connection-health.ts +++ b/ts/packages/web-console/src/hooks/use-connection-health.ts @@ -66,6 +66,7 @@ export function useConnectionHealth( } probe(); const timer = setInterval(probe, timing.intervalMs); + return () => { clearInterval(timer); }; diff --git a/ts/packages/web-console/src/hooks/use-grants.ts b/ts/packages/web-console/src/hooks/use-grants.ts index 2d21db83..a84bf342 100644 --- a/ts/packages/web-console/src/hooks/use-grants.ts +++ b/ts/packages/web-console/src/hooks/use-grants.ts @@ -99,6 +99,7 @@ export function useGrants(options: Readonly): GrantsApi { reload(); const stops = [grants.subscribe(reload), revocations.subscribe(reload)]; const timer = setInterval(reload, STATUS_REFRESH_MS); + return () => { lifecycle.cancelled = true; clearInterval(timer); @@ -113,6 +114,7 @@ export function useGrants(options: Readonly): GrantsApi { return minted; } await grants.record("issued", minted.token, clock.now()); + return { ok: true as const, code: encodeGrantCode(minted.token) }; }, [identity, clock, grants], @@ -142,6 +144,7 @@ export function useGrants(options: Readonly): GrantsApi { }; } await grants.record("held", token, clock.now()); + return { ok: true }; }, [identity, clock, grants, revocations], @@ -153,6 +156,7 @@ export function useGrants(options: Readonly): GrantsApi { ): Promise> => { try { const entry = await revocations.revoke(row.claims["token-id"]); + return { ok: true, announced: await announce(entry) }; } catch (error) { return { diff --git a/ts/packages/web-console/src/hooks/use-mesh-session-events.ts b/ts/packages/web-console/src/hooks/use-mesh-session-events.ts index c8569c94..a6e9a7d2 100644 --- a/ts/packages/web-console/src/hooks/use-mesh-session-events.ts +++ b/ts/packages/web-console/src/hooks/use-mesh-session-events.ts @@ -54,6 +54,7 @@ export function useMeshSessionEvents( })); } })(); + return () => { lifecycle.cancelled = true; }; diff --git a/ts/packages/web-console/src/hooks/use-now.ts b/ts/packages/web-console/src/hooks/use-now.ts index df5b06c7..ed4374ac 100644 --- a/ts/packages/web-console/src/hooks/use-now.ts +++ b/ts/packages/web-console/src/hooks/use-now.ts @@ -16,9 +16,11 @@ export function useNow( const timer = setInterval(() => { setNow(clock.now()); }, intervalMs); + return () => { clearInterval(timer); }; }, [clock, active, intervalMs]); + return now; } diff --git a/ts/packages/web-console/src/hooks/use-peer-names.ts b/ts/packages/web-console/src/hooks/use-peer-names.ts index 67c518af..71c61859 100644 --- a/ts/packages/web-console/src/hooks/use-peer-names.ts +++ b/ts/packages/web-console/src/hooks/use-peer-names.ts @@ -23,5 +23,6 @@ export function usePeerNames(): PeerNames { if (names === undefined) { throw new Error("usePeerNames must be used inside PeerNamesProvider"); } + return names; } diff --git a/ts/packages/web-console/src/hooks/use-room-messaging.ts b/ts/packages/web-console/src/hooks/use-room-messaging.ts index f4b2455e..75c47164 100644 --- a/ts/packages/web-console/src/hooks/use-room-messaging.ts +++ b/ts/packages/web-console/src/hooks/use-room-messaging.ts @@ -52,6 +52,7 @@ import { createPersistentRoomTokenStore, } from "../persistent-room-state.js"; import { createRoomRekeyHandler } from "wire-mesh-core/domain/room-rekey"; +import { eachInOrder } from "../in-order.js"; import { bootstrapDmEpoch1, createNoticeWiring } from "../notices.js"; // The domains a direct, WebRTC-negotiated peer session offers -- distinct from the connect-form's own domains list, which governs only what this console offers its relay/hub connection. core/management is implied by manage-request/response itself; core/room is what this session actually exists to speak. @@ -61,6 +62,7 @@ const LOCAL_MESSAGE_ID_BYTE_LENGTH = 16; function randomLocalMessageId(): Uint8Array { const bytes = new Uint8Array(LOCAL_MESSAGE_ID_BYTE_LENGTH); crypto.getRandomValues(bytes); + return bytes; } @@ -115,13 +117,21 @@ function routeOf(entry: Readonly | undefined): { throw new Error("conversation is not connected"); } -export function useRoomMessaging( - identity: IdentityPort, - clock: Readonly, - messageStore: Readonly, - roomStorage: Readonly, - capabilities: Readonly, -): RoomMessaging { +export interface RoomMessagingOptions { + identity: IdentityPort; + clock: Readonly; + messageStore: Readonly; + roomStorage: Readonly; + capabilities: Readonly; +} + +export function useRoomMessaging({ + identity, + clock, + messageStore, + roomStorage, + capabilities, +}: Readonly): RoomMessaging { const { revocation, grants } = capabilities; const [sessions, dispatch] = useReducer( reduceConversations, @@ -232,19 +242,23 @@ export function useRoomMessaging( if (current !== undefined) return current; const restored = await tokenStore.get(roomPath); if (restored !== undefined) tokenRef.current.set(roomPath, restored); + return restored; } useEffect(() => { const unmounted = new AbortController(); + const isUnmounted = (): boolean => unmounted.signal.aborted; void (async (): Promise => { const roomPaths = new Set([ ...(await messageStore.roomPaths()), ...(await tokenStore.rooms()), ]); - for (const roomPath of roomPaths) { + // One room at a time, so the rooms before one whose list fails are already restored. + await eachInOrder([...roomPaths], async (roomPath) => { + if (isUnmounted()) return; const messages = await messageStore.list(roomPath); - if (unmounted.signal.aborted) return; + if (isUnmounted()) return; const participants = participantsOf(roomPath, ownDeviceHex); dispatch({ type: "restored", roomPath, participants, messages }); // The reducer has not applied the action yet, so the notices are read for the participants directly. @@ -256,8 +270,9 @@ export function useRoomMessaging( .then((notices) => { dispatch({ type: "notices", roomPath, notices: [...notices] }); }); - } + }); })(); + return () => { unmounted.abort(); }; @@ -288,6 +303,7 @@ export function useRoomMessaging( firstEpochInFlight.current.delete(roomPath); }); firstEpochInFlight.current.set(roomPath, delivery); + return delivery; } @@ -333,12 +349,12 @@ export function useRoomMessaging( }, }); }, - // The inbound room.rekey path, built lazily per request so the - // recipient's own token is read at its current value -- tokens - // arrive only after the join/grant exchange, and a rekey that - // lands before this side holds one fails closed (no_token) until - // the peer retries, which the DM bootstrap's lower-mints ordering - // makes the steady state anyway. + /* The inbound room.rekey path, built lazily per request so the + recipient's own token is read at its current value -- tokens + arrive only after the join/grant exchange, and a rekey that + lands before this side holds one fails closed (no_token) until + the peer retries, which the DM bootstrap's lower-mints ordering + makes the steady state anyway. */ onRekey: async (incoming) => { const ownToken = await heldToken(roomPath); if (ownToken === undefined) { @@ -346,6 +362,7 @@ export function useRoomMessaging( result: "error", code: "no_token", }); + return; } const handler = createRoomRekeyHandler({ @@ -354,13 +371,14 @@ export function useRoomMessaging( revocation: revocation, ownRoomMemberToken: ownToken, onRekey: async (event) => { - for (const [i, key] of event.contentKeys.entries()) { + // Ascending epoch order, so a failure part way leaves the earlier epochs stored. + await eachInOrder(event.contentKeys, async (key, i) => { await roomKeyStore.set( roomPath, event.keyEpoch - event.contentKeys.length + 1 + i, key, ); - } + }); refreshRoom(roomPath); }, }); @@ -374,11 +392,11 @@ export function useRoomMessaging( connection: Readonly, knownPeerDevice?: DeviceId, ): Promise => { - // The peer's device-id (and with it the DM room path) only resolves - // after the handshake, but the session's onFrame hook exists from the - // start; a late-bound sink bridges the gap: early frames (handshake, - // gossip) find nothing to observe, and once the sink is set every frame - // reaches the notice wiring. + /* The peer's device-id (and with it the DM room path) only resolves + after the handshake, but the session's onFrame hook exists from the + start; a late-bound sink bridges the gap: early frames (handshake, + gossip) find nothing to observe, and once the sink is set every frame + reaches the notice wiring. */ const frameSink: { current?: (frame: Frame) => void } = {}; const accepted = await acceptMeshSession( connection, @@ -397,6 +415,7 @@ export function useRoomMessaging( if (sessions.get(roomPath)?.direct !== undefined) { // Both sides can race to negotiate a connection to each other at once; the second one to arrive here yields to whichever direct session the conversation already has rather than duplicating it. await accepted.close(); + return; } frameSink.current = (frame) => { @@ -468,31 +487,26 @@ export function useRoomMessaging( let token = entry?.token; if (token === undefined) { onPhase("awaiting-approval"); - const joined = await requestToJoin( - session, - roomPath, - target, - await requestTokenFor(roomPath), - ); + const joined = await requestToJoin(session, roomPath, { + targetDevice: target, + requestToken: await requestTokenFor(roomPath), + }); onPhase("sending"); token = joined.token; await rememberToken(roomPath, token); persist(grants.record("held", token, clock.now())); dispatch({ type: "token", roomPath, token }); - // Opportunistic DM bootstrap: once this side holds its join-grant, - // the lower participant mints epoch 1 for the noticeboard. Fire-and- - // observe rather than awaited -- messaging must not block on it. + /* Opportunistic DM bootstrap: once this side holds its join-grant, + the lower participant mints epoch 1 for the noticeboard. Fire-and- + observe rather than awaited -- messaging must not block on it. */ void deliverFirstEpoch(roomPath, token, { session, target }).catch( () => undefined, ); } - const outcome = await sendRoomMessage( - session, - roomPath, - text, + const outcome = await sendRoomMessage(session, roomPath, text, { token, - target, - ); + targetDevice: target, + }); if (outcome.result !== "ok") { throw new Error(`send failed: ${outcome.code}`); } @@ -503,6 +517,7 @@ export function useRoomMessaging( sentAt: clock.now(), }; await messageStore.append(roomPath, stored); + return stored; }, [sessions, clock, messageStore, identity, roomKeyStore, requestTokenFor], @@ -569,21 +584,19 @@ export function useRoomMessaging( const { session, target } = routeOf(entry); let token = entry?.token ?? (await heldToken(roomPath)); if (token === undefined) { - const joined = await requestToJoin( - session, - roomPath, - target, - await requestTokenFor(roomPath), - ); + const joined = await requestToJoin(session, roomPath, { + targetDevice: target, + requestToken: await requestTokenFor(roomPath), + }); token = joined.token; await rememberToken(roomPath, token); persist(grants.record("held", token, clock.now())); dispatch({ type: "token", roomPath, token }); } - // Awaited here (unlike send()'s opportunistic trigger): posting - // without a key would throw inside the wiring -- the bootstrap must - // complete first, and for the lower participant it is exactly one - // wrap + one send. + /* Awaited here (unlike send()'s opportunistic trigger): posting + without a key would throw inside the wiring -- the bootstrap must + complete first, and for the lower participant it is exactly one + wrap + one send. */ await deliverFirstEpoch(roomPath, token, { session, target }); await noticeWiring.post({ room: roomPath, diff --git a/ts/packages/web-console/src/ice-servers.ts b/ts/packages/web-console/src/ice-servers.ts index 04fa605b..7e0f4347 100644 --- a/ts/packages/web-console/src/ice-servers.ts +++ b/ts/packages/web-console/src/ice-servers.ts @@ -1,6 +1,6 @@ -// The ICE servers every peer connection this console makes is configured with, in one place so the offering and answering sides cannot disagree. Two peers on the same network connect over host candidates alone; STUN is what lets a direct connection also form across NATs, by telling each device the public address its traffic appears to come from. -// -// Cloudflare runs a public STUN service that its documentation describes as free and unlimited (https://developers.cloudflare.com/realtime/turn/faq/, service address and ports at https://developers.cloudflare.com/realtime/turn/). A STUN server learns the public address of each device that asks it, and nothing about what the connection carries. There is deliberately no TURN server: when no direct path exists the conversation falls back to the hub relay instead of a second relay service. +/* The ICE servers every peer connection this console makes is configured with, in one place so the offering and answering sides cannot disagree. Two peers on the same network connect over host candidates alone; STUN is what lets a direct connection also form across NATs, by telling each device the public address its traffic appears to come from. + + Cloudflare runs a public STUN service that its documentation describes as free and unlimited (https://developers.cloudflare.com/realtime/turn/faq/, service address and ports at https://developers.cloudflare.com/realtime/turn/). A STUN server learns the public address of each device that asks it, and nothing about what the connection carries. There is deliberately no TURN server: when no direct path exists the conversation falls back to the hub relay instead of a second relay service. */ export const DEFAULT_ICE_SERVERS: readonly RTCIceServer[] = [ { urls: "stun:stun.cloudflare.com:3478" }, diff --git a/ts/packages/web-console/src/in-order.ts b/ts/packages/web-console/src/in-order.ts new file mode 100644 index 00000000..eb384942 --- /dev/null +++ b/ts/packages/web-console/src/in-order.ts @@ -0,0 +1,12 @@ +/** + * Runs `step` for each item, starting each only once the one before it has finished and stopping at the first that rejects, so side effects happen in item order and nothing after a failure runs. + */ +export async function eachInOrder( + items: readonly T[], + step: (item: T, index: number) => Promise, +): Promise { + await items.reduce(async (previous, item, index) => { + await previous; + await step(item, index); + }, Promise.resolve()); +} diff --git a/ts/packages/web-console/src/local-network.ts b/ts/packages/web-console/src/local-network.ts index 3fdb5bc3..5d2c52d5 100644 --- a/ts/packages/web-console/src/local-network.ts +++ b/ts/packages/web-console/src/local-network.ts @@ -27,6 +27,7 @@ function ipv4ToNumber(host: string): number | undefined { } value = value * 2 ** BITS_PER_OCTET + octet; } + return value; } @@ -38,6 +39,7 @@ function inRange(address: number, range: string): boolean { return false; } const size = 2 ** (IPV4_BITS - length); + return Math.floor(address / size) === Math.floor(start / size); } @@ -53,6 +55,7 @@ export function isLocalNetworkHost(host: string): boolean { if (name.includes(":")) { return name === "::1" || /^f[cd]/.test(name) || /^fe[89ab]/.test(name); } + return name === "localhost" || name.endsWith(".local") || !name.includes("."); } @@ -91,11 +94,13 @@ export function browserPermissions(): PermissionQuerier | undefined { if (!("permissions" in navigator)) { return undefined; } + return { query: async (descriptor) => { if (!isPermissionDescriptor(descriptor)) { throw new TypeError("not a permission descriptor"); } + return navigator.permissions.query(descriptor); }, }; @@ -124,6 +129,7 @@ export async function explainLocalNetworkBlock( const { state } = await permissions.query({ name: LOCAL_NETWORK_PERMISSION, }); + return state === "denied" ? `Your browser blocked this page from reaching ${target}, a device on your local network. Allow "Local network access" for this site in the browser's site settings (the icon left of the address), then connect again.` : undefined; diff --git a/ts/packages/web-console/src/message-store.ts b/ts/packages/web-console/src/message-store.ts index daa990b1..f23e2365 100644 --- a/ts/packages/web-console/src/message-store.ts +++ b/ts/packages/web-console/src/message-store.ts @@ -28,12 +28,14 @@ function messageKey( message: Readonly, ): string { const sentAtSegment = String(message.sentAt).padStart(SENT_AT_KEY_WIDTH, "0"); + return `${KEY_PREFIX}/${roomPath}/${sentAtSegment}-${bytesToHex(message.messageId)}`; } /** The room path a storage key belongs to: the key is `message//-`, and an owner-named room path itself contains a `/`, so the path is everything between the prefix and the final segment. */ function roomPathOfKey(key: string): string { const withoutPrefix = key.slice(`${KEY_PREFIX}/`.length); + return withoutPrefix.slice(0, withoutPrefix.lastIndexOf("/")); } @@ -45,6 +47,7 @@ function isStoredMessage(value: unknown): value is StoredMessage { if (typeof value !== "object" || value === null) return false; if (!("direction" in value) || !("text" in value)) return false; if (!("messageId" in value) || !("sentAt" in value)) return false; + return ( isDirection(value.direction) && typeof value.text === "string" && @@ -65,13 +68,16 @@ export function createMessageStore( }, async roomPaths(): Promise { const keys = await storage.keys(`${KEY_PREFIX}/`); + return [...new Set(keys.map(roomPathOfKey))]; }, async list(roomPath): Promise { const keys = (await storage.keys(`${KEY_PREFIX}/${roomPath}/`)).sort(); const messages: StoredMessage[] = []; - for (const key of keys) { - const value = await storage.get(key); + const entries = await Promise.all( + keys.map(async (key) => ({ key, value: await storage.get(key) })), + ); + for (const { key, value } of entries) { if (value === undefined) continue; const decoded: unknown = decode(value, cdeDecodeOptions); if (!isStoredMessage(decoded)) { @@ -79,6 +85,7 @@ export function createMessageStore( } messages.push(decoded); } + return messages; }, }; diff --git a/ts/packages/web-console/src/mint-grant.ts b/ts/packages/web-console/src/mint-grant.ts index 0cb1070a..eef01b97 100644 --- a/ts/packages/web-console/src/mint-grant.ts +++ b/ts/packages/web-console/src/mint-grant.ts @@ -123,12 +123,14 @@ function parseTargetVerb( "the covered verb must look like subsystem:action, for example room:member", }; } + return { ok: true, claim, verb: verb.data }; } function randomTokenId(): Uint8Array { const bytes = new Uint8Array(TOKEN_ID_BYTE_LENGTH); crypto.getRandomValues(bytes); + return bytes; } @@ -220,6 +222,7 @@ export async function mintGrant( : { [target.claim]: target.verb }), ...(barNodes.length === 0 ? {} : { conditions: barNodes }), }); + return verdict.ok ? { ok: true, token: verdict.token } : { ok: false, error: `refused: ${REFUSAL_TEXT[verdict.reason]}` }; diff --git a/ts/packages/web-console/src/name-store.ts b/ts/packages/web-console/src/name-store.ts index d4fd70e2..1cde9edb 100644 --- a/ts/packages/web-console/src/name-store.ts +++ b/ts/packages/web-console/src/name-store.ts @@ -25,19 +25,25 @@ export function createNameStore(storage: Readonly): NameStore { const cleaned = cleanName(name); if (cleaned === undefined) { await storage.delete(key); + return; } await storage.set(key, new Uint8Array(encoder.encode(cleaned))); } + return { async petnames() { const names = new Map(); - for (const key of await storage.keys(PETNAME_PREFIX)) { - const value = await storage.get(key); + const keys = await storage.keys(PETNAME_PREFIX); + const entries = await Promise.all( + keys.map(async (key) => ({ key, value: await storage.get(key) })), + ); + for (const { key, value } of entries) { if (value !== undefined) { names.set(key.slice(PETNAME_PREFIX.length), decoder.decode(value)); } } + return names; }, async setPetname(deviceHex, name) { @@ -45,6 +51,7 @@ export function createNameStore(storage: Readonly): NameStore { }, async selfName() { const value = await storage.get(SELF_NAME_KEY); + return value === undefined ? undefined : decoder.decode(value); }, async setSelfName(name) { diff --git a/ts/packages/web-console/src/notices.ts b/ts/packages/web-console/src/notices.ts index e6eee98e..fb9d99b9 100644 --- a/ts/packages/web-console/src/notices.ts +++ b/ts/packages/web-console/src/notices.ts @@ -111,6 +111,7 @@ export function createNoticeWiring( function handleFrame(frame: Frame, from: Readonly): void { if (frame.type === "data-have") { void requestCatchUp(from, frame.peer).catch(() => undefined); + return; } if (frame.type === "data-entries") { @@ -123,6 +124,7 @@ export function createNoticeWiring( } onChange(); })(); + return; } if (frame.type === "data-request") { @@ -153,6 +155,7 @@ export function createNoticeWiring( const perPeer = await Promise.all( peers.map(async (peer) => board.readPeerNotices(peer, room)), ); + return [...own, ...perPeer.flat()]; }, async sync(sender, peers) { @@ -219,9 +222,9 @@ export async function bootstrapDmEpoch1( delivery, } = options; const FIRST_EPOCH = 1; - // Only the lower participant mints epoch 1: dmRoomPath embeds the sorted - // pair (lower + "+" + higher), so this side mints iff its own hex sorts - // strictly below the peer's -- the participant names ARE the path halves. + /* Only the lower participant mints epoch 1: dmRoomPath embeds the sorted + pair (lower + "+" + higher), so this side mints iff its own hex sorts + strictly below the peer's -- the participant names ARE the path halves. */ const lower = roomPath.split("+")[0] ?? ""; if (lower === "" || deviceIdToHex(identity.deviceId) !== lower) { return; diff --git a/ts/packages/web-console/src/peer-names.ts b/ts/packages/web-console/src/peer-names.ts index 66948d47..ec9ca600 100644 --- a/ts/packages/web-console/src/peer-names.ts +++ b/ts/packages/web-console/src/peer-names.ts @@ -39,6 +39,7 @@ const UNSAFE_NAME_CHARACTERS = export function cleanName(name: string): string | undefined { const trimmed = name.replace(UNSAFE_NAME_CHARACTERS, "").trim(); if (trimmed === "") return undefined; + return Array.from(trimmed).slice(0, MAX_NAME_LENGTH).join("").trimEnd(); } @@ -49,6 +50,7 @@ export function selfAssertedName( const claim: unknown = advert[SELF_ADVERT_KEY]; if (typeof claim !== "object" || claim === null) return undefined; if (!("name" in claim) || typeof claim.name !== "string") return undefined; + return cleanName(claim.name); } @@ -81,6 +83,7 @@ export function labelPeer( matchesPetname: heldPetnames.has(selfName), }; } + return { primary: shortId(deviceHex), secondary: undefined, @@ -94,5 +97,6 @@ export function labelText(label: Readonly): string { if (label.source === "self" && label.secondary !== undefined) { return `${label.primary} (${label.secondary})`; } + return label.primary; } diff --git a/ts/packages/web-console/src/persistent-room-state.ts b/ts/packages/web-console/src/persistent-room-state.ts index b9d55690..e6956ae3 100644 --- a/ts/packages/web-console/src/persistent-room-state.ts +++ b/ts/packages/web-console/src/persistent-room-state.ts @@ -46,6 +46,7 @@ export function createPersistentRoomKeyStore( currentEpoch: async (room) => { const keys = (await storage.keys(`${KEY_PREFIX}${room}/`)).sort(); const last = keys.at(-1); + return last === undefined ? undefined : Number(last.slice(last.lastIndexOf("/") + 1)); @@ -78,6 +79,7 @@ export function createPersistentRoomTokenStore( if (verdict.ok) return parsed.data; } await storage.delete(key); + return undefined; }, set: async (room, token) => { diff --git a/ts/packages/web-console/src/pwa-manifest.ts b/ts/packages/web-console/src/pwa-manifest.ts index 15319de4..c309d48c 100644 --- a/ts/packages/web-console/src/pwa-manifest.ts +++ b/ts/packages/web-console/src/pwa-manifest.ts @@ -1,6 +1,8 @@ import type { ManifestOptions } from "vite-plugin-pwa"; -// Deployable from any static origin (see README), so start_url/scope are relative rather than root-absolute: an install served from a subpath still resolves both against its own location. +/** + * Deployable from any static origin (see README), so start_url/scope are relative rather than root-absolute: an install served from a subpath still resolves both against its own location. + */ export const pwaManifest: Partial = { name: "wire-mesh console", short_name: "wire-mesh", diff --git a/ts/packages/web-console/src/request-demux.ts b/ts/packages/web-console/src/request-demux.ts index 093133b9..7b5567e3 100644 --- a/ts/packages/web-console/src/request-demux.ts +++ b/ts/packages/web-console/src/request-demux.ts @@ -24,12 +24,14 @@ export function createRequestDemux( queues.get(request.command.verb)?.push(request); } })(); + return { stream: (verb) => { const queue = queues.get(verb); if (queue === undefined) { throw new Error(`the demux was not built to carry verb ${verb}`); } + return queue.stream; }, }; diff --git a/ts/packages/web-console/src/request-permission.ts b/ts/packages/web-console/src/request-permission.ts index 6646c6d7..80baaa79 100644 --- a/ts/packages/web-console/src/request-permission.ts +++ b/ts/packages/web-console/src/request-permission.ts @@ -20,21 +20,31 @@ export async function heldRequestToken( capability: string, scope: Readonly, ): Promise { - for (const record of await grants.list()) { + const candidates = (await grants.list()).filter((record) => { const claims = record.claims; if ( record.direction !== "held" || claims.capability !== MANAGE_REQUEST_CAPABILITY ) { - continue; + return false; } const covered = claims["requests-capability"]; - if (covered !== undefined && covered !== capability) continue; - if (!scopeNarrows(claims.scope, scope)) continue; + if (covered !== undefined && covered !== capability) return false; + + return scopeNarrows(claims.scope, scope); + }); + // In order, stopping at the first valid candidate: later ones are never judged. + const firstValid = async ( + remaining: readonly (typeof candidates)[number][], + ): Promise => { + const [record, ...rest] = remaining; + if (record === undefined) return undefined; const status = await grantStatus(record.token, verification); - if (status.kind === "valid") return record.token; - } - return undefined; + + return status.kind === "valid" ? record.token : firstValid(rest); + }; + + return firstValid(candidates); } /** What a refusal of a capability request means to the person, telling a receiver's policy (it gates requests and this one did not qualify) apart from a person's no. */ diff --git a/ts/packages/web-console/src/revocation-store.ts b/ts/packages/web-console/src/revocation-store.ts index 8af2fe68..185aa051 100644 --- a/ts/packages/web-console/src/revocation-store.ts +++ b/ts/packages/web-console/src/revocation-store.ts @@ -66,6 +66,7 @@ async function loadStored( return "it is not a revocation entry"; } const verdict = await view.record(parsed.data, { identity }); + return verdict.ok ? undefined : `it no longer verifies (${verdict.reason})`; } @@ -88,18 +89,23 @@ export async function createRevocationStore( await storage.set(key, new Uint8Array(encode(entry, cdeEncodeOptions))); for (const listener of listeners) listener(); } + return verdict; } - let unreadable: readonly UnreadableRevocation[] = []; - for (const key of await storage.keys(KEY_PREFIX)) { - const value = await storage.get(key); - if (value === undefined) continue; - const reason = await loadStored(view, identity, value); - if (reason !== undefined) { - unreadable = [...unreadable, { key, reason }]; - } - } + const storedKeys = await storage.keys(KEY_PREFIX); + const loaded = await Promise.all( + storedKeys.map(async (key) => { + const value = await storage.get(key); + if (value === undefined) return undefined; + const reason = await loadStored(view, identity, value); + + return reason === undefined ? undefined : { key, reason }; + }), + ); + let unreadable: readonly UnreadableRevocation[] = loaded.filter( + (entry) => entry !== undefined, + ); return { view, @@ -111,6 +117,7 @@ export async function createRevocationStore( }, subscribe(listener) { listeners.add(listener); + return () => { listeners.delete(listener); }; @@ -128,6 +135,7 @@ export async function createRevocationStore( `the revocation this device signed does not verify (${verdict.reason})`, ); } + return entry; }, }; diff --git a/ts/packages/web-console/src/room-client.ts b/ts/packages/web-console/src/room-client.ts index 2dc0285b..b49697c9 100644 --- a/ts/packages/web-console/src/room-client.ts +++ b/ts/packages/web-console/src/room-client.ts @@ -43,6 +43,7 @@ const MESSAGE_ID_BYTE_LENGTH = 16; function randomMessageId(): Uint8Array { const bytes = new Uint8Array(MESSAGE_ID_BYTE_LENGTH); crypto.getRandomValues(bytes); + return bytes; } @@ -51,6 +52,7 @@ const TOKEN_ID_BYTE_LENGTH = 16; function randomTokenId(): Uint8Array { const bytes = new Uint8Array(TOKEN_ID_BYTE_LENGTH); crypto.getRandomValues(bytes); + return bytes; } @@ -77,6 +79,12 @@ export function buildRoomSendCommand( }; } +/** Who a room message is sent as and where it goes: the sender's own room:member grant, and the relay target when it is not the session's own direct connection. */ +export interface RoomMessageRoute { + token: CapabilityToken; + targetDevice?: DeviceId | undefined; +} + /** * Sends a room.send (a DM is just a room-path variant, not a separate verb, so this covers both). token is this side's own room:member grant for roomPath, minted by the room's owner (room.join) or pushed by it (room.invite). targetDevice routes the request via a relay-connect pairing rather than directly over the session's own Connection, the same targetDevice semantics sendManageRequest itself already defines -- omit it for a session that already is the direct connection to the room's other member (web-console's own primary case, one negotiated WebRTC Connection per peer). */ @@ -84,10 +92,10 @@ export async function sendRoomMessage( session: Readonly>, roomPath: string, text: string, - token: CapabilityToken, - targetDevice?: DeviceId, + { token, targetDevice }: Readonly, ): Promise { const command = buildRoomSendCommand(text, randomMessageId(), Date.now()); + return session.sendManageRequest( command, { kind: "room", path: roomPath }, @@ -96,6 +104,12 @@ export async function sendRoomMessage( ); } +export interface RoomJoinOptions { + targetDevice?: DeviceId | undefined; + /** A held manage:request to present, for a receiver that gates requests (wire-mesh#324). Absent keeps the join ungated, as a first contact from a stranger must be. */ + requestToken?: CapabilityToken | undefined; +} + export interface RoomJoinResult { token: CapabilityToken; members: DeviceId[]; @@ -107,9 +121,7 @@ export interface RoomJoinResult { export async function requestToJoin( session: Readonly>, roomPath: string, - targetDevice?: DeviceId, - /** A held manage:request to present, for a receiver that gates requests (wire-mesh#324). Absent keeps the join ungated, as a first contact from a stranger must be. */ - requestToken?: CapabilityToken, + { targetDevice, requestToken }: Readonly = {}, ): Promise { let outcome; try { @@ -130,22 +142,28 @@ export async function requestToJoin( if (!parsed.success) { throw new Error(`room.join response for ${roomPath} was malformed`); } + return { token: parsed.data["granted-token"], members: parsed.data.members.map((member: RoomMember) => member.device), }; } +/** The room:member grant an inviter mints: for which room and bearer, until when, and how far the bearer may delegate it onwards. */ +export interface RoomInvite { + roomPath: string; + invitee: DeviceId; + expires: number; + delegationsRemaining?: number | undefined; +} + /** * Mints a fresh room:member grant for invitee, ready to hand to sendRoomInvite -- the room-specific specialization of the generic mintCapabilityToken, the same way requestToJoin/handleRoomJoin already specialize capability-request.ts's own generic primitives to core/room. There is no equivalent minting call already living in this file to mirror (the accept path's own minting moved into capability-request.ts's createCapabilityRequestHandler when #78 generalized room.join, so room-client.ts itself no longer calls mintCapabilityToken anywhere else): capability-grant is a push of an ALREADY-minted token, so unlike the pull side, minting has to happen here, on the inviter's own side, before there is anything to send at all. */ export async function mintRoomInviteGrant( identity: Readonly, clock: Readonly, - roomPath: string, - invitee: DeviceId, - expires: number, - delegationsRemaining?: number, + { roomPath, invitee, expires, delegationsRemaining }: Readonly, ): Promise { const verdict = await mintCapabilityToken({ identity, @@ -162,6 +180,7 @@ export async function mintRoomInviteGrant( `failed to mint an invite grant for ${roomPath} (${verdict.reason})`, ); } + return verdict.token; } @@ -251,6 +270,7 @@ export function createRoomRouter( const roomPath = incoming.scope.path; if (roomPath === undefined || incoming.token === undefined) { await incoming.respond({ result: "error", code: "unauthorized" }); + return; } const verdict = await verifyRoomToken(incoming.token, { @@ -262,6 +282,7 @@ export function createRoomRouter( }); if (!verdict.ok) { await incoming.respond({ result: "error", code: "unauthorized" }); + return; } await incoming.respond({ result: "ok" }); @@ -291,6 +312,7 @@ export function createRoomRouter( ...(decision.reason !== undefined ? { reason: decision.reason } : {}), }; await event.decide(generic); + return; } const members = [ @@ -322,6 +344,7 @@ export function createRoomRouter( // Both unreachable in practice -- handleRoomJoin below already refuses (missing_scope_path/unsupported_verb, matching this router's own pre-rewiring codes exactly) before ever calling this handler when either precondition fails. Kept because CapabilityGrantRequestEvent's own scope/handlers types don't encode either precondition structurally, so TS cannot narrow across this callback boundary on its own. if (roomPath === undefined || onJoinRequest === undefined) { void event.decide({ kind: "reject", reason: "unsupported verb" }); + return; } onJoinRequest({ @@ -338,10 +361,12 @@ export function createRoomRouter( const roomPath = incoming.scope.path; if (roomPath === undefined) { await incoming.respond({ result: "error", code: "missing_scope_path" }); + return; } if (handlers.onJoinRequest === undefined) { await incoming.respond({ result: "error", code: "unsupported_verb" }); + return; } await handleCapabilityGrantRequest({ @@ -387,10 +412,12 @@ export function createRoomRouter( const roomPath = incoming.scope.path; if (roomPath === undefined) { await incoming.respond({ result: "error", code: "missing_scope_path" }); + return; } if (handlers.onRoomInvite === undefined) { await incoming.respond({ result: "error", code: "unsupported_verb" }); + return; } await handleCapabilityGrant(incoming); diff --git a/ts/packages/web-console/src/search.ts b/ts/packages/web-console/src/search.ts index a46fb8c6..168cee57 100644 --- a/ts/packages/web-console/src/search.ts +++ b/ts/packages/web-console/src/search.ts @@ -28,6 +28,7 @@ export function queryTerms(query: string): string[] { function matches(text: string, terms: readonly string[]): boolean { const haystack = text.toLowerCase(); + return terms.every((term) => haystack.includes(term)); } @@ -74,5 +75,6 @@ export function searchConversations( } }); } + return results.sort((a, b) => b.at - a.at).slice(0, MAX_SEARCH_RESULTS); } diff --git a/ts/packages/web-console/src/webrtc-negotiation.ts b/ts/packages/web-console/src/webrtc-negotiation.ts index 6b59639e..ca027542 100644 --- a/ts/packages/web-console/src/webrtc-negotiation.ts +++ b/ts/packages/web-console/src/webrtc-negotiation.ts @@ -110,6 +110,7 @@ export async function mintOfferToken( if (!verdict.ok) { throw new Error(`could not mint a webrtc:signal token: ${verdict.reason}`); } + return verdict.token; } @@ -152,6 +153,7 @@ export function createWebrtcNegotiator( const authorized = await authorizeIncomingOffer(incoming, verifyOptions); if (!authorized) { await incoming.respond({ result: "error", code: "unauthorized" }); + return; } const negotiationId = offer["negotiation-id"]; @@ -163,8 +165,9 @@ export function createWebrtcNegotiator( void sendIceCandidate(negotiationId, event.candidate); }); pc.addEventListener("datachannel", (event) => { - event.channel.binaryType = "arraybuffer"; - wireOpenChannel(event.channel, options.onIncomingConnection); + const { channel } = event; + channel.binaryType = "arraybuffer"; + wireOpenChannel(channel, options.onIncomingConnection); }); await pc.setRemoteDescription({ type: "offer", sdp: offer.sdp }); const answer = await pc.createAnswer(); @@ -257,6 +260,7 @@ export function createWebrtcNegotiator( pc.close(); throw new Error(`webrtc offer rejected: ${outcome.code}`); } + return channelOpen; }, }; diff --git a/ts/packages/web-console/test/App.revocations.unit.test.tsx b/ts/packages/web-console/test/App.revocations.unit.test.tsx index 3b72dcc2..f57c9215 100644 --- a/ts/packages/web-console/test/App.revocations.unit.test.tsx +++ b/ts/packages/web-console/test/App.revocations.unit.test.tsx @@ -65,6 +65,7 @@ describe("App revocations", () => { await vi.waitFor(() => { const responses = rootSocket.sent.slice(sentBefore).flatMap((data) => { const frame = decodeMessage(data); + return frame.type === "manage-response" ? [frame] : []; }); expect(responses).toEqual([ diff --git a/ts/packages/web-console/test/App.unit.test.tsx b/ts/packages/web-console/test/App.unit.test.tsx index cacf1177..4bed9708 100644 --- a/ts/packages/web-console/test/App.unit.test.tsx +++ b/ts/packages/web-console/test/App.unit.test.tsx @@ -105,6 +105,7 @@ function discoveredRow(deviceHex: string): HTMLElement { if (row === null) { throw new Error(`expected a table row for device ${deviceHex}`); } + return row; } @@ -116,8 +117,10 @@ function publishedSelfNames( return socket.sent.slice(from).flatMap((data) => { const frame = decodeMessage(data); if (frame.type !== "gossip") return []; + return frame.peers.flatMap((advert) => { const name = selfAssertedName(advert); + return name === undefined ? [] : [name]; }); }); @@ -130,6 +133,7 @@ function gossipedClaims( ): (string | undefined)[] { return socket.sent.slice(from).flatMap((data) => { const frame = decodeMessage(data); + return frame.type === "gossip" ? frame.peers.map((advert) => selfAssertedName(advert)) : []; @@ -677,6 +681,7 @@ describe("App", () => { ), ).toBeInTheDocument(); }); + return discoveredRow(pinnedGossipDeviceHex); } diff --git a/ts/packages/web-console/test/ConnectionHealth.unit.test.tsx b/ts/packages/web-console/test/ConnectionHealth.unit.test.tsx index 8135418b..8927697f 100644 --- a/ts/packages/web-console/test/ConnectionHealth.unit.test.tsx +++ b/ts/packages/web-console/test/ConnectionHealth.unit.test.tsx @@ -37,6 +37,7 @@ function renderReconnecting(attempt: number, now: number): HTMLElement { /> , ); + return screen.getByTestId("reconnect-status"); } diff --git a/ts/packages/web-console/test/ConnectionPanel.unit.test.tsx b/ts/packages/web-console/test/ConnectionPanel.unit.test.tsx index 2ce0595e..8caa0d95 100644 --- a/ts/packages/web-console/test/ConnectionPanel.unit.test.tsx +++ b/ts/packages/web-console/test/ConnectionPanel.unit.test.tsx @@ -36,6 +36,7 @@ async function failingSession(): Promise> { void session .connect(LOCAL_ADDRESS, ["core/management"]) .catch(() => undefined); + return session; } diff --git a/ts/packages/web-console/test/ConversationList.unit.test.tsx b/ts/packages/web-console/test/ConversationList.unit.test.tsx index a966fce5..3d784379 100644 --- a/ts/packages/web-console/test/ConversationList.unit.test.tsx +++ b/ts/packages/web-console/test/ConversationList.unit.test.tsx @@ -15,6 +15,7 @@ function conversation( overrides: Partial = {}, ): ConversationView { const peer = peerDigit.repeat(DEVICE_ID_HEX_LENGTH); + return { roomPath: `${"1".repeat(DEVICE_ID_HEX_LENGTH)}+${peer}`, participants: [peer], @@ -29,10 +30,12 @@ function conversation( }; } -function renderList( - props: Partial> = {}, +function renderList({ store = memoryNameStore(), -): ReturnType { + ...props +}: Partial> & { + store?: ReturnType; +} = {}): ReturnType { return render( { const store = memoryNameStore(); await store.setPetname("2".repeat(DEVICE_ID_HEX_LENGTH), "Ada"); - renderList( - { conversations: [conversation("2"), conversation("3")] }, + renderList({ + conversations: [conversation("2"), conversation("3")], store, - ); + }); await screen.findByText("Ada"); expect(screen.getByText("333333333333")).toBeInTheDocument(); diff --git a/ts/packages/web-console/test/IdentityPanel.unit.test.tsx b/ts/packages/web-console/test/IdentityPanel.unit.test.tsx index 4a1e0178..155e9b74 100644 --- a/ts/packages/web-console/test/IdentityPanel.unit.test.tsx +++ b/ts/packages/web-console/test/IdentityPanel.unit.test.tsx @@ -41,6 +41,7 @@ async function renderPanel(): Promise { /> , ); + return { storage, identity, save, reload }; } diff --git a/ts/packages/web-console/test/IdentitySection.unit.test.tsx b/ts/packages/web-console/test/IdentitySection.unit.test.tsx index 36b14848..9cb1cc0f 100644 --- a/ts/packages/web-console/test/IdentitySection.unit.test.tsx +++ b/ts/packages/web-console/test/IdentitySection.unit.test.tsx @@ -103,6 +103,7 @@ async function codeFrom( { identity: issuer, clock }, ); if (!minted.ok) throw new Error(minted.error); + return encodeGrantCode(minted.token); } diff --git a/ts/packages/web-console/test/MintGrantForm.unit.test.tsx b/ts/packages/web-console/test/MintGrantForm.unit.test.tsx index 977439ea..6c89eef2 100644 --- a/ts/packages/web-console/test/MintGrantForm.unit.test.tsx +++ b/ts/packages/web-console/test/MintGrantForm.unit.test.tsx @@ -57,6 +57,7 @@ async function heldRow(): Promise { if (!minted.ok) throw new Error(minted.error); const claims = decodeGrantClaims(minted.token); if (claims === undefined) throw new Error("minted token has no claims"); + return { tokenId: "held-1", direction: "held", @@ -84,6 +85,7 @@ async function heldGrantRow(): Promise { if (!minted.ok) throw new Error(minted.error); const claims = decodeGrantClaims(minted.token); if (claims === undefined) throw new Error("minted token has no claims"); + return { tokenId: "held-grant-1", direction: "held", diff --git a/ts/packages/web-console/test/NoticesView.unit.test.tsx b/ts/packages/web-console/test/NoticesView.unit.test.tsx index f1e2631d..6a12be85 100644 --- a/ts/packages/web-console/test/NoticesView.unit.test.tsx +++ b/ts/packages/web-console/test/NoticesView.unit.test.tsx @@ -28,9 +28,9 @@ describe("NoticesView", () => { }); it("renders nothing for an empty notice list", () => { - // Mantine injects its own
    {devices.map((hex) => ( diff --git a/ts/packages/web-console/test/PeerNamesProvider.unit.test.tsx b/ts/packages/web-console/test/PeerNamesProvider.unit.test.tsx index 3cb60179..5a6a2bc8 100644 --- a/ts/packages/web-console/test/PeerNamesProvider.unit.test.tsx +++ b/ts/packages/web-console/test/PeerNamesProvider.unit.test.tsx @@ -59,6 +59,7 @@ describe("PeerNamesProvider", () => { let names: PeerNames | undefined; function Capture(): null { names = usePeerNames(); + return null; } render( diff --git a/ts/packages/web-console/test/RoomPanel.unit.test.tsx b/ts/packages/web-console/test/RoomPanel.unit.test.tsx index 277839e3..3e2479d7 100644 --- a/ts/packages/web-console/test/RoomPanel.unit.test.tsx +++ b/ts/packages/web-console/test/RoomPanel.unit.test.tsx @@ -36,10 +36,12 @@ function view(overrides: Partial = {}): ConversationView { }; } -function renderPanel( - props: Partial> = {}, +function renderPanel({ store = memoryNameStore(), -): ReturnType { + ...props +}: Partial> & { + store?: ReturnType; +} = {}): ReturnType { return render( { const store = memoryNameStore(); const peer = "2".repeat(DEVICE_ID_HEX_LENGTH); await store.setPetname(peer, "Ada"); - renderPanel( - { - view: view({ - participants: [peer], - pendingJoinRequest: { requesterHex: peer, decide: vi.fn() }, - }), - }, + renderPanel({ + view: view({ + participants: [peer], + pendingJoinRequest: { requesterHex: peer, decide: vi.fn() }, + }), store, - ); + }); await screen.findAllByText("Ada"); expect(screen.getByText(/wants to message you/)).toHaveTextContent( diff --git a/ts/packages/web-console/test/RttSparkline.unit.test.tsx b/ts/packages/web-console/test/RttSparkline.unit.test.tsx index b70c9b47..4bd65e19 100644 --- a/ts/packages/web-console/test/RttSparkline.unit.test.tsx +++ b/ts/packages/web-console/test/RttSparkline.unit.test.tsx @@ -19,6 +19,7 @@ function renderSparkline(samples: readonly RttSample[]): HTMLElement { , ); + return screen.getByTestId("rtt-sparkline"); } diff --git a/ts/packages/web-console/test/SearchPanel.unit.test.tsx b/ts/packages/web-console/test/SearchPanel.unit.test.tsx index 5dfc2cc9..ca6f974f 100644 --- a/ts/packages/web-console/test/SearchPanel.unit.test.tsx +++ b/ts/packages/web-console/test/SearchPanel.unit.test.tsx @@ -39,8 +39,13 @@ function conversation(text: string): ConversationView { function renderSearch( conversations: readonly ConversationView[], - onSelect: (roomPath: string) => void = () => undefined, - store = memoryNameStore(), + { + onSelect = () => undefined, + store = memoryNameStore(), + }: { + onSelect?: (roomPath: string) => void; + store?: ReturnType; + } = {}, ): void { render( @@ -83,7 +88,7 @@ describe("SearchPanel", () => { await store.setPetname(PEER, "Ada"); const onSelect = vi.fn<(roomPath: string) => void>(); const view = conversation("lunch at the harbour"); - renderSearch([view], onSelect, store); + renderSearch([view], { onSelect, store }); search("harbour"); diff --git a/ts/packages/web-console/test/StackedTable.unit.test.tsx b/ts/packages/web-console/test/StackedTable.unit.test.tsx index 112efd10..af7cca1f 100644 --- a/ts/packages/web-console/test/StackedTable.unit.test.tsx +++ b/ts/packages/web-console/test/StackedTable.unit.test.tsx @@ -19,6 +19,7 @@ function renderTable(): HTMLElement { /> , ); + return container; } diff --git a/ts/packages/web-console/test/UnreadableRevocations.unit.test.tsx b/ts/packages/web-console/test/UnreadableRevocations.unit.test.tsx index 3bb62ddb..942674b8 100644 --- a/ts/packages/web-console/test/UnreadableRevocations.unit.test.tsx +++ b/ts/packages/web-console/test/UnreadableRevocations.unit.test.tsx @@ -43,6 +43,7 @@ async function storeWithBadEntry(): Promise< > { const storage = createMemoryStorage(); await storage.set(BAD_KEY, new Uint8Array([CBOR_ZERO])); + return createRevocationStore({ storage, identity: own, clock }); } diff --git a/ts/packages/web-console/test/app-harness.tsx b/ts/packages/web-console/test/app-harness.tsx index 7a2cf06a..f6061bc2 100644 --- a/ts/packages/web-console/test/app-harness.tsx +++ b/ts/packages/web-console/test/app-harness.tsx @@ -30,10 +30,12 @@ export const fixedClock: Clock = { now: () => 0 }; export function fakeMessageStore(): MessageStore { const stored = new Map(); + return { async append(roomPath, message): Promise { const existing = stored.get(roomPath) ?? []; stored.set(roomPath, [...existing, message]); + return Promise.resolve(); }, async list(roomPath): Promise { @@ -59,6 +61,7 @@ class TrackedFakeWebSocket extends FakeWebSocket { export function dismissedPreferencesStorage(): KeyValueStorage { const storage = createMemoryStorage(); void createPreferencesStore(storage).setIntroDismissed(true); + return storage; } @@ -86,6 +89,7 @@ export function renderApp( capabilities: suppliedCapabilities = currentCapabilities(), identityBackupStorage = createMemoryStorage(), } = options; + return render( { } rootSocket.emitOpen(); await screen.findByText(/^connected/); + return rootSocket; } @@ -135,6 +140,7 @@ export function appIdentity(): IdentityPort { if (identity === undefined) { throw new Error("installAppHarness must be called before the tests run"); } + return identity; } @@ -143,6 +149,7 @@ export function currentCapabilities(): TestCapabilities { if (capabilities === undefined) { throw new Error("installAppHarness must be called before the tests run"); } + return capabilities; } diff --git a/ts/packages/web-console/test/capability-services.ts b/ts/packages/web-console/test/capability-services.ts index 7492483b..6db57c62 100644 --- a/ts/packages/web-console/test/capability-services.ts +++ b/ts/packages/web-console/test/capability-services.ts @@ -25,6 +25,7 @@ export async function testCapabilities( identity, clock, }); + return { grants, revocations, diff --git a/ts/packages/web-console/test/certificate-trust.unit.test.ts b/ts/packages/web-console/test/certificate-trust.unit.test.ts index 35def859..3d2d2504 100644 --- a/ts/packages/web-console/test/certificate-trust.unit.test.ts +++ b/ts/packages/web-console/test/certificate-trust.unit.test.ts @@ -80,6 +80,7 @@ describe("observeCertificateChanges", () => { changes.push(change); }, ); + return { memory: observeCertificateChanges(base, onChange), changes }; } diff --git a/ts/packages/web-console/test/conversations.unit.test.ts b/ts/packages/web-console/test/conversations.unit.test.ts index 59924003..97b725ef 100644 --- a/ts/packages/web-console/test/conversations.unit.test.ts +++ b/ts/packages/web-console/test/conversations.unit.test.ts @@ -84,6 +84,7 @@ function conversation( ): ConversationInternal { const found = state.get(roomPath); if (found === undefined) throw new Error(`no conversation ${roomPath}`); + return found; } diff --git a/ts/packages/web-console/test/e2e-containers-offline/offline.e2e.spec.ts b/ts/packages/web-console/test/e2e-containers-offline/offline.e2e.spec.ts index f02c8429..4e06fe2a 100644 --- a/ts/packages/web-console/test/e2e-containers-offline/offline.e2e.spec.ts +++ b/ts/packages/web-console/test/e2e-containers-offline/offline.e2e.spec.ts @@ -16,6 +16,7 @@ function requiredEnvironment(name: string): string { if (value === undefined || value === "") { throw new Error(`${name} must be set`); } + return value; } @@ -30,8 +31,10 @@ function launchArguments(): string[] { } if (process.env.TRUST_LAN_NODE === "spki") { const spki = readFileSync(LAN_NODE_SPKI_FILE, "utf8").trim(); + return [`--ignore-certificate-errors-spki-list=${spki}`]; } + return []; } @@ -50,6 +53,7 @@ async function openConsole(): Promise { console.log(`[${role} console] ${message.text()}`); }); await page.goto(cloudOrigin); + return page; } @@ -74,11 +78,15 @@ if (phase === "warm") { async () => page.evaluate(async () => { const names = await caches.keys(); - let entries = 0; - for (const name of names) { - entries += (await (await caches.open(name)).keys()).length; - } - return entries; + const counts = await Promise.all( + names.map(async (name) => { + const cache = await caches.open(name); + + return (await cache.keys()).length; + }), + ); + + return counts.reduce((total, count) => total + count, 0); }), { timeout: STEP_TIMEOUT_MS }, ) diff --git a/ts/packages/web-console/test/e2e-containers/two-containers.e2e.spec.ts b/ts/packages/web-console/test/e2e-containers/two-containers.e2e.spec.ts index ab8a98cd..94fa00f9 100644 --- a/ts/packages/web-console/test/e2e-containers/two-containers.e2e.spec.ts +++ b/ts/packages/web-console/test/e2e-containers/two-containers.e2e.spec.ts @@ -11,6 +11,7 @@ function requiredEnvironment(name: string): string { if (value === undefined || value === "") { throw new Error(`${name} must be set`); } + return value; } diff --git a/ts/packages/web-console/test/e2e/identity-grants.e2e.spec.ts b/ts/packages/web-console/test/e2e/identity-grants.e2e.spec.ts index 0169336b..7233a936 100644 --- a/ts/packages/web-console/test/e2e/identity-grants.e2e.spec.ts +++ b/ts/packages/web-console/test/e2e/identity-grants.e2e.spec.ts @@ -15,11 +15,13 @@ async function newConsole( throw error; }); await page.goto(appUrl); + return page; } async function ownDeviceId(page: Readonly): Promise { await page.getByRole("button", { name: "Identity" }).click(); + return (await page.getByTestId("own-device-id").textContent()) ?? ""; } diff --git a/ts/packages/web-console/test/e2e/notices-offline.e2e.spec.ts b/ts/packages/web-console/test/e2e/notices-offline.e2e.spec.ts index 9f4e8f51..2ae4fdaa 100644 --- a/ts/packages/web-console/test/e2e/notices-offline.e2e.spec.ts +++ b/ts/packages/web-console/test/e2e/notices-offline.e2e.spec.ts @@ -33,6 +33,7 @@ async function openConsole(context: Readonly): Promise { ); await page.getByLabel("Node").fill(MAILBOX_RELAY_ADDRESS); await page.getByRole("button", { name: "Connect" }).click(); + return page; } diff --git a/ts/packages/web-console/test/e2e/onboarding-mobile.e2e.spec.ts b/ts/packages/web-console/test/e2e/onboarding-mobile.e2e.spec.ts index ecc1499a..298e35ff 100644 --- a/ts/packages/web-console/test/e2e/onboarding-mobile.e2e.spec.ts +++ b/ts/packages/web-console/test/e2e/onboarding-mobile.e2e.spec.ts @@ -50,10 +50,12 @@ test("the intro shows once per device, and the directory stacks into cards on a await expect(phone.getByTestId("onboarding-intro")).toBeHidden(); await peer.goto(appUrl); - for (const page of [phone, peer]) { - await page.getByLabel("Node").fill(RELAY_ADDRESS); - await page.getByRole("button", { name: "Connect" }).click(); - } + await Promise.all( + [phone, peer].map(async (page) => { + await page.getByLabel("Node").fill(RELAY_ADDRESS); + await page.getByRole("button", { name: "Connect" }).click(); + }), + ); const messageButton = phone.getByRole("button", { name: "Message" }); await expect(messageButton).toBeVisible({ timeout: DIRECTORY_TIMEOUT_MS }); diff --git a/ts/packages/web-console/test/e2e/room-messaging.e2e.spec.ts b/ts/packages/web-console/test/e2e/room-messaging.e2e.spec.ts index 9535e9ac..4ae4d2b1 100644 --- a/ts/packages/web-console/test/e2e/room-messaging.e2e.spec.ts +++ b/ts/packages/web-console/test/e2e/room-messaging.e2e.spec.ts @@ -1,6 +1,6 @@ -// A real, checked-in end-to-end test for the actual console UI's room-messaging flow (wire-mesh#101) -- webrtc.spec.ts already proves the raw WebRTC/relay signaling works against a bare test harness page; this drives the real production App/ConnectionPanel/RoomPanel components instead, the way a person actually uses the console: two independent browser instances, each its own persisted identity, connect to a real relay, one clicks "Message" on the other's directory row and sends a message, the other approves the resulting request, and the message renders on the receiving side -- with no manually copy-pasted token anywhere in the flow. -// -// A conversation opens at once over the hub both browsers are connected to, and a direct WebRTC connection is negotiated in the background and takes over when it opens. Two cases pin both halves, each asserting every step unconditionally: with the flags in SAME_MACHINE_WEBRTC_ARGS, which let ICE complete on a single host, the conversation must end up direct; with UDP candidates forbidden, so no direct connection can open, the whole exchange must still work through the hub and stay there. +/* A real, checked-in end-to-end test for the actual console UI's room-messaging flow (wire-mesh#101) -- webrtc.spec.ts already proves the raw WebRTC/relay signaling works against a bare test harness page; this drives the real production App/ConnectionPanel/RoomPanel components instead, the way a person actually uses the console: two independent browser instances, each its own persisted identity, connect to a real relay, one clicks "Message" on the other's directory row and sends a message, the other approves the resulting request, and the message renders on the receiving side -- with no manually copy-pasted token anywhere in the flow. + + A conversation opens at once over the hub both browsers are connected to, and a direct WebRTC connection is negotiated in the background and takes over when it opens. Two cases pin both halves, each asserting every step unconditionally: with the flags in SAME_MACHINE_WEBRTC_ARGS, which let ICE complete on a single host, the conversation must end up direct; with UDP candidates forbidden, so no direct connection can open, the whole exchange must still work through the hub and stay there. */ import { type Browser, @@ -43,6 +43,7 @@ async function newDevicePage(browser: Readonly): Promise { page.on("pageerror", (error) => { throw error; }); + return page; } @@ -178,5 +179,6 @@ async function runRoomMessagingTest( await expect(pageB.getByText(/Waiting for .* to allow messages/)).toBeHidden({ timeout: MESSAGE_RENDER_TIMEOUT_MS, }); + return [pageA, pageB]; } diff --git a/ts/packages/web-console/test/e2e/webrtc.e2e.spec.ts b/ts/packages/web-console/test/e2e/webrtc.e2e.spec.ts index f49dbc8e..16d007ff 100644 --- a/ts/packages/web-console/test/e2e/webrtc.e2e.spec.ts +++ b/ts/packages/web-console/test/e2e/webrtc.e2e.spec.ts @@ -1,8 +1,8 @@ -// A real, checked-in end-to-end test for the WebRTC data path -- the thing vitest cannot exercise at all, since neither RTCPeerConnection nor RTCDataChannel exists under Node (unlike WebSocket, which Node provides natively). Two genuinely separate Chromium Browser instances (each its own OS process, launched independently rather than sharing one process via two BrowserContexts) each load the real production mesh-session/webrtc-negotiation/webrtc-transport modules, connect to a real, unmodified wire-mesh relay (booted by playwright.config.ts's webServer, not a bespoke stand-in), and negotiate a real WebRTC data channel through it. -// -// core's relay-hub domain (shared by wire-mesh and cloudflare-hub) deliberately drops manage-request/manage-response frames sent directly to it -- see relay-hub.ts's own handleFrame, whose final branch comment says so. That is correct for relay-hub's actual job (gossip/relay-connect/ relay-data), and it is exactly why core/webrtc signaling addressed to a specific peer rides inside relay-data's own opaque payload instead (see mesh-session.ts's sendManageRequest targetDevice parameter): relay-data is the one frame kind relay-hub already forwards blindly between an established relay-connect pairing. This test's relay is the real thing, not a stand-in, specifically to prove the signaling traverses an unmodified relay-hub's real forwarding. -// -// Every assertion is unconditional: the signaling round trip must complete through the relay's real relay-data forwarding, and the data channel must then open on both sides. A run where ICE does not complete fails. Both browsers run on loopback with the flags in SAME_MACHINE_WEBRTC_ARGS, which is what lets ICE complete on a single host. +/* A real, checked-in end-to-end test for the WebRTC data path -- the thing vitest cannot exercise at all, since neither RTCPeerConnection nor RTCDataChannel exists under Node (unlike WebSocket, which Node provides natively). Two genuinely separate Chromium Browser instances (each its own OS process, launched independently rather than sharing one process via two BrowserContexts) each load the real production mesh-session/webrtc-negotiation/webrtc-transport modules, connect to a real, unmodified wire-mesh relay (booted by playwright.config.ts's webServer, not a bespoke stand-in), and negotiate a real WebRTC data channel through it. + + core's relay-hub domain (shared by wire-mesh and cloudflare-hub) deliberately drops manage-request/manage-response frames sent directly to it -- see relay-hub.ts's own handleFrame, whose final branch comment says so. That is correct for relay-hub's actual job (gossip/relay-connect/ relay-data), and it is exactly why core/webrtc signaling addressed to a specific peer rides inside relay-data's own opaque payload instead (see mesh-session.ts's sendManageRequest targetDevice parameter): relay-data is the one frame kind relay-hub already forwards blindly between an established relay-connect pairing. This test's relay is the real thing, not a stand-in, specifically to prove the signaling traverses an unmodified relay-hub's real forwarding. + + Every assertion is unconditional: the signaling round trip must complete through the relay's real relay-data forwarding, and the data channel must then open on both sides. A run where ICE does not complete fails. Both browsers run on loopback with the flags in SAME_MACHINE_WEBRTC_ARGS, which is what lets ICE complete on a single host. */ import { type Browser, @@ -38,8 +38,10 @@ const SYNTHETIC_DEVICE_ID_LENGTH = 32; const SYNTHETIC_KEY_FILL_BYTE = 0xee; const SYNTHETIC_SIGNATURE_FILL_BYTE = 0xaa; const ED25519_PUBLIC_KEY_LENGTH = 32; -const SIGNATURE_LENGTH = 64; // raw ES256/EdDSA signature length -const EDDSA = -8; // COSE algorithm identifier for pure Ed25519 (RFC 9053) +// raw ES256/EdDSA signature length +const SIGNATURE_LENGTH = 64; +// COSE algorithm identifier for pure Ed25519 (RFC 9053) +const EDDSA = -8; interface FrameSummaryEntry { direction: "sent" | "received"; @@ -69,7 +71,7 @@ async function pollUntil( description: string, ): Promise { const deadline = Date.now() + timeoutMs; - for (;;) { + const attempt = async (): Promise => { const result = await predicate(); if (result !== undefined) { return result; @@ -80,7 +82,11 @@ async function pollUntil( await new Promise((resolve) => { setTimeout(resolve, POLL_INTERVAL_MS); }); - } + + return attempt(); + }; + + return attempt(); } /** A genuinely separate Chromium process, launched independently rather than as a second context inside a shared Browser, simulating one device -- separate storage/IndexedDB/identity, and no shared browser process either. */ @@ -103,6 +109,7 @@ async function newDevicePage( page.on("pageerror", (error) => { throw error; }); + return page; } @@ -213,6 +220,7 @@ async function runNegotiationTest( entry.type === "manage-request" && entry.verb === "webrtc.offer", ); + return aGotAnswer && bGotOffer ? { summaryA: currentSummaryA, summaryB: currentSummaryB } : undefined; @@ -380,6 +388,7 @@ async function runMediaNegotiationTest( const bothHaveBothKinds = [currentKindsA, currentKindsB].every( (kinds) => kinds.includes("audio") && kinds.includes("video"), ); + return bothHaveBothKinds ? [currentKindsA, currentKindsB] : undefined; }, NEGOTIATION_TIMEOUT_MS, diff --git a/ts/packages/web-console/test/e2e/webtransport-address-refresh.e2e.spec.ts b/ts/packages/web-console/test/e2e/webtransport-address-refresh.e2e.spec.ts index 8494b99c..a6e6d41b 100644 --- a/ts/packages/web-console/test/e2e/webtransport-address-refresh.e2e.spec.ts +++ b/ts/packages/web-console/test/e2e/webtransport-address-refresh.e2e.spec.ts @@ -41,11 +41,16 @@ test("a console keeps reconnecting after the node has rotated past every hash in await handshakesSent(initiator), await handshakesSent(responder), ]; - for (let rotation = 1; rotation <= ROTATIONS; rotation++) { - await test.step(`wait for rotation ${String(rotation)}`, async () => { - await node.nextRotation(); - }); - } + // Each rotation is waited for only once the one before it has happened, so they are awaited in turn. + await Array.from({ length: ROTATIONS }, (_, index) => index + 1).reduce( + async (previous, rotation) => { + await previous; + await test.step(`wait for rotation ${String(rotation)}`, async () => { + await node.nextRotation(); + }); + }, + Promise.resolve(), + ); // Every rotation ends the session and the console makes a new one, so each has to have succeeded, the last after the address's own hashes matched nothing. await test.step("both consoles reconnected every time", async () => { await expectReconnected( diff --git a/ts/packages/web-console/test/e2e/webtransport-support.ts b/ts/packages/web-console/test/e2e/webtransport-support.ts index 71bdedbf..5ff9edea 100644 --- a/ts/packages/web-console/test/e2e/webtransport-support.ts +++ b/ts/packages/web-console/test/e2e/webtransport-support.ts @@ -75,6 +75,7 @@ export async function startNode( reject(new Error(`the node exited with code ${String(code)}`)); }); }); + return { address, process: child, @@ -99,6 +100,7 @@ export async function openConsole( "First connection to", ); await page.getByRole("button", { name: "Trust and connect" }).click(); + return page; } @@ -140,6 +142,7 @@ export async function converse( export async function handshakesSent(page: Readonly): Promise { // The handshakes are counted in the raw frame log, which the connection panel keeps behind a toggle. await page.getByRole("switch", { name: "Show raw frames" }).check(); + return page .locator("table tbody tr", { hasText: /^sent\{"type":"handshake"/ }) .count(); diff --git a/ts/packages/web-console/test/fake-rtc-data-channel.ts b/ts/packages/web-console/test/fake-rtc-data-channel.ts index d6b664c3..d2619c88 100644 --- a/ts/packages/web-console/test/fake-rtc-data-channel.ts +++ b/ts/packages/web-console/test/fake-rtc-data-channel.ts @@ -4,8 +4,11 @@ type Listener = (event: { data?: unknown }) => void; export class FakeRtcDataChannel { binaryType = "arraybuffer"; + sent: ArrayBuffer[] = []; + readyState: "connecting" | "open" | "closing" | "closed" = "open"; + private readonly listeners = new Map(); send(data: Readonly): void { diff --git a/ts/packages/web-console/test/fake-websocket.ts b/ts/packages/web-console/test/fake-websocket.ts index baa28d3b..df2f500c 100644 --- a/ts/packages/web-console/test/fake-websocket.ts +++ b/ts/packages/web-console/test/fake-websocket.ts @@ -4,9 +4,13 @@ type Listener = (event: { data?: unknown }) => void; export class FakeWebSocket { binaryType = "arraybuffer"; + sent: ArrayBuffer[] = []; + closed = false; + closeCode: number | null = null; + private readonly listeners = new Map(); constructor(public readonly url: string) {} diff --git a/ts/packages/web-console/test/grant-store.unit.test.ts b/ts/packages/web-console/test/grant-store.unit.test.ts index d2f34a8e..06a5c2e3 100644 --- a/ts/packages/web-console/test/grant-store.unit.test.ts +++ b/ts/packages/web-console/test/grant-store.unit.test.ts @@ -33,6 +33,7 @@ async function grant(id: number): Promise { expires: NOW + LIFETIME, }); if (!verdict.ok) throw new Error("expected the grant to mint"); + return verdict.token; } diff --git a/ts/packages/web-console/test/grants.unit.test.ts b/ts/packages/web-console/test/grants.unit.test.ts index 9e013a7f..5295b275 100644 --- a/ts/packages/web-console/test/grants.unit.test.ts +++ b/ts/packages/web-console/test/grants.unit.test.ts @@ -38,6 +38,7 @@ async function grant(expires = NOW + LIFETIME): Promise { expires, }); if (!verdict.ok) throw new Error("expected the grant to mint"); + return verdict.token; } diff --git a/ts/packages/web-console/test/hex.ts b/ts/packages/web-console/test/hex.ts index a415fc58..e1f211cc 100644 --- a/ts/packages/web-console/test/hex.ts +++ b/ts/packages/web-console/test/hex.ts @@ -11,6 +11,7 @@ export function bytesFromHex(hex: string): Uint8Array { HEX_RADIX, ); } + return out; } @@ -18,5 +19,6 @@ export function bytesFromHex(hex: string): Uint8Array { export function deviceIdFromFillHex(fillHex: string): Uint8Array { const out = new Uint8Array(SHA256_BYTE_LENGTH); out.fill(Number.parseInt(fillHex, HEX_RADIX)); + return out; } diff --git a/ts/packages/web-console/test/identity-backup.unit.test.ts b/ts/packages/web-console/test/identity-backup.unit.test.ts index 35c57438..9ea9484d 100644 --- a/ts/packages/web-console/test/identity-backup.unit.test.ts +++ b/ts/packages/web-console/test/identity-backup.unit.test.ts @@ -16,6 +16,7 @@ async function storedIdentity(): Promise<{ }> { const storage = createMemoryStorage(); const identity = await createPersistedWebCryptoIdentity(storage); + return { storage, deviceHex: deviceIdToHex(identity.deviceId) }; } @@ -75,6 +76,7 @@ describe("parseIdentityBackup", () => { async function exported(): Promise> { const { storage } = await storedIdentity(); const backup = await createIdentityBackupService(storage).export(); + return { ...backup }; } diff --git a/ts/packages/web-console/test/jsdom-mantine-polyfills.ts b/ts/packages/web-console/test/jsdom-mantine-polyfills.ts index 1c0e9839..09f72963 100644 --- a/ts/packages/web-console/test/jsdom-mantine-polyfills.ts +++ b/ts/packages/web-console/test/jsdom-mantine-polyfills.ts @@ -19,9 +19,11 @@ export class ResizeObserverStub { observe(): void { // no-op } + unobserve(): void { // no-op } + disconnect(): void { // no-op } diff --git a/ts/packages/web-console/test/local-network.unit.test.ts b/ts/packages/web-console/test/local-network.unit.test.ts index 4d506759..76e45a66 100644 --- a/ts/packages/web-console/test/local-network.unit.test.ts +++ b/ts/packages/web-console/test/local-network.unit.test.ts @@ -67,15 +67,17 @@ describe("explainLocalNetworkBlock", () => { }); it("says nothing when the permission is granted or still to be asked", async () => { - for (const state of ["granted", "prompt"]) { - expect( - await explainLocalNetworkBlock( - "ws://192.168.1.5:8787", - PUBLIC_PAGE, - permissionAt(state), - ), - ).toBeUndefined(); - } + await Promise.all( + ["granted", "prompt"].map(async (state) => { + expect( + await explainLocalNetworkBlock( + "ws://192.168.1.5:8787", + PUBLIC_PAGE, + permissionAt(state), + ), + ).toBeUndefined(); + }), + ); }); it("says nothing for a public address, or when the page itself is on the local network", async () => { diff --git a/ts/packages/web-console/test/mint-grant.unit.test.ts b/ts/packages/web-console/test/mint-grant.unit.test.ts index d1a7940a..3c41a9b2 100644 --- a/ts/packages/web-console/test/mint-grant.unit.test.ts +++ b/ts/packages/web-console/test/mint-grant.unit.test.ts @@ -45,6 +45,7 @@ function input(overrides: Partial = {}): MintGrantInput { function claimsOf(token: CapabilityToken): TokenClaims { const claims = decodeGrantClaims(token); if (claims === undefined) throw new Error("the minted token has no claims"); + return claims; } @@ -161,6 +162,7 @@ describe("mintGrant", () => { { identity: peer, clock }, ); if (!minted.ok) throw new Error(minted.error); + return minted.token; } @@ -228,8 +230,8 @@ describe("mintGrant", () => { if (!minted.ok) throw new Error(minted.error); expect(claimsOf(minted.token)["grants-capability"]).toBe("exec:pty"); - // The bar does its job end to end: the holder (peer) minting exec:pty under this grant - // naming itself is refused, and naming anyone else is accepted. + /* The bar does its job end to end: the holder (peer) minting exec:pty under this grant + naming itself is refused, and naming anyone else is accepted. */ const toSelf = await mintCapabilityToken({ identity: peer, clock, diff --git a/ts/packages/web-console/test/names-harness.tsx b/ts/packages/web-console/test/names-harness.tsx index c60664a6..f6b1e931 100644 --- a/ts/packages/web-console/test/names-harness.tsx +++ b/ts/packages/web-console/test/names-harness.tsx @@ -50,5 +50,6 @@ export function AssertsSelfName({ }, ]); }, [deviceHex, selfName, observeDirectory]); + return null; } diff --git a/ts/packages/web-console/test/notices.unit.test.ts b/ts/packages/web-console/test/notices.unit.test.ts index bce44931..e5307ecb 100644 --- a/ts/packages/web-console/test/notices.unit.test.ts +++ b/ts/packages/web-console/test/notices.unit.test.ts @@ -67,6 +67,7 @@ async function generateEs256Identity(): Promise { false, ["deriveBits"], ); + return createNodeIdentity( keyPair.privateKey, publicKeyBytes, @@ -77,10 +78,12 @@ async function generateEs256Identity(): Promise { function memoryDelivery(delivered: readonly string[] = []): RoomKeyDelivery { const rooms = new Set(delivered); + return { isDelivered: async (room) => Promise.resolve(rooms.has(room)), markDelivered: async (room) => { rooms.add(room); + return Promise.resolve(); }, }; @@ -88,6 +91,7 @@ function memoryDelivery(delivered: readonly string[] = []): RoomKeyDelivery { function memoryKeyStore(): RoomKeyStore { const keys = new Map>(); + return { async get(room, epoch) { return Promise.resolve(keys.get(room)?.get(epoch)); @@ -126,6 +130,7 @@ function fakeSession(): MeshSession & { const unimplemented = (member: string): never => { throw new Error(`the session double does not implement ${member}`); }; + return { sent, commands, @@ -148,10 +153,12 @@ function fakeSession(): MeshSession & { close: async () => Promise.resolve(), sendDataFrame: async (frame: Frame) => { sent.push(frame); + return Promise.resolve(); }, sendManageRequest: async (command: ManageCommand) => { commands.push(command); + return Promise.resolve( refusal === undefined ? ({ result: "ok" } as const) @@ -175,14 +182,17 @@ async function drain( memberWiring: NoticeWiring, ): Promise { const MAX_DRAIN_ROUNDS = 8; - for (let round = 0; round < MAX_DRAIN_ROUNDS; round += 1) { + const round = async (roundsLeft: number): Promise => { + if (roundsLeft === 0) return; const ownerFrames = ownerSession.sent.splice(0); const memberFrames = memberSession.sent.splice(0); - if (ownerFrames.length === 0 && memberFrames.length === 0) break; + if (ownerFrames.length === 0 && memberFrames.length === 0) return; for (const f of ownerFrames) memberWiring.handleFrame(f, memberSession); for (const f of memberFrames) ownerWiring.handleFrame(f, ownerSession); await flush(); - } + await round(roundsLeft - 1); + }; + await round(MAX_DRAIN_ROUNDS); } async function mintRoomMemberToken( @@ -201,6 +211,7 @@ async function mintRoomMemberToken( delegationsRemaining: 0, }); if (!verdict.ok) throw new Error(`mint failed: ${verdict.reason}`); + return verdict.token; } @@ -235,8 +246,8 @@ describe("bootstrapDmEpoch1", () => { delivery: memoryDelivery(), }); - // The lower side stored its own epoch-1 key and sent exactly one rekey - // (a manage-request, recorded by the session double). + /* The lower side stored its own epoch-1 key and sent exactly one rekey + (a manage-request, recorded by the session double). */ expect(await lowerKeys.currentEpoch(roomPath)).toBe(1); expect(lowerSession.commands).toHaveLength(1); @@ -251,13 +262,15 @@ describe("bootstrapDmEpoch1", () => { revocation: createRevocationView(), ownRoomMemberToken: higherToken, onRekey: async (event: Readonly) => { - for (const [i, key] of event.contentKeys.entries()) { - await higherKeys.set( - roomPath, - event.keyEpoch - event.contentKeys.length + 1 + i, - key, - ); - } + await Promise.all( + event.contentKeys.map(async (key, i) => + higherKeys.set( + roomPath, + event.keyEpoch - event.contentKeys.length + 1 + i, + key, + ), + ), + ); }, }); await handler({ @@ -332,6 +345,7 @@ describe("bootstrapDmEpoch1 redelivery", () => { const lower = aIsLower ? a : b; const higher = aIsLower ? b : a; const roomPath = `${deviceIdToHex(lower.deviceId)}+${deviceIdToHex(higher.deviceId)}`; + return { lower, lowerToken: await mintRoomMemberToken(higher, lower, roomPath), @@ -400,13 +414,15 @@ describe("createNoticeWiring", () => { revocation: createRevocationView(), ownRoomMemberToken: memberToken, onRekey: async (event: Readonly) => { - for (const [i, key] of event.contentKeys.entries()) { - await memberKeys.set( - roomPath, - event.keyEpoch - event.contentKeys.length + 1 + i, - key, - ); - } + await Promise.all( + event.contentKeys.map(async (key, i) => + memberKeys.set( + roomPath, + event.keyEpoch - event.contentKeys.length + 1 + i, + key, + ), + ), + ); }, })({ requestId: 0, @@ -448,10 +464,10 @@ describe("createNoticeWiring", () => { }); await drain(ownerSession, ownerWiring, memberSession, memberWiring); - // The full catch-up round trip flowed (announce, request, entries -- - // ownerSession.sent was drained into the member); assert on the - // member's received state: its storage holds the owner's entry and its - // read returns the decrypted notice. + /* The full catch-up round trip flowed (announce, request, entries -- + ownerSession.sent was drained into the member); assert on the + member's received state: its storage holds the owner's entry and its + read returns the decrypted notice. */ expect(memberChanges).toBeGreaterThan(0); const memberRead = await memberWiring.readRoom(roomPath, [owner.deviceId]); const replicated = memberRead.find( diff --git a/ts/packages/web-console/test/request-demux.unit.test.ts b/ts/packages/web-console/test/request-demux.unit.test.ts index 346a83ea..695c3de6 100644 --- a/ts/packages/web-console/test/request-demux.unit.test.ts +++ b/ts/packages/web-console/test/request-demux.unit.test.ts @@ -21,6 +21,7 @@ async function take( ids.push(item.requestId); if (ids.length === count) return ids; } + return ids; } diff --git a/ts/packages/web-console/test/request-permission.unit.test.ts b/ts/packages/web-console/test/request-permission.unit.test.ts index 6f5f5c94..6e708e65 100644 --- a/ts/packages/web-console/test/request-permission.unit.test.ts +++ b/ts/packages/web-console/test/request-permission.unit.test.ts @@ -15,6 +15,7 @@ import { describeRequestRefusal, heldRequestToken, } from "../src/request-permission.js"; +import { inSequence } from "./sequence.js"; const NOW = 1000; const HOUR_MS = 3_600_000; @@ -62,6 +63,7 @@ async function permission( : { requestsCapability: overrides.requestsCapability }), }); if (!verdict.ok) throw new Error(verdict.reason); + return verdict.token; } @@ -69,9 +71,10 @@ async function storeWith( recorded: readonly { token: CapabilityToken; direction?: GrantDirection }[], ): Promise> { const store = createGrantStore(createMemoryStorage()); - for (const { token, direction } of recorded) { + await inSequence(recorded, async ({ token, direction }) => { await store.record(direction ?? "held", token, NOW); - } + }); + return store; } diff --git a/ts/packages/web-console/test/room-client.unit.test.ts b/ts/packages/web-console/test/room-client.unit.test.ts index 31937d2a..06ae1e8c 100644 --- a/ts/packages/web-console/test/room-client.unit.test.ts +++ b/ts/packages/web-console/test/room-client.unit.test.ts @@ -51,6 +51,7 @@ function encodeBuf(value: unknown): Uint8Array { let issuedTokenIds = 0; function nextTokenId(): Uint8Array { issuedTokenIds += 1; + return buf([issuedTokenIds]); } @@ -84,6 +85,7 @@ async function signToken( payload, ]); const signature = await identity.sign(toBeSigned); + return [protectedHeader, {}, payload, signature]; } @@ -127,6 +129,7 @@ function fakeSession(): { if (queued !== undefined) { return Promise.resolve({ value: queued, done: false }); } + return new Promise((resolve) => { waiters.push((request) => { resolve({ value: request, done: false }); @@ -137,6 +140,7 @@ function fakeSession(): { }, }, } as unknown as MeshSession; + return { session, push(request: IncomingManageRequest): void { @@ -162,7 +166,7 @@ describe("sendRoomMessage", () => { const mockOutcome: ManageOutcome = { result: "ok" }; vi.mocked(session.sendManageRequest).mockResolvedValue(mockOutcome); - const outcome = await sendRoomMessage(session, ROOM_PATH, "hi", token); + const outcome = await sendRoomMessage(session, ROOM_PATH, "hi", { token }); expect(outcome).toBe(mockOutcome); expect(session.sendManageRequest).toHaveBeenCalledTimes(1); @@ -216,7 +220,7 @@ describe("requestToJoin", () => { new Uint8Array(0), ]; - await requestToJoin(session, ROOM_PATH, undefined, permission).catch( + await requestToJoin(session, ROOM_PATH, { requestToken: permission }).catch( () => undefined, ); await requestToJoin(session, ROOM_PATH).catch(() => undefined); @@ -255,13 +259,11 @@ describe("mintRoomInviteGrant", () => { const owner = await createWebCryptoIdentity(); const invitee = await createWebCryptoIdentity(); - const token = await mintRoomInviteGrant( - owner, - fixedClock(NOW_MS), - ROOM_PATH, - invitee.deviceId, - NOW_MS + HOUR_MS, - ); + const token = await mintRoomInviteGrant(owner, fixedClock(NOW_MS), { + roomPath: ROOM_PATH, + invitee: invitee.deviceId, + expires: NOW_MS + HOUR_MS, + }); const payload = token[2]; if (payload === null) throw new Error("expected a payload"); @@ -301,8 +303,10 @@ describe("sendRoomInvite", () => { function fakeIncomingRoomSend( token: CapabilityToken | undefined, - params: Record = {}, - roomPath: string = ROOM_PATH, + { + params = {}, + roomPath = ROOM_PATH, + }: { params?: Record; roomPath?: string } = {}, ): { incoming: IncomingManageRequest; respond: ReturnType } { const respond = vi.fn(async (): Promise => Promise.resolve()); const incoming: IncomingManageRequest = { @@ -315,6 +319,7 @@ function fakeIncomingRoomSend( ...(token !== undefined ? { token } : {}), respond, }; + return { incoming, respond }; } @@ -349,11 +354,14 @@ describe("createRoomRouter", () => { { onMessage }, ); - const { incoming, respond } = fakeIncomingRoomSend( - token, - { "message-id": messageId, "sent-at": NOW_MS, text: "hello room" }, + const { incoming, respond } = fakeIncomingRoomSend(token, { + params: { + "message-id": messageId, + "sent-at": NOW_MS, + text: "hello room", + }, roomPath, - ); + }); push(incoming); await vi.waitFor(() => { expect(onMessage).toHaveBeenCalledTimes(1); @@ -385,9 +393,11 @@ describe("createRoomRouter", () => { ); const { incoming, respond } = fakeIncomingRoomSend(undefined, { - "message-id": messageId, - "sent-at": NOW_MS, - text: "hello room", + params: { + "message-id": messageId, + "sent-at": NOW_MS, + text: "hello room", + }, }); push(incoming); await vi.waitFor(() => { @@ -506,13 +516,11 @@ describe("createRoomRouter", () => { deviceIdToHex(owner.deviceId), "general", ); - const grantedToken = await mintRoomInviteGrant( - owner, - fixedClock(NOW_MS), + const grantedToken = await mintRoomInviteGrant(owner, fixedClock(NOW_MS), { roomPath, - invitee.deviceId, - NOW_MS + HOUR_MS, - ); + invitee: invitee.deviceId, + expires: NOW_MS + HOUR_MS, + }); const { session, push } = fakeSession(); const onRoomInvite = vi.fn<(event: Readonly) => void>(); createRoomRouter( @@ -560,9 +568,7 @@ describe("createRoomRouter", () => { const misdirectedToken = await mintRoomInviteGrant( owner, fixedClock(NOW_MS), - roomPath, - someoneElse.deviceId, - NOW_MS + HOUR_MS, + { roomPath, invitee: someoneElse.deviceId, expires: NOW_MS + HOUR_MS }, ); const { session, push } = fakeSession(); const onRoomInvite = vi.fn<(event: Readonly) => void>(); @@ -608,13 +614,11 @@ describe("createRoomRouter", () => { deviceIdToHex(owner.deviceId), "general", ); - const grantedToken = await mintRoomInviteGrant( - owner, - fixedClock(NOW_MS), + const grantedToken = await mintRoomInviteGrant(owner, fixedClock(NOW_MS), { roomPath, - invitee.deviceId, - NOW_MS + HOUR_MS, - ); + invitee: invitee.deviceId, + expires: NOW_MS + HOUR_MS, + }); const { session, push } = fakeSession(); createRoomRouter( session, diff --git a/ts/packages/web-console/test/search.unit.test.ts b/ts/packages/web-console/test/search.unit.test.ts index 41a0c176..9ea6d377 100644 --- a/ts/packages/web-console/test/search.unit.test.ts +++ b/ts/packages/web-console/test/search.unit.test.ts @@ -28,6 +28,7 @@ function conversation( overrides: Partial = {}, ): ConversationView { const peer = digit.repeat(DEVICE_ID_HEX_LENGTH); + return { roomPath: `${"1".repeat(DEVICE_ID_HEX_LENGTH)}+${peer}`, participants: [peer], @@ -48,6 +49,7 @@ function notice( postedAt: number, ): ConversationView["notices"][number] { const content = new TextEncoder().encode(text); + return { verified: true, contentType: "text/plain", diff --git a/ts/packages/web-console/test/sequence.ts b/ts/packages/web-console/test/sequence.ts new file mode 100644 index 00000000..9080f598 --- /dev/null +++ b/ts/packages/web-console/test/sequence.ts @@ -0,0 +1,23 @@ +/** + * Runs `step` for each item, starting each only once the one before it has finished: the ordered counterpart to `Promise.all`, for steps that depend on the effects of the earlier ones or must not overlap. + */ +export async function inSequence( + items: readonly T[], + step: (item: T, index: number) => Promise, +): Promise { + await items.reduce(async (previous, item, index) => { + await previous; + await step(item, index); + }, Promise.resolve()); +} + +/** `inSequence` over the indices 0 to `times - 1`. */ +export async function repeatInSequence( + times: number, + step: (index: number) => Promise, +): Promise { + await inSequence( + Array.from({ length: times }, (_, index) => index), + async (index) => step(index), + ); +} diff --git a/ts/packages/web-console/test/use-certificate-trust.unit.test.tsx b/ts/packages/web-console/test/use-certificate-trust.unit.test.tsx index a4e73b11..82aea1f3 100644 --- a/ts/packages/web-console/test/use-certificate-trust.unit.test.tsx +++ b/ts/packages/web-console/test/use-certificate-trust.unit.test.tsx @@ -40,6 +40,7 @@ async function memoryRemembering( digits.map((digit) => bytesFromHex(hashHex(digit))), ); } + return memory; } diff --git a/ts/packages/web-console/test/use-connection-health.unit.test.tsx b/ts/packages/web-console/test/use-connection-health.unit.test.tsx index f1ad5856..e1f44c29 100644 --- a/ts/packages/web-console/test/use-connection-health.unit.test.tsx +++ b/ts/packages/web-console/test/use-connection-health.unit.test.tsx @@ -28,6 +28,7 @@ function renderHealth( typeof renderHook, unknown> > { const session = { sendPingMeasureRtt: ping }; + return renderHook(() => useConnectionHealth(session, connected, TIMING)); } @@ -44,8 +45,10 @@ function scripted( fallback: number, ): ReturnType> { const remaining = [...outcomes]; + return vi.fn(async () => { const outcome = remaining.shift(); + return outcome === undefined ? fallback : outcome(); }); } diff --git a/ts/packages/web-console/test/use-grants.unit.test.tsx b/ts/packages/web-console/test/use-grants.unit.test.tsx index 77f5399f..f180fc20 100644 --- a/ts/packages/web-console/test/use-grants.unit.test.tsx +++ b/ts/packages/web-console/test/use-grants.unit.test.tsx @@ -44,6 +44,7 @@ async function issuedRecord(): Promise { if (!minted.ok) throw new Error(minted.error); const claims = decodeGrantClaims(minted.token); if (claims === undefined) throw new Error("minted token has no claims"); + return { tokenId: "issued-1", direction: "issued", @@ -66,15 +67,18 @@ function scriptedStore(): { list: async () => { const answer = Promise.withResolvers(); answers.push(answer); + return answer.promise; }, subscribe: (listener) => { listeners.add(listener); + return () => { listeners.delete(listener); }; }, }; + return { store, answers, diff --git a/ts/packages/web-console/test/use-room-messaging-relay.unit.test.tsx b/ts/packages/web-console/test/use-room-messaging-relay.unit.test.tsx index 01379a77..375077ac 100644 --- a/ts/packages/web-console/test/use-room-messaging-relay.unit.test.tsx +++ b/ts/packages/web-console/test/use-room-messaging-relay.unit.test.tsx @@ -64,13 +64,11 @@ async function answeringHub(): Promise<{ sentDataFrames: Parameters[0][]; sendDataFrame: MeshSession["sendDataFrame"]; }> { - const grant = await mintRoomInviteGrant( - peer, - clock, + const grant = await mintRoomInviteGrant(peer, clock, { roomPath, - own.deviceId, - TOKEN_EXPIRES, - ); + invitee: own.deviceId, + expires: TOKEN_EXPIRES, + }); const sendManageRequest = vi.fn(async (command) => Promise.resolve( command.params.verb === "capability.request" @@ -83,11 +81,13 @@ async function answeringHub(): Promise<{ ), ); const sentDataFrames: Parameters[0][] = []; + return { sendManageRequest, sentDataFrames, sendDataFrame: async (frame) => { sentDataFrames.push(frame); + return Promise.resolve(); }, }; @@ -98,8 +98,15 @@ function render(): ReturnType< > { const store = createMessageStore(createMemoryStorage()); const roomStorage = createMemoryStorage(); + return renderHook(() => - useRoomMessaging(own, clock, store, roomStorage, capabilities.services), + useRoomMessaging({ + identity: own, + clock, + messageStore: store, + roomStorage, + capabilities: capabilities.services, + }), ); } @@ -133,7 +140,13 @@ describe("a conversation over a hub", () => { const store = createMessageStore(createMemoryStorage()); const roomStorage = createMemoryStorage(); const gated = renderHook(() => - useRoomMessaging(own, clock, store, roomStorage, own2.services), + useRoomMessaging({ + identity: own, + clock, + messageStore: store, + roomStorage, + capabilities: own2.services, + }), ); const hub = await answeringHub(); act(() => { @@ -203,13 +216,11 @@ describe("a conversation over a hub", () => { incomingManageRequests: incoming.stream, }); }); - const token = await mintRoomInviteGrant( - own, - clock, + const token = await mintRoomInviteGrant(own, clock, { roomPath, - peer.deviceId, - TOKEN_EXPIRES, - ); + invitee: peer.deviceId, + expires: TOKEN_EXPIRES, + }); const respond = vi.fn(async () => Promise.resolve(), ); @@ -243,13 +254,11 @@ describe("a conversation over a hub", () => { }); }); const stranger = await createWebCryptoIdentity(); - const tokenForSomeoneElse = await mintRoomInviteGrant( - own, - clock, + const tokenForSomeoneElse = await mintRoomInviteGrant(own, clock, { roomPath, - stranger.deviceId, - TOKEN_EXPIRES, - ); + invitee: stranger.deviceId, + expires: TOKEN_EXPIRES, + }); const respond = vi.fn(async () => Promise.resolve(), ); @@ -361,7 +370,13 @@ describe("a conversation over a hub", () => { }; const roomStorage = createMemoryStorage(); const { result } = renderHook(() => - useRoomMessaging(own, clock, store, roomStorage, services), + useRoomMessaging({ + identity: own, + clock, + messageStore: store, + roomStorage, + capabilities: services, + }), ); const hub = await answeringHub(); act(() => { @@ -425,6 +440,7 @@ describe("a conversation over a hub", () => { await waitFor(() => { expect(respond).toHaveBeenCalled(); }); + return respond; }; diff --git a/ts/packages/web-console/test/use-room-messaging.unit.test.tsx b/ts/packages/web-console/test/use-room-messaging.unit.test.tsx index e78d4348..93aff493 100644 --- a/ts/packages/web-console/test/use-room-messaging.unit.test.tsx +++ b/ts/packages/web-console/test/use-room-messaging.unit.test.tsx @@ -37,11 +37,18 @@ async function renderRestoredConversation(): Promise< const { services } = await testCapabilities(identity, clock); const roomStorage = createMemoryStorage(); const rendered = renderHook(() => - useRoomMessaging(identity, clock, store, roomStorage, services), + useRoomMessaging({ + identity, + clock, + messageStore: store, + roomStorage, + capabilities: services, + }), ); await waitFor(() => { expect(rendered.result.current.conversations).toHaveLength(1); }); + return rendered; } diff --git a/ts/packages/web-console/test/web-crypto-identity.unit.test.ts b/ts/packages/web-console/test/web-crypto-identity.unit.test.ts index cb3a75af..e7026390 100644 --- a/ts/packages/web-console/test/web-crypto-identity.unit.test.ts +++ b/ts/packages/web-console/test/web-crypto-identity.unit.test.ts @@ -16,9 +16,11 @@ import { import { bytesFromHex } from "./hex.js"; const ES256 = -7; -const ES512 = -36; // a real COSE algorithm this adapter deliberately does not implement +// a real COSE algorithm this adapter deliberately does not implement +const ES512 = -36; const SHA256_BYTE_LENGTH = 32; -const UNCOMPRESSED_P256_POINT_BYTE_LENGTH = 65; // 0x04 || X || Y +// 0x04 || X || Y +const UNCOMPRESSED_P256_POINT_BYTE_LENGTH = 65; function someMessage(): Uint8Array { return bytesFromHex("0102030405"); diff --git a/ts/packages/web-console/test/webrtc-negotiation.unit.test.ts b/ts/packages/web-console/test/webrtc-negotiation.unit.test.ts index 2731c1df..2e28f6d2 100644 --- a/ts/packages/web-console/test/webrtc-negotiation.unit.test.ts +++ b/ts/packages/web-console/test/webrtc-negotiation.unit.test.ts @@ -44,6 +44,7 @@ let issuedTokenIds = 0; /** A fresh, distinct token-id per call -- the tests only need each token to be distinguishable from the others, not any particular byte value. */ function nextTokenId(): Uint8Array { issuedTokenIds += 1; + return buf([issuedTokenIds]); } @@ -77,6 +78,7 @@ async function signToken( payload, ]); const signature = await identity.sign(toBeSigned); + return [protectedHeader, {}, payload, signature]; } @@ -231,21 +233,27 @@ class StubPeerConnection { addEventListener(): void { return undefined; } + createDataChannel(): { binaryType: string; addEventListener: () => void } { return { binaryType: "", addEventListener: () => undefined }; } + async createOffer(): Promise<{ sdp: string }> { return Promise.resolve({ sdp: "v=0" }); } + async createAnswer(): Promise<{ sdp: string }> { return Promise.resolve({ sdp: "v=0" }); } + async setLocalDescription(): Promise { return Promise.resolve(); } + async setRemoteDescription(): Promise { return Promise.resolve(); } + close(): void { return undefined; } @@ -265,10 +273,12 @@ function requestsOf( return { [Symbol.asyncIterator]: () => { let index = 0; + return { next: async () => { const value = requests[index]; index += 1; + return Promise.resolve( value === undefined ? { done: true as const, value: undefined } @@ -421,14 +431,16 @@ describe("mintOfferToken", () => { revocation: neverRevoked, }), ).toBe(true); - for (const token of [first, second]) { - expect( - await authorizeIncomingOffer(fakeIncomingRequest(token), { - identity, - clock: fixedClock(now + HOUR_MS + 1), - revocation: neverRevoked, - }), - ).toBe(false); - } + await Promise.all( + [first, second].map(async (token) => { + expect( + await authorizeIncomingOffer(fakeIncomingRequest(token), { + identity, + clock: fixedClock(now + HOUR_MS + 1), + revocation: neverRevoked, + }), + ).toBe(false); + }), + ); }); }); diff --git a/ts/packages/web-console/test/webrtc-transport.unit.test.ts b/ts/packages/web-console/test/webrtc-transport.unit.test.ts index d07706c3..8c6cc8fd 100644 --- a/ts/packages/web-console/test/webrtc-transport.unit.test.ts +++ b/ts/packages/web-console/test/webrtc-transport.unit.test.ts @@ -10,8 +10,10 @@ import { syntheticAdvertProof } from "./synthetic-advert.js"; const SHA256_BYTE_LENGTH = 32; const ping: Frame = { type: "ping" }; -const CBOR_MAP_ONE_ENTRY_FIRST_BYTE = 0xa1; // a one-entry CBOR map head -- the ping frame, no length prefix -const CBOR_BREAK_BYTE = 0xff; // the CBOR break byte on its own: undecodable as a complete value +// a one-entry CBOR map head -- the ping frame, no length prefix +const CBOR_MAP_ONE_ENTRY_FIRST_BYTE = 0xa1; +// the CBOR break byte on its own: undecodable as a complete value +const CBOR_BREAK_BYTE = 0xff; function arrayBuffer(bytes: Uint8Array): ArrayBuffer { return bytes.buffer.slice( @@ -25,6 +27,7 @@ async function collect(iterable: Readonly>): Promise { for await (const item of iterable) { out.push(item); } + return out; } @@ -34,6 +37,7 @@ async function nextValue(iterable: Readonly>): Promise { if (result.done === true) { throw new Error("iterator ended without producing a value"); } + return result.value; } diff --git a/ts/packages/web-console/test/websocket-transport.integration.test.ts b/ts/packages/web-console/test/websocket-transport.integration.test.ts index 65e2cab3..62f88886 100644 --- a/ts/packages/web-console/test/websocket-transport.integration.test.ts +++ b/ts/packages/web-console/test/websocket-transport.integration.test.ts @@ -13,8 +13,10 @@ import { syntheticAdvertProof } from "./synthetic-advert.js"; const SHA256_BYTE_LENGTH = 32; const ping: Frame = { type: "ping" }; -const CBOR_MAP_ONE_ENTRY_FIRST_BYTE = 0xa1; // a one-entry CBOR map head -- the ping frame, no length prefix -const CBOR_BREAK_BYTE = 0xff; // the CBOR break byte on its own: undecodable as a complete value +// a one-entry CBOR map head -- the ping frame, no length prefix +const CBOR_MAP_ONE_ENTRY_FIRST_BYTE = 0xa1; +// the CBOR break byte on its own: undecodable as a complete value +const CBOR_BREAK_BYTE = 0xff; const CLOSE_PROTOCOL_ERROR = 1002; function arrayBuffer(bytes: Uint8Array): ArrayBuffer { @@ -29,6 +31,7 @@ async function collect(iterable: Readonly>): Promise { for await (const item of iterable) { out.push(item); } + return out; } diff --git a/ts/packages/web-console/test/webtransport-transport.unit.test.ts b/ts/packages/web-console/test/webtransport-transport.unit.test.ts index 51a92837..52425776 100644 --- a/ts/packages/web-console/test/webtransport-transport.unit.test.ts +++ b/ts/packages/web-console/test/webtransport-transport.unit.test.ts @@ -14,11 +14,16 @@ const ADDRESS = formatPinnedAddress("192.0.2.5:4433", [HASH_HEX]); /** A session that is ready but whose streams never open, as Safari's do against a node that grants no flow-control credit. */ class StuckSession { static closed = 0; + readonly ready = Promise.resolve(); + readonly closed = new Promise(() => undefined); + readonly incomingUnidirectionalStreams = new ReadableStream(); + readonly createBidirectionalStream = async (): Promise => new Promise(() => undefined); + close(): void { StuckSession.closed += 1; } diff --git a/ts/packages/web-console/vite.config.ts b/ts/packages/web-console/vite.config.ts index 8d87c84f..4c53b151 100644 --- a/ts/packages/web-console/vite.config.ts +++ b/ts/packages/web-console/vite.config.ts @@ -37,10 +37,13 @@ function invokePostcssFactory( if (!isPostcssPlugin(plugin)) { throw new Error("PostCSS plugin factory did not return a plugin object"); } + return plugin; } -// No dev proxy: the console takes full ws:// URLs and WebSocket connections are not same-origin-restricted, so `vite` (dev) serves the page and the page dials the node directly. +/** + * No dev proxy: the console takes full ws:// URLs and WebSocket connections are not same-origin-restricted, so `vite` (dev) serves the page and the page dials the node directly. + */ export default defineConfig({ plugins: [ react(),