diff --git a/conformance/.npmrc b/conformance/.npmrc new file mode 100644 index 0000000..98d78df --- /dev/null +++ b/conformance/.npmrc @@ -0,0 +1,2 @@ +save-exact=true +minimum-release-age-exclude[]=@exadev/eslint-config diff --git a/conformance/package.json b/conformance/package.json index d1d8b11..1f9afd0 100644 --- a/conformance/package.json +++ b/conformance/package.json @@ -3,7 +3,7 @@ "version": "0.0.0", "private": true, "type": "module", - "packageManager": "pnpm@10.33.0", + "packageManager": "pnpm@12.4.1+sha512.2e81e399d73fe8390dab25e06aa788ab7a5908248d2f5a370f82b481147a6a7a367bf8048f9a6fdb6460f21a66f0542dedb8b94ca2c8723596741920b1656d4c", "scripts": { "build": "turbo run _build", "_build": "tsdown", @@ -21,7 +21,7 @@ }, "devDependencies": { "@arethetypeswrong/cli": "0.18.5", - "@exadev/eslint-config": "2.10.6", + "@exadev/eslint-config": "2.12.1", "@types/node": "26.4.1", "eslint": "10.10.0", "eslint-config-prettier": "10.1.8", diff --git a/conformance/pnpm-lock.yaml b/conformance/pnpm-lock.yaml index 2df1708..cfed86b 100644 --- a/conformance/pnpm-lock.yaml +++ b/conformance/pnpm-lock.yaml @@ -1,3 +1,161 @@ +--- +lockfileVersion: '9.0' + +importers: + + .: + configDependencies: {} + packageManagerDependencies: + pnpm: + specifier: 12.4.1 + version: 12.4.1 + +packages: + + '@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] + + pnpm@12.4.1: + resolution: {integrity: sha512-LoHjmdc/6DkNqyXgaqeIq3pZCCSNL1o3D4K0gRR6ano2e/gEj5pv22Rg8hpm8FQt7bi5TKLIcjWWdBkgsWVtTA==} + engines: {node: '>=18.*'} + hasBin: true + +snapshots: + + '@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 + + 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 + +--- lockfileVersion: '9.0' settings: @@ -16,8 +174,8 @@ importers: specifier: 0.18.5 version: 0.18.5 '@exadev/eslint-config': - specifier: 2.10.6 - version: 2.10.6(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: 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) '@types/node': specifier: 26.4.1 version: 26.4.1 @@ -83,6 +241,14 @@ packages: resolution: {integrity: sha512-LfUFi+Vv4eDzj+XAtR89e3wwjXA/NZjUSwU5NhwbBrLecxPaBYFy3exCuc1j+D4UZeOVdqlsl8G7LmOt18V0tg==} engines: {node: '>=20'} + '@es-joy/jsdoccomment@0.97.0': + resolution: {integrity: sha512-EP8uoFfh6+GsdGCduYtmWAW0h7AO+Ayik9Vh5YbA2r/3N6lmJKkCNZX+q3QBXC1K6ixjQ/9igF2b7WVvLm063g==} + engines: {node: ^22.22.2 || >=24.15.0} + + '@es-joy/resolve.exports@1.2.0': + resolution: {integrity: sha512-Q9hjxWI5xBM+qW2enxfe8wDKdFWMfd0Z29k5ZJnuBqD/CasY5Zryj09aCA6owbGATWz+39p5uIdaHXpopOcG8g==} + engines: {node: '>=10'} + '@esbuild/aix-ppc64@0.28.2': resolution: {integrity: sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==} engines: {node: '>=18'} @@ -278,8 +444,8 @@ packages: resolution: {integrity: sha512-IkO+/KEUvwbVpiURZg+P7zF74z5Jxe0UgJxVni+RtoHQ6IZieXaO02kmadomap/q+l6bc/jdPGGqTjhuZnuz1Q==} engines: {node: ^20.19.0 || ^22.13.0 || >=24} - '@exadev/eslint-config@2.10.6': - resolution: {integrity: sha512-iU/0uHNqo9GLLpLzlBA9KN3233aKHOj7bQYsc4in1HCZaUHqjNmAdX8bPs99SxhNjsP7wOpBl7Inrh6w3JDlqw==} + '@exadev/eslint-config@2.12.1': + resolution: {integrity: sha512-b5JKCX7l5onZy0i0kHdIeuiocxDoiuEwnrG6+j+EV8ZWaLpi18BDzNi1iT3tc647DFRUYJtHWDiJrkWT6E2JjQ==} engines: {node: '>=20'} peerDependencies: '@next/eslint-plugin-next': ^16.3.2 @@ -341,6 +507,12 @@ packages: '@loaderkit/resolve@1.0.6': resolution: {integrity: sha512-G8FdIoF5CypfwmD9rl8BXod5HDn8JqB0CCNBXDTaRZ+yRYhARrrSToX1zg1zy9jX3zLqigsELwhT4gNtkdQAUg==} + '@microsoft/tsdoc-config@0.18.1': + resolution: {integrity: sha512-9brPoVdfN9k9g0dcWkFeA7IH9bbcttzDJlXvkf8b2OBzd5MueR1V2wkKBL0abn0otvmkHJC6aapBOTJDDeMCZg==} + + '@microsoft/tsdoc@0.16.0': + resolution: {integrity: sha512-xgAyonlVVS+q7Vc7qLW0UrJU7rSFcETRWsqdXZtjzRU8dF+6CkozTK4V4y1LwOX7j8r/vHphjDeMeGI4tNGeGA==} + '@oxc-project/types@0.148.0': resolution: {integrity: sha512-Nm4s/jB+4FpFsPhWGEC4h7rzksesmtnMXomo6rCMcg/b8zLQuOziRgkCS1fxDCXOlJB/6Q8oABOZ/OP6RIPj9A==} @@ -450,6 +622,10 @@ packages: '@rolldown/pluginutils@1.0.1': resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==} + '@sindresorhus/base62@1.0.0': + resolution: {integrity: sha512-TeheYy0ILzBEI/CO55CP6zJCSdSWeRtGnHy8U8dWSUH4I68iqTsy7HkMktR4xakThc9jotkPQUXT4ITdbV7cHA==} + engines: {node: '>=18'} + '@sindresorhus/is@4.6.0': resolution: {integrity: sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw==} engines: {node: '>=10'} @@ -517,16 +693,32 @@ packages: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: '>=4.8.4 <6.1.0' + '@typescript-eslint/project-service@8.56.1': + resolution: {integrity: sha512-TAdqQTzHNNvlVFfR+hu2PDJrURiwKsUvxFn1M0h95BB8ah5jejas08jUWG4dBA68jDMI988IvtfdAI53JzEHOQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.0.0' + '@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/scope-manager@8.56.1': + resolution: {integrity: sha512-YAi4VDKcIZp0O4tz/haYKhmIDZFEUPOreKbfdAN3SzUDMcPhJ8QI99xQXqX+HoUVq8cs85eRKnD+rne2UAnj2w==} + 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/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.69.0': resolution: {integrity: sha512-xNqK7YTDZsLniQMV/4rpFR8Z5JlqeRvVjuG1YgF/mdPVH84HSD19L8CczMA0qg2RfwEV231GHH3VnToJDo4MfQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -540,16 +732,33 @@ packages: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: '>=4.8.4 <6.1.0' + '@typescript-eslint/types@8.56.1': + resolution: {integrity: sha512-dbMkdIUkIkchgGDIv7KLUpa0Mda4IYjo4IAMJUZ+3xNoUXxMsk9YtKpTHSChRS85o+H9ftm51gsK1dZReY9CVw==} + 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/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.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/utils@8.56.1': + resolution: {integrity: sha512-HPAVNIME3tABJ61siYlHzSWCGtOoeP2RTIaHXFMPqjrQKCGB9OgUVdiNgH7TJS2JNIQ5qQ4RsAUDuGaGme/KOA==} + 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.0.0' + '@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} @@ -557,6 +766,10 @@ packages: 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} + '@typescript-eslint/visitor-keys@8.69.0': resolution: {integrity: sha512-+rmdgPA+EXkNgKYvHvFfhrs35utXbwaC5PGpDquSXcoXQDKUA5UjV0LmTucG/4JXkM31BTu4TilHtrN8IVBe8w==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -723,6 +936,9 @@ packages: ajv@6.15.0: resolution: {integrity: sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==} + ajv@8.18.0: + resolution: {integrity: sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==} + ansi-escapes@7.3.0: resolution: {integrity: sha512-BvU8nYgGQBxcmMuEeUEmNTvrMVjJNSH7RgW24vXexN4Ven6qCvy4TntnvlnwnMLTVlcRQQdbRY8NKnaIoeWDNg==} engines: {node: '>=18'} @@ -742,6 +958,10 @@ packages: any-promise@1.3.0: resolution: {integrity: sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==} + are-docs-informative@0.1.1: + resolution: {integrity: sha512-sqRsNQBwbKLRX0jV5Cu5uzmtflf892n4Vukz7T659ebL4pz3mpOqCMU7lxMoBTFwnp10E3YB5ZcyHM41W5bcDA==} + engines: {node: '>=18'} + assertion-error@2.0.1: resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} engines: {node: '>=12'} @@ -807,6 +1027,10 @@ packages: resolution: {integrity: sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug==} engines: {node: '>=14'} + comment-parser@1.4.8: + resolution: {integrity: sha512-rKZTGo4fzKYna8UcL0isTg5wkBNla7bxTypLwZQXjIdi++IdP1OJ41rI5Mti3/jltkPujbu4i9LIARYA+zpotQ==} + engines: {node: '>= 12.0.0'} + cross-spawn@7.0.6: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} @@ -853,6 +1077,10 @@ packages: resolution: {integrity: sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q==} engines: {node: '>=18'} + es-errors@1.3.0: + resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} + engines: {node: '>= 0.4'} + es-module-lexer@2.3.2: resolution: {integrity: sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==} @@ -869,12 +1097,22 @@ packages: resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} engines: {node: '>=10'} + escape-string-regexp@5.0.0: + resolution: {integrity: sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==} + engines: {node: '>=12'} + eslint-config-prettier@10.1.8: resolution: {integrity: sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==} hasBin: true peerDependencies: eslint: '>=7.0.0' + eslint-plugin-jsdoc@64.3.6: + resolution: {integrity: sha512-lo7IXmgUUNy88SxW7KnJmmD2iPQBIRMomfCFPHidW1M3zNNVuzxlB2uoNrNyE1g4v2/L+RtYmiROPwQhSNzL8Q==} + engines: {node: ^22.22.2 || >=24.15.0} + peerDependencies: + eslint: ^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0 + eslint-plugin-prettier@5.5.6: resolution: {integrity: sha512-ifetmTcxWfz+4qRW3pH/ujdTq2jQIj59AxJMIN26K5avYgU8dxycUETQonWiW+wPrYXA0j3Try0l1CnwVQtDqQ==} engines: {node: ^14.18.0 || >=16.0.0} @@ -889,6 +1127,9 @@ packages: eslint-config-prettier: optional: true + eslint-plugin-tsdoc@0.5.2: + resolution: {integrity: sha512-BlvqjWZdBJDIPO/YU3zcPCF23CvjYT3gyu63yo6b609NNV3D1b6zceAREy2xnweuBoDpZcLNuPyAUq9cvx6bbQ==} + eslint-scope@9.1.2: resolution: {integrity: sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==} engines: {node: ^20.19.0 || ^22.13.0 || >=24} @@ -950,6 +1191,9 @@ packages: fast-levenshtein@2.0.6: resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} + fast-uri@3.1.7: + resolution: {integrity: sha512-dOvZVzjdZdz7phd9v6jCbwxrBW3fK6n8Rc0CtdmM4bumzMnxywBYhuph6J819RRw/ku+rLbelwfMunktuzVVHg==} + fdir@6.5.0: resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} engines: {node: '>=12.0.0'} @@ -980,6 +1224,9 @@ packages: engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} os: [darwin] + function-bind@1.1.2: + resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + get-caller-file@2.0.5: resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} engines: {node: 6.* || 8.* || >= 10.*} @@ -1004,6 +1251,10 @@ packages: resolution: {integrity: sha512-iZyKG96/JwPz1N55vj2Ie2vXbhu440zfUfJvSwEqEbeLluk7NnapfGqa7LH0mOsnDxTF85Mx8/dyR6HfqcbmbQ==} engines: {node: '>=20'} + hasown@2.0.4: + resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==} + engines: {node: '>= 0.4'} + highlight.js@10.7.3: resolution: {integrity: sha512-tzcUFauisWKNHaRkN4Wjl/ZA07gENAjFl3J/c480dprkGTg5EQstgaNFqBfUqCq54kZRIEcreTsAgF/m2quD7A==} @@ -1016,6 +1267,9 @@ packages: hookified@2.2.0: resolution: {integrity: sha512-p/LgFzRN5FeoD3DLS6bkUapeye6E4SI6yJs6KetENd18S+FBthqYq2amJUWpt5z0EQwwHemidjY5OqJGEKm5uA==} + html-entities@2.6.0: + resolution: {integrity: sha512-kig+rMn/QOVRvr7c86gQ8lWXq+Hkv6CbAH1hLu+RG338StTpE8Z0b44SDVaqVu7HGKf27frdmUYEs9hTUX/cLQ==} + ignore@5.3.2: resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} engines: {node: '>= 4'} @@ -1032,6 +1286,10 @@ packages: resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} engines: {node: '>=0.8.19'} + is-core-module@2.16.2: + resolution: {integrity: sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==} + engines: {node: '>= 0.4'} + is-extglob@2.1.1: resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} engines: {node: '>=0.10.0'} @@ -1051,9 +1309,19 @@ packages: resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==} hasBin: true + jju@1.4.0: + resolution: {integrity: sha512-8wb9Yw966OSxApiCt0K3yNJL8pnNeIv+OEq2YMidz4FKP6nonSRoOXc80iXY4JaN2FC11B9qsNmDsm+ZOfMROA==} + + jsdoc-type-pratt-parser@9.2.1: + resolution: {integrity: sha512-V4Ww4EHnTcTLSOMoB0FsF72JhQvcAsriCm/LWnxJeGWoxIjEL2l9na11abQok5SYShq8m0Gl02el/xAbTCulvQ==} + engines: {node: ^22.22.2 || >=24.15.0} + json-schema-traverse@0.4.1: resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} + json-schema-traverse@1.0.0: + resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} + json-stable-stringify-without-jsonify@1.0.1: resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} @@ -1186,6 +1454,9 @@ packages: resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} engines: {node: '>=0.10.0'} + object-deep-merge@2.0.1: + resolution: {integrity: sha512-aKttDKcU3pyZqKcCkDhsMn70WmZFG2JGDQLP9EcLyTSIFQRCPWLAmBZRLJnrVUrhPG1jETEEbfdgbNtJf1LyMg==} + obug@2.1.4: resolution: {integrity: sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==} engines: {node: '>=12.20.0'} @@ -1202,6 +1473,12 @@ packages: resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} engines: {node: '>=10'} + parse-imports-exports@0.2.4: + resolution: {integrity: sha512-4s6vd6dx1AotCx/RCI2m7t7GCh5bDRUtGNvRfHSP2wbBQdMi67pPe7mtzmgwcaQ8VKK/6IB7Glfyu3qdZJPybQ==} + + parse-statements@1.0.11: + resolution: {integrity: sha512-HlsyYdMBnbPQ9Jr/VgJ1YF4scnldvJpJxCVx6KgqPL4dxppsWrJHCIIxQXMJrqGnsRkNPATbeMJ8Yxu7JMsYcA==} + parse5-htmlparser2-tree-adapter@6.0.1: resolution: {integrity: sha512-qPuWvbLgvDGilKc5BoicRovlT4MtYT6JfJyBOMDsKoiT+GiuP5qyrPCnR9HcPECIJJmZh5jRndyNThnhhb/vlA==} @@ -1219,6 +1496,9 @@ packages: resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} engines: {node: '>=8'} + path-parse@1.0.7: + resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} + picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} @@ -1258,9 +1538,22 @@ packages: resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} engines: {node: '>=0.10.0'} + require-from-string@2.0.2: + resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} + engines: {node: '>=0.10.0'} + + reserved-identifiers@1.2.0: + resolution: {integrity: sha512-yE7KUfFvaBFzGPs5H3Ops1RevfUEsDc5Iz65rOwWg4lE8HJSYtle77uul3+573457oHvBKuHYDl/xqUkKpEEdw==} + engines: {node: '>=18'} + resolve-pkg-maps@1.0.0: resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} + resolve@1.22.12: + resolution: {integrity: sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==} + engines: {node: '>= 0.4'} + hasBin: true + rolldown-plugin-dts@0.28.5: resolution: {integrity: sha512-yYd3C9CeJwqjOc9X23m0Tyxcqic491uLZlfg51szT287S8zCCqLR2uoySoElgqy2CLn7PdXcEo1dlkBs4n1WHg==} engines: {node: ^22.18.0 || ^24.11.0 || >=26.0.0} @@ -1309,6 +1602,15 @@ packages: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} + spdx-exceptions@2.5.0: + resolution: {integrity: sha512-PiU42r+xO4UbUS1buo3LPJkjlO7430Xn5SVAhdpzzsPHsjbYVflnnFdATgabnLude+Cqu25p6N+g2lw/PFsa4w==} + + spdx-expression-parse@5.0.0: + resolution: {integrity: sha512-vngmw3Rgn+o2arXNbnZaj5UtOEBuWBfvaI+Wc8GFfykIhA5/vdK9/Sp/XkLv63dykz2rxKDvKEHupF5P0FORcQ==} + + spdx-license-ids@3.0.23: + resolution: {integrity: sha512-CWLcCCH7VLu13TgOH+r8p1O/Znwhqv/dbb6lqWy67G+pT1kHmeD/+V36AVb/vq8QMIQwVShJ6Ssl5FPh0fuSdw==} + stackback@0.0.2: resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} @@ -1331,6 +1633,10 @@ packages: resolution: {integrity: sha512-zFObLMyZeEwzAoKCyu1B91U79K2t7ApXuQfo8OuxwXLDgcKxuwM+YvcbIhm6QWqz7mHUH1TVytR1PwVVjEuMig==} engines: {node: '>=14.18'} + supports-preserve-symlinks-flag@1.0.0: + resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} + engines: {node: '>= 0.4'} + synckit@0.11.13: resolution: {integrity: sha512-eNRKgb3z66Yp3D2CixVujOUvXLFUTij/zVnV8KRyvFdQwpz7I5DS8UfRkTeLzb64u+dkzDSdelE24izu+zSSUg==} engines: {node: ^14.18.0 || >=16.0.0} @@ -1358,6 +1664,10 @@ packages: resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} engines: {node: '>=12.0.0'} + to-valid-identifier@1.0.0: + resolution: {integrity: sha512-41wJyvKep3yT2tyPqX/4blcfybknGB4D+oETKLs7Q76UiPqRpUJK3hr1nxelyYO0PHKVzJwlu0aCeEAsGI6rpw==} + engines: {node: '>=20'} + tree-kill@1.2.2: resolution: {integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==} hasBin: true @@ -1624,6 +1934,16 @@ snapshots: '@cto.af/wtf8@0.0.5': {} + '@es-joy/jsdoccomment@0.97.0': + dependencies: + '@types/estree': 1.0.9 + '@typescript-eslint/types': 8.69.0 + comment-parser: 1.4.8 + esquery: 1.7.0 + jsdoc-type-pratt-parser: 9.2.1 + + '@es-joy/resolve.exports@1.2.0': {} + '@esbuild/aix-ppc64@0.28.2': optional: true @@ -1736,11 +2056,13 @@ snapshots: '@eslint/core': 1.2.1 levn: 0.4.1 - '@exadev/eslint-config@2.10.6(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)': + '@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) 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) @@ -1784,6 +2106,15 @@ snapshots: dependencies: '@braidai/lang': 1.1.2 + '@microsoft/tsdoc-config@0.18.1': + dependencies: + '@microsoft/tsdoc': 0.16.0 + ajv: 8.18.0 + jju: 1.4.0 + resolve: 1.22.12 + + '@microsoft/tsdoc@0.16.0': {} + '@oxc-project/types@0.148.0': {} '@pkgr/core@0.3.6': {} @@ -1839,6 +2170,8 @@ snapshots: '@rolldown/pluginutils@1.0.1': {} + '@sindresorhus/base62@1.0.0': {} + '@sindresorhus/is@4.6.0': {} '@turbo/darwin-64@2.10.12': @@ -1904,6 +2237,15 @@ snapshots: transitivePeerDependencies: - supports-color + '@typescript-eslint/project-service@8.56.1(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 + typescript: 6.0.3 + transitivePeerDependencies: + - supports-color + '@typescript-eslint/project-service@8.69.0(typescript@6.0.3)': dependencies: '@typescript-eslint/tsconfig-utils': 8.69.0(typescript@6.0.3) @@ -1913,11 +2255,20 @@ snapshots: transitivePeerDependencies: - supports-color + '@typescript-eslint/scope-manager@8.56.1': + dependencies: + '@typescript-eslint/types': 8.56.1 + '@typescript-eslint/visitor-keys': 8.56.1 + '@typescript-eslint/scope-manager@8.69.0': dependencies: '@typescript-eslint/types': 8.69.0 '@typescript-eslint/visitor-keys': 8.69.0 + '@typescript-eslint/tsconfig-utils@8.56.1(typescript@6.0.3)': + dependencies: + typescript: 6.0.3 + '@typescript-eslint/tsconfig-utils@8.69.0(typescript@6.0.3)': dependencies: typescript: 6.0.3 @@ -1934,8 +2285,25 @@ snapshots: transitivePeerDependencies: - supports-color + '@typescript-eslint/types@8.56.1': {} + '@typescript-eslint/types@8.69.0': {} + '@typescript-eslint/typescript-estree@8.56.1(typescript@6.0.3)': + dependencies: + '@typescript-eslint/project-service': 8.56.1(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 + 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/typescript-estree@8.69.0(typescript@6.0.3)': dependencies: '@typescript-eslint/project-service': 8.69.0(typescript@6.0.3) @@ -1951,6 +2319,17 @@ snapshots: transitivePeerDependencies: - supports-color + '@typescript-eslint/utils@8.56.1(eslint@10.10.0(jiti@2.7.0))(typescript@6.0.3)': + dependencies: + '@eslint-community/eslint-utils': 4.10.1(eslint@10.10.0(jiti@2.7.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: 6.0.3 + transitivePeerDependencies: + - supports-color + '@typescript-eslint/utils@8.69.0(eslint@10.10.0(jiti@2.7.0))(typescript@6.0.3)': dependencies: '@eslint-community/eslint-utils': 4.10.1(eslint@10.10.0(jiti@2.7.0)) @@ -1962,6 +2341,11 @@ snapshots: transitivePeerDependencies: - supports-color + '@typescript-eslint/visitor-keys@8.56.1': + dependencies: + '@typescript-eslint/types': 8.56.1 + eslint-visitor-keys: 5.0.1 + '@typescript-eslint/visitor-keys@8.69.0': dependencies: '@typescript-eslint/types': 8.69.0 @@ -2065,6 +2449,13 @@ snapshots: json-schema-traverse: 0.4.1 uri-js: 4.4.1 + ajv@8.18.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-uri: 3.1.7 + json-schema-traverse: 1.0.0 + require-from-string: 2.0.2 + ansi-escapes@7.3.0: dependencies: environment: 1.1.0 @@ -2079,6 +2470,8 @@ snapshots: any-promise@1.3.0: {} + are-docs-informative@0.1.1: {} + assertion-error@2.0.1: {} balanced-match@4.0.4: {} @@ -2143,6 +2536,8 @@ snapshots: commander@10.0.1: {} + comment-parser@1.4.8: {} + cross-spawn@7.0.6: dependencies: path-key: 3.1.1 @@ -2169,6 +2564,8 @@ snapshots: environment@1.1.0: {} + es-errors@1.3.0: {} + es-module-lexer@2.3.2: {} esbuild@0.28.2: @@ -2205,10 +2602,34 @@ snapshots: escape-string-regexp@4.0.0: {} + escape-string-regexp@5.0.0: {} + eslint-config-prettier@10.1.8(eslint@10.10.0(jiti@2.7.0)): dependencies: 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): + 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) + are-docs-informative: 0.1.1 + comment-parser: 1.4.8 + debug: 4.4.3 + escape-string-regexp: 5.0.0 + eslint: 10.10.0(jiti@2.7.0) + espree: 11.2.0 + esquery: 1.7.0 + html-entities: 2.6.0 + object-deep-merge: 2.0.1 + parse-imports-exports: 0.2.4 + semver: 7.8.5 + spdx-expression-parse: 5.0.0 + to-valid-identifier: 1.0.0 + transitivePeerDependencies: + - 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): dependencies: eslint: 10.10.0(jiti@2.7.0) @@ -2218,6 +2639,16 @@ snapshots: optionalDependencies: eslint-config-prettier: 10.1.8(eslint@10.10.0(jiti@2.7.0)) + eslint-plugin-tsdoc@0.5.2(eslint@10.10.0(jiti@2.7.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) + transitivePeerDependencies: + - eslint + - supports-color + - typescript + eslint-scope@9.1.2: dependencies: '@types/esrecurse': 4.3.1 @@ -2298,6 +2729,8 @@ snapshots: fast-levenshtein@2.0.6: {} + fast-uri@3.1.7: {} + fdir@6.5.0(picomatch@4.0.7): optionalDependencies: picomatch: 4.0.7 @@ -2324,6 +2757,8 @@ snapshots: fsevents@2.3.3: optional: true + function-bind@1.1.2: {} + get-caller-file@2.0.5: {} get-tsconfig@5.0.0-beta.6: @@ -2342,6 +2777,10 @@ snapshots: dependencies: hookified: 1.15.1 + hasown@2.0.4: + dependencies: + function-bind: 1.1.2 + highlight.js@10.7.3: {} hookable@6.1.1: {} @@ -2350,6 +2789,8 @@ snapshots: hookified@2.2.0: {} + html-entities@2.6.0: {} + ignore@5.3.2: {} ignore@7.0.8: {} @@ -2358,6 +2799,10 @@ snapshots: imurmurhash@0.1.4: {} + is-core-module@2.16.2: + dependencies: + hasown: 2.0.4 + is-extglob@2.1.1: {} is-fullwidth-code-point@3.0.0: {} @@ -2370,8 +2815,17 @@ snapshots: jiti@2.7.0: {} + jju@1.4.0: {} + + jsdoc-type-pratt-parser@9.2.1: + dependencies: + '@types/estree': 1.0.9 + '@types/node': 26.4.1 + json-schema-traverse@0.4.1: {} + json-schema-traverse@1.0.0: {} + json-stable-stringify-without-jsonify@1.0.1: {} keyv@5.6.0: @@ -2480,6 +2934,8 @@ snapshots: object-assign@4.1.1: {} + object-deep-merge@2.0.1: {} + obug@2.1.4: {} optionator@0.9.4: @@ -2499,6 +2955,12 @@ snapshots: dependencies: p-limit: 3.1.0 + parse-imports-exports@0.2.4: + dependencies: + parse-statements: 1.0.11 + + parse-statements@1.0.11: {} + parse5-htmlparser2-tree-adapter@6.0.1: dependencies: parse5: 6.0.1 @@ -2511,6 +2973,8 @@ snapshots: path-key@3.1.1: {} + path-parse@1.0.7: {} + picocolors@1.1.1: {} picomatch@4.0.7: {} @@ -2539,8 +3003,19 @@ snapshots: require-directory@2.1.1: {} + require-from-string@2.0.2: {} + + reserved-identifiers@1.2.0: {} + resolve-pkg-maps@1.0.0: {} + resolve@1.22.12: + dependencies: + es-errors: 1.3.0 + is-core-module: 2.16.2 + path-parse: 1.0.7 + supports-preserve-symlinks-flag: 1.0.0 + rolldown-plugin-dts@0.28.5(rolldown@1.2.7)(typescript@6.0.3): dependencies: dts-resolver: 3.0.0 @@ -2592,6 +3067,15 @@ snapshots: source-map-js@1.2.1: {} + spdx-exceptions@2.5.0: {} + + spdx-expression-parse@5.0.0: + dependencies: + spdx-exceptions: 2.5.0 + spdx-license-ids: 3.0.23 + + spdx-license-ids@3.0.23: {} + stackback@0.0.2: {} std-env@4.2.0: {} @@ -2615,6 +3099,8 @@ snapshots: has-flag: 4.0.0 supports-color: 7.2.0 + supports-preserve-symlinks-flag@1.0.0: {} + synckit@0.11.13: dependencies: '@pkgr/core': 0.3.6 @@ -2638,6 +3124,11 @@ snapshots: fdir: 6.5.0(picomatch@4.0.7) picomatch: 4.0.7 + to-valid-identifier@1.0.0: + dependencies: + '@sindresorhus/base62': 1.0.0 + reserved-identifiers: 1.2.0 + tree-kill@1.2.2: {} ts-api-utils@2.5.0(typescript@6.0.3): diff --git a/conformance/pnpm-workspace.yaml b/conformance/pnpm-workspace.yaml new file mode 100644 index 0000000..8837151 --- /dev/null +++ b/conformance/pnpm-workspace.yaml @@ -0,0 +1,6 @@ +# Left disallowed, matching this package's existing behaviour under pnpm 10 (which only ever warned "Ignored build scripts" for esbuild and installed successfully without running it). +allowBuilds: + esbuild: false +minimumReleaseAgeExclude: + - '@exadev/eslint-config' +saveExact: true diff --git a/ts/.npmrc b/ts/.npmrc new file mode 100644 index 0000000..98d78df --- /dev/null +++ b/ts/.npmrc @@ -0,0 +1,2 @@ +save-exact=true +minimum-release-age-exclude[]=@exadev/eslint-config diff --git a/ts/package.json b/ts/package.json index f547384..1796f3f 100644 --- a/ts/package.json +++ b/ts/package.json @@ -2,7 +2,7 @@ "name": "wire-mesh-ts", "version": "0.0.0", "private": true, - "packageManager": "pnpm@10.33.0", + "packageManager": "pnpm@12.4.1+sha512.2e81e399d73fe8390dab25e06aa788ab7a5908248d2f5a370f82b481147a6a7a367bf8048f9a6fdb6460f21a66f0542dedb8b94ca2c8723596741920b1656d4c", "scripts": { "build": "turbo run _build", "test": "turbo run _test", @@ -12,10 +12,5 @@ }, "devDependencies": { "turbo": "2.10.12" - }, - "pnpm": { - "overrides": { - "conventional-changelog-writer": "^9.2.0" - } } } diff --git a/ts/packages/cloudflare-hub/package.json b/ts/packages/cloudflare-hub/package.json index 0b1f5fb..8cf4039 100644 --- a/ts/packages/cloudflare-hub/package.json +++ b/ts/packages/cloudflare-hub/package.json @@ -3,7 +3,7 @@ "version": "0.0.0", "private": true, "type": "module", - "packageManager": "pnpm@10.33.0", + "packageManager": "pnpm@12.4.1+sha512.2e81e399d73fe8390dab25e06aa788ab7a5908248d2f5a370f82b481147a6a7a367bf8048f9a6fdb6460f21a66f0542dedb8b94ca2c8723596741920b1656d4c", "scripts": { "build": "turbo run _build", "_build": "wrangler deploy --dry-run --outdir=dist", @@ -16,12 +16,12 @@ "_lint": "eslint . --fix --cache --max-warnings 0" }, "dependencies": { - "wire-mesh-core": "workspace:*", - "cbor2": "2.3.0" + "cbor2": "2.3.0", + "wire-mesh-core": "workspace:*" }, "devDependencies": { "@cloudflare/workers-types": "5.20260905.1", - "@exadev/eslint-config": "2.10.6", + "@exadev/eslint-config": "2.12.1", "@types/node": "26.4.1", "eslint": "10.10.0", "eslint-config-prettier": "10.1.8", diff --git a/ts/packages/core/package.json b/ts/packages/core/package.json index fbc19a9..0f87aa2 100644 --- a/ts/packages/core/package.json +++ b/ts/packages/core/package.json @@ -2,7 +2,7 @@ "name": "wire-mesh-core", "version": "0.0.0", "type": "module", - "packageManager": "pnpm@10.33.0", + "packageManager": "pnpm@12.4.1+sha512.2e81e399d73fe8390dab25e06aa788ab7a5908248d2f5a370f82b481147a6a7a367bf8048f9a6fdb6460f21a66f0542dedb8b94ca2c8723596741920b1656d4c", "repository": { "type": "git", "url": "https://github.com/ExaDev/wire-mesh.git", @@ -38,7 +38,7 @@ }, "devDependencies": { "@arethetypeswrong/cli": "0.18.5", - "@exadev/eslint-config": "2.10.6", + "@exadev/eslint-config": "2.12.1", "@semantic-release/commit-analyzer": "13.0.1", "@semantic-release/github": "12.0.9", "@semantic-release/npm": "13.1.5", diff --git a/ts/packages/core/test/mesh-session-accept.test.ts b/ts/packages/core/test/mesh-session-accept.test.ts new file mode 100644 index 0000000..33451ba --- /dev/null +++ b/ts/packages/core/test/mesh-session-accept.test.ts @@ -0,0 +1,145 @@ +import { describe, expect, it } from "vitest"; +import type { GossipFrame, HandshakeFrame } from "../src/generated/protocol.js"; +import { acceptMeshSession } from "../src/domain/mesh-session.js"; +import { + EVENTS_THROUGH_REMOTE_HANDSHAKE, + FakeConnection, + deviceA, + deviceB, + gossipFor, + nthEvent, + testClock, + testIdentity, +} from "./mesh-session-fixtures.js"; + +describe("acceptMeshSession", () => { + it("wires up handshake and self-advert immediately, with no dial step at all", async () => { + const fake = new FakeConnection(); + const session = await acceptMeshSession( + fake.connection, + testIdentity, + ["core/data"], + { clock: testClock, label: "peer-over-tcp" }, + ); + + expect(fake.sent[0]).toEqual({ + type: "handshake", + version: 1, + domains: ["core/data"], + } satisfies HandshakeFrame); + const selfAdvert = fake.sent[1] as GossipFrame; + expect(selfAdvert.peers[0]?.device).toEqual(testIdentity.deviceId); + await session.close(); + }); + + it("advertises this node's own given addresses in its self-advert", async () => { + const fake = new FakeConnection(); + const session = await acceptMeshSession( + fake.connection, + testIdentity, + ["core/data"], + { clock: testClock, addresses: ["192.168.1.10:9000"] }, + ); + + const selfAdvert = fake.sent[1] as GossipFrame; + expect(selfAdvert.peers[0]?.addresses).toEqual(["192.168.1.10:9000"]); + await session.close(); + }); + + it("advertises no addresses in its self-advert when none are given, rather than a stray default", async () => { + const fake = new FakeConnection(); + const session = await acceptMeshSession(fake.connection, testIdentity, [ + "core/data", + ]); + + const selfAdvert = fake.sent[1] as GossipFrame; + expect(selfAdvert.peers[0]?.addresses).toEqual([]); + await session.close(); + }); + + it("labels its connection state 'accepted' when no label is given", async () => { + const fake = new FakeConnection(); + const session = await acceptMeshSession( + fake.connection, + testIdentity, + ["core/data"], + { clock: testClock }, + ); + const event = (await nthEvent(session, 1)) as { + state: { address: string }; + }; + expect(event.state.address).toBe("accepted"); + await session.close(); + }); + + it("negotiates against the remote's own handshake exactly like the dial side", async () => { + const fake = new FakeConnection(); + const session = await acceptMeshSession(fake.connection, testIdentity, [ + "core/management", + "core/data", + ]); + const eventsDone = nthEvent(session, EVENTS_THROUGH_REMOTE_HANDSHAKE - 1); + fake.push({ + type: "handshake", + version: 1, + domains: ["core/data", "core/exec"], + }); + const event = (await eventsDone) as { + state: { + status: string; + handshake: { status: string; sharedDomains: string[] }; + }; + }; + expect(event.state.status).toBe("connected"); + expect(event.state.handshake.status).toBe("negotiated"); + expect(event.state.handshake.sharedDomains).toEqual(["core/data"]); + await session.close(); + }); + + it("resolves peerDeviceId from the remote's own first self-advert", async () => { + const fake = new FakeConnection(); + const session = await acceptMeshSession(fake.connection, testIdentity, [ + "core/data", + ]); + fake.push(gossipFor(deviceB)); + await expect(session.peerDeviceId).resolves.toEqual(deviceB); + await session.close(); + }); + + it("peerDeviceId resolves from the first advert and never changes on a later one", async () => { + const fake = new FakeConnection(); + const session = await acceptMeshSession(fake.connection, testIdentity, [ + "core/data", + ]); + fake.push(gossipFor(deviceA)); + await expect(session.peerDeviceId).resolves.toEqual(deviceA); + fake.push(gossipFor(deviceB)); + // Same promise, already settled -- a second, different advert cannot retroactively change what it resolved to. + await expect(session.peerDeviceId).resolves.toEqual(deviceA); + await session.close(); + }); + + it("refuses connect(): the session is already connected by construction, with nothing to dial", async () => { + const fake = new FakeConnection(); + const session = await acceptMeshSession(fake.connection, testIdentity, [ + "core/data", + ]); + await expect(session.connect("ws://node", ["core/data"])).rejects.toThrow( + "connects once", + ); + await session.close(); + }); + + it("close() closes the underlying connection and rejects pending manage-requests, same as the dial side", async () => { + const fake = new FakeConnection(); + const session = await acceptMeshSession(fake.connection, testIdentity, [ + "core/data", + ]); + const pending = session.sendManageRequest( + { verb: "exec:proc", params: { verb: "exec.list" } }, + { kind: "folder" }, + ); + await session.close(); + await expect(pending).rejects.toThrow(); + }); +}); diff --git a/ts/packages/core/test/mesh-session-create.test.ts b/ts/packages/core/test/mesh-session-create.test.ts new file mode 100644 index 0000000..95e532f --- /dev/null +++ b/ts/packages/core/test/mesh-session-create.test.ts @@ -0,0 +1,551 @@ +import { describe, expect, it, vi } from "vitest"; +import type { GossipFrame, HandshakeFrame } from "../src/generated/protocol.js"; +import type { + Connection, + Listener, + Transport, +} from "../src/ports/transport.js"; +import { + HANDSHAKE_TIMEOUT_MS, + createMeshSession, +} from "../src/domain/mesh-session.js"; +import { + EVENTS_THROUGH_FAILURE, + EVENTS_THROUGH_PING_ROUND_TRIP, + EVENTS_THROUGH_REMOTE_HANDSHAKE, + EVENTS_THROUGH_THREE_GOSSIPS, + EVENTS_THROUGH_TIMEOUT, + FakeConnection, + MS_PER_SECOND, + SNAPSHOT_FIRST, + SNAPSHOT_SECOND, + SNAPSHOT_UPDATED, + TEST_CLOCK_NOW_MS, + TIMEOUT_MARKER, + deviceA, + deviceB, + fakeTransport, + gossipFor, + nthEvent, + testClock, + testIdentity, + testIdentityDeviceId, + withinShortWait, + yielded, +} from "./mesh-session-fixtures.js"; + +describe("createMeshSession", () => { + it("connects, sends the local handshake, and negotiates against the node's answer", async () => { + const { transport, connection } = fakeTransport(); + const session = createMeshSession(transport, testIdentity, testClock); + // Events: connecting, connected(handshake sent/pending), self-advert sent, connected/negotiated + const eventsDone = nthEvent(session, EVENTS_THROUGH_REMOTE_HANDSHAKE); + await session.connect("ws://node", ["core/management", "core/data"]); + + expect(connection.sent[0]).toEqual({ + type: "handshake", + version: 1, + domains: ["core/management", "core/data"], + } satisfies HandshakeFrame); + + const answer: HandshakeFrame = { + type: "handshake", + version: 1, + domains: ["core/data", "core/exec"], + }; + connection.push(answer); + const event = (await eventsDone) as { + state: { + status: string; + handshake: { status: string; sharedDomains: string[] }; + }; + }; + expect(event.state.status).toBe("connected"); + expect(event.state.handshake.status).toBe("negotiated"); + expect(event.state.handshake.sharedDomains).toEqual(["core/data"]); + await session.close(); + }); + + it("sends a self-advertisement gossip frame right after the handshake", async () => { + const { transport, connection } = fakeTransport(); + const session = createMeshSession(transport, testIdentity, testClock); + // Events: connecting, connected(handshake sent/pending), self-advert sent + const eventsDone = nthEvent(session, EVENTS_THROUGH_REMOTE_HANDSHAKE - 1); + await session.connect("ws://node", ["core/data"]); + await eventsDone; + + expect(connection.sent[0]?.type).toBe("handshake"); + const selfAdvert = connection.sent[1] as GossipFrame; + expect(selfAdvert).toEqual({ + type: "gossip", + peers: [ + { + device: testIdentityDeviceId, + addresses: [], + "snapshot-seconds": Math.floor(TEST_CLOCK_NOW_MS / MS_PER_SECOND), + }, + ], + } satisfies GossipFrame); + expect(selfAdvert.peers[0]?.device).toEqual(testIdentity.deviceId); + await session.close(); + }); + + it("advertises this node's own given addresses in its self-advert", async () => { + const { transport, connection } = fakeTransport(); + const session = createMeshSession( + transport, + testIdentity, + testClock, + null, + ["10.0.0.1:9000", "203.0.113.5:9000"], + ); + const eventsDone = nthEvent(session, EVENTS_THROUGH_REMOTE_HANDSHAKE - 1); + await session.connect("ws://node", ["core/data"]); + await eventsDone; + + const selfAdvert = connection.sent[1] as GossipFrame; + expect(selfAdvert.peers[0]?.addresses).toEqual([ + "10.0.0.1:9000", + "203.0.113.5:9000", + ]); + await session.close(); + }); + + it("sendGossipUpdate re-sends a fresh self-advert with the given extensions merged in", async () => { + const { transport, connection } = fakeTransport(); + const session = createMeshSession(transport, testIdentity, testClock); + await session.connect("ws://node", ["core/data"]); + + await session.sendGossipUpdate({ "presence/status": "idle" }); + + const updated = connection.sent.at(-1) as GossipFrame; + expect(updated).toEqual({ + type: "gossip", + peers: [ + { + device: testIdentityDeviceId, + addresses: [], + "snapshot-seconds": Math.floor(TEST_CLOCK_NOW_MS / MS_PER_SECOND), + "presence/status": "idle", + }, + ], + } satisfies GossipFrame); + await session.close(); + }); + + it("sendGossipUpdate rejects an extension key that collides with a mandatory peer-advert field", async () => { + const { transport } = fakeTransport(); + const session = createMeshSession(transport, testIdentity, testClock); + await session.connect("ws://node", ["core/data"]); + + await expect( + session.sendGossipUpdate({ device: "spoofed" }), + ).rejects.toThrow(/collides with a mandatory peer-advert field/); + await session.close(); + }); + + it("sendGossipUpdate rejects a bare, non-domain-qualified extension key", async () => { + const { transport } = fakeTransport(); + const session = createMeshSession(transport, testIdentity, testClock); + await session.connect("ws://node", ["core/data"]); + + await expect( + session.sendGossipUpdate({ presence: "idle" }), + ).rejects.toThrow(/must be domain-qualified/); + await session.close(); + }); + + it("sendGossipUpdate rejects an extension key that collides with the mandatory addresses field", async () => { + const { transport } = fakeTransport(); + const session = createMeshSession(transport, testIdentity, testClock); + await session.connect("ws://node", ["core/data"]); + + await expect(session.sendGossipUpdate({ addresses: [] })).rejects.toThrow( + /collides with a mandatory peer-advert field/, + ); + await session.close(); + }); + + it("sendGossipUpdate rejects an extension key that collides with the mandatory snapshot-seconds field", async () => { + const { transport } = fakeTransport(); + const session = createMeshSession(transport, testIdentity, testClock); + await session.connect("ws://node", ["core/data"]); + + await expect( + session.sendGossipUpdate({ "snapshot-seconds": 0 }), + ).rejects.toThrow(/collides with a mandatory peer-advert field/); + await session.close(); + }); + + it("sendGossipUpdate rejects an extension key with a domain-qualified prefix but trailing garbage after it", async () => { + const { transport } = fakeTransport(); + const session = createMeshSession(transport, testIdentity, testClock); + await session.connect("ws://node", ["core/data"]); + + await expect( + session.sendGossipUpdate({ "presence/status!": "idle" }), + ).rejects.toThrow(/must be domain-qualified/); + await session.close(); + }); + + it("sendGossipUpdate rejects an extension key that only matches a domain-qualified pattern partway through the string", async () => { + const { transport } = fakeTransport(); + const session = createMeshSession(transport, testIdentity, testClock); + await session.connect("ws://node", ["core/data"]); + + await expect( + session.sendGossipUpdate({ "1presence/status": "idle" }), + ).rejects.toThrow(/must be domain-qualified/); + await session.close(); + }); + + it("sendGossipUpdate with no extensions re-sends a plain self-advert", async () => { + const { transport, connection } = fakeTransport(); + const session = createMeshSession(transport, testIdentity, testClock); + await session.connect("ws://node", ["core/data"]); + + await session.sendGossipUpdate(); + + const updated = connection.sent.at(-1) as GossipFrame; + expect(updated).toEqual({ + type: "gossip", + peers: [ + { + device: testIdentityDeviceId, + addresses: [], + "snapshot-seconds": Math.floor(TEST_CLOCK_NOW_MS / MS_PER_SECOND), + }, + ], + } satisfies GossipFrame); + await session.close(); + }); + + it("emits a session event after sending a gossip update", async () => { + const { transport } = fakeTransport(); + const session = createMeshSession(transport, testIdentity, testClock); + const eventsDone = nthEvent(session, EVENTS_THROUGH_REMOTE_HANDSHAKE - 1); + await session.connect("ws://node", ["core/data"]); + await eventsDone; + + const eventsIterator = session.events[Symbol.asyncIterator](); + await session.sendGossipUpdate(); + // Must already be available -- not merely eventually rescued by session.close()'s own trailing emit(), which would otherwise mask a missing emit() call in sendGossipUpdate. + const result = await withinShortWait(eventsIterator.next()); + expect(result).not.toBe(TIMEOUT_MARKER); + const event = yielded( + result as IteratorResult<{ + frameLog: { direction: string; frame: { type: string } }[]; + }>, + ); + expect(event.frameLog.at(-1)?.direction).toBe("sent"); + expect(event.frameLog.at(-1)?.frame.type).toBe("gossip"); + await session.close(); + }); + + it("refuses sendGossipUpdate while not connected", async () => { + const { transport } = fakeTransport(); + const session = createMeshSession(transport, testIdentity, testClock); + await expect(session.sendGossipUpdate()).rejects.toThrow("not connected"); + }); + + it("refuses sendGossipUpdate once the connection has failed and closed, not just before the first connect", async () => { + const { transport, connection } = fakeTransport(); + const session = createMeshSession(transport, testIdentity, testClock); + await session.connect("ws://node", ["core/data"]); + connection.fail(new Error("dropped")); + await nthEvent(session, EVENTS_THROUGH_FAILURE); + await expect(session.sendGossipUpdate()).rejects.toThrow("not connected"); + }); + + it("excludes the retired core/federation domain even when both sides offer it", async () => { + const { transport, connection } = fakeTransport(); + const session = createMeshSession(transport, testIdentity, testClock); + // Events: connecting, connected, connected/rejected + const eventsDone = nthEvent(session, EVENTS_THROUGH_REMOTE_HANDSHAKE); + await session.connect("ws://node", ["core/federation"]); + connection.push({ + type: "handshake", + version: 1, + domains: ["core/federation"], + }); + const event = (await eventsDone) as { + state: { handshake: { status: string } }; + }; + expect(event.state.handshake.status).toBe("rejected"); + await session.close(); + }); + + it("marks the handshake unanswered after the timeout instead of hanging", async () => { + vi.useFakeTimers(); + try { + const { transport } = fakeTransport(); + const session = createMeshSession(transport, testIdentity, testClock); + await session.connect("ws://node", ["core/data"]); + await vi.advanceTimersByTimeAsync(HANDSHAKE_TIMEOUT_MS); + // Events so far: connecting, connected -- then the timeout emits unanswered + const event = (await nthEvent(session, EVENTS_THROUGH_TIMEOUT)) as { + state: { handshake: { status: string } }; + }; + expect(event.state.handshake.status).toBe("unanswered"); + await session.close(); + } finally { + vi.useRealTimers(); + } + }); + + it("assembles the peer directory from gossip, latest advert per device winning", async () => { + const { transport, connection } = fakeTransport(); + const session = createMeshSession(transport, testIdentity, testClock); + await session.connect("ws://node", ["core/data"]); + connection.push(gossipFor(deviceA, SNAPSHOT_FIRST)); + connection.push(gossipFor(deviceB, SNAPSHOT_SECOND)); + connection.push(gossipFor(deviceA, SNAPSHOT_UPDATED)); + // Events: connecting, connected, then one per gossip push -- drain to the last + const event = (await nthEvent(session, EVENTS_THROUGH_THREE_GOSSIPS)) as { + directory: { + device: Uint8Array; + advert: { "snapshot-seconds": number }; + }[]; + }; + expect(event.directory.length).toBe(2); + const entryA = event.directory.find( + (entry) => entry.advert["snapshot-seconds"] === SNAPSHOT_UPDATED, + ); + expect(entryA).toBeDefined(); + expect(event.directory[0]?.device).toEqual(deviceA); + expect(event.directory[1]?.device).toEqual(deviceB); + await session.close(); + }); + + it("records sent and received frames in the frame log in order", async () => { + const { transport, connection } = fakeTransport(); + const session = createMeshSession(transport, testIdentity, testClock); + await session.connect("ws://node", ["core/data"]); + await session.sendPing(); + connection.push({ type: "ping" }); + // Events: connecting, connected, ping sent, ping received + const event = (await nthEvent(session, EVENTS_THROUGH_PING_ROUND_TRIP)) as { + frameLog: { direction: string; frame: { type: string } }[]; + }; + expect(event.frameLog.map((entry) => entry.direction)).toEqual([ + "sent", + "sent", + "sent", + "received", + ]); + await session.close(); + }); + + it("closes with the node's reason when the receive iteration rejects", async () => { + const { transport, connection } = fakeTransport(); + const session = createMeshSession(transport, testIdentity, testClock); + await session.connect("ws://node", ["core/data"]); + connection.fail(new Error("node closed abruptly")); + // Events: connecting, connected, closed + const event = (await nthEvent(session, EVENTS_THROUGH_FAILURE)) as { + state: { status: string; reason: string }; + }; + expect(event.state.status).toBe("closed"); + expect(event.state.reason).toBe("node closed abruptly"); + }); + + it("refuses a second connect on the same session", async () => { + const { transport } = fakeTransport(); + const session = createMeshSession(transport, testIdentity, testClock); + await session.connect("ws://node", ["core/data"]); + await expect(session.connect("ws://node", ["core/data"])).rejects.toThrow( + "connects once", + ); + await session.close(); + }); + + it("refuses a ping while not connected", async () => { + const { transport } = fakeTransport(); + const session = createMeshSession(transport, testIdentity, testClock); + await expect(session.sendPing()).rejects.toThrow("not connected"); + }); + + it("refuses a ping once the connection has failed and the session is closed, not just before the first connect", async () => { + const { transport, connection } = fakeTransport(); + const session = createMeshSession(transport, testIdentity, testClock); + await session.connect("ws://node", ["core/data"]); + connection.fail(new Error("dropped")); + await nthEvent(session, EVENTS_THROUGH_FAILURE); + await expect(session.sendPing()).rejects.toThrow("not connected"); + }); + + it("close() while connected finalizes state as closed by you, and actually closes the underlying connection", async () => { + const { transport, connection } = fakeTransport(); + const session = createMeshSession(transport, testIdentity, testClock); + const eventsDone = nthEvent(session, EVENTS_THROUGH_FAILURE); + await session.connect("ws://node", ["core/data"]); + await session.close(); + const event = (await eventsDone) as { + state: { status: string; reason?: string }; + }; + expect(event.state.status).toBe("closed"); + expect((event.state as { reason: string }).reason).toBe("closed by you"); + expect(connection.isClosed).toBe(true); + }); + + it("closes a dial that only completes after close() was already called, instead of wiring it up", async () => { + // A plain `let` reassigned only inside the Promise executor below loses its non-null narrowing by the time it's called several `await`s later -- an object property isn't narrowed the same way a bare closed-over variable is, so this sidesteps that entirely. + const dialResolver: { + resolve: ((connection: Connection) => void) | null; + } = { resolve: null }; + const transport: Transport = { + connect: async (): Promise => + new Promise((resolve) => { + dialResolver.resolve = resolve; + }), + listen: async (): Promise => + Promise.reject(new Error("client-only transport")), + }; + const session = createMeshSession(transport, testIdentity, testClock); + const eventsIterator = session.events[Symbol.asyncIterator](); + const connectPromise = session.connect("ws://node", ["core/data"]); + await eventsIterator.next(); // connecting + await session.close(); + const lateConnection = new FakeConnection(); + if (dialResolver.resolve === null) + throw new Error("expected resolveDial to be set"); + dialResolver.resolve(lateConnection.connection); + await connectPromise; + expect(lateConnection.sent).toHaveLength(0); + expect(lateConnection.isClosed).toBe(true); + const closedEvent = yielded(await eventsIterator.next()) as { + state: { status: string; reason?: string }; + }; + expect(closedEvent.state.status).toBe("closed"); + expect((closedEvent.state as { reason: string }).reason).toBe( + "closed by you", + ); + }); + + it("treats a clean end of the receive stream as a disconnect when still connected", async () => { + const { transport, connection } = fakeTransport(); + const session = createMeshSession(transport, testIdentity, testClock); + await session.connect("ws://node", ["core/data"]); + const eventsDone = nthEvent(session, EVENTS_THROUGH_FAILURE); + connection.endStream(); + const event = (await eventsDone) as { + state: { status: string; reason: string }; + }; + expect(event.state.status).toBe("closed"); + expect(event.state.reason).toBe("node closed the connection"); + }); + + it("ignores a frame that was already queued when close() is called, instead of applying it", async () => { + const { transport, connection } = fakeTransport(); + const session = createMeshSession(transport, testIdentity, testClock); + const iterator = session.events[Symbol.asyncIterator](); + await session.connect("ws://node", ["core/data"]); + connection.push(gossipFor(deviceA)); + await session.close(); + for (let i = 0; i < EVENTS_THROUGH_FAILURE; i++) { + await iterator.next(); + } + const result = await withinShortWait(iterator.next()); + expect(result).toBe(TIMEOUT_MARKER); + }); + + it("does not negotiate against a second handshake frame once the first has already settled the outcome", async () => { + const { transport, connection } = fakeTransport(); + const session = createMeshSession(transport, testIdentity, testClock); + const eventsDone = nthEvent(session, EVENTS_THROUGH_REMOTE_HANDSHAKE); + await session.connect("ws://node", ["core/management", "core/data"]); + connection.push({ + type: "handshake", + version: 1, + domains: ["core/data", "core/exec"], + } satisfies HandshakeFrame); + await eventsDone; + + const secondEventDone = nthEvent(session, 1); + connection.push({ + type: "handshake", + version: 1, + domains: ["core/federation"], + } satisfies HandshakeFrame); + const event = (await secondEventDone) as { + state: { + status: string; + handshake: { status: string; sharedDomains: string[] }; + }; + }; + expect(event.state.handshake.status).toBe("negotiated"); + expect(event.state.handshake.sharedDomains).toEqual(["core/data"]); + await session.close(); + }); + + it("keeps the handshake negotiated after HANDSHAKE_TIMEOUT_MS elapses, instead of flipping to unanswered", async () => { + vi.useFakeTimers(); + try { + const { transport, connection } = fakeTransport(); + const session = createMeshSession(transport, testIdentity, testClock); + const eventsDone = nthEvent(session, EVENTS_THROUGH_REMOTE_HANDSHAKE); + await session.connect("ws://node", ["core/data"]); + connection.push({ + type: "handshake", + version: 1, + domains: ["core/data"], + } satisfies HandshakeFrame); + await eventsDone; + + await vi.advanceTimersByTimeAsync(HANDSHAKE_TIMEOUT_MS); + await session.sendPing(); + const lastFrame = connection.sent.at(-1) as { type: string }; + expect(lastFrame.type).toBe("ping"); + // sendPing itself would have thrown if state had reverted away from "connected", and negotiate() already proved the handshake status. A direct re-check below confirms the handshake status specifically stayed "negotiated". + const stillConnectedEvent = (await nthEvent(session, 1)) as { + state: { handshake?: { status: string } }; + }; + expect(stillConnectedEvent.state.handshake?.status).toBe("negotiated"); + await session.close(); + } finally { + vi.useRealTimers(); + } + }); + + it("records a specific reason when the handshake is rejected for sharing no domains or version", async () => { + const { transport, connection } = fakeTransport(); + const session = createMeshSession(transport, testIdentity, testClock); + const eventsDone = nthEvent(session, EVENTS_THROUGH_REMOTE_HANDSHAKE); + await session.connect("ws://node", ["core/federation"]); + connection.push({ + type: "handshake", + version: 1, + domains: ["core/federation"], + }); + const event = (await eventsDone) as { + state: { handshake: { status: string; reason?: string } }; + }; + expect(event.state.handshake.status).toBe("rejected"); + expect((event.state.handshake as { reason: string }).reason).toBe( + "no shared domains or version", + ); + await session.close(); + }); + + it("events iterator resolves a live event, delivered after the wait began, with done: false", async () => { + const { transport } = fakeTransport(); + const session = createMeshSession(transport, testIdentity, testClock); + const iterator = session.events[Symbol.asyncIterator](); + // No event has been emitted yet, so this call registers a waiter rather than draining the backlog. + const pending = iterator.next(); + await session.connect("ws://node", ["core/data"]); + const result = await pending; + expect(result.done).toBe(false); + await session.close(); + }); + + it("events iterator resolves a backlogged event, queued before anyone was iterating, with done: false", async () => { + const { transport } = fakeTransport(); + const session = createMeshSession(transport, testIdentity, testClock); + await session.connect("ws://node", ["core/data"]); + const iterator = session.events[Symbol.asyncIterator](); + const result = await iterator.next(); + expect(result.done).toBe(false); + await session.close(); + }); +}); diff --git a/ts/packages/core/test/mesh-session-fixtures.ts b/ts/packages/core/test/mesh-session-fixtures.ts new file mode 100644 index 0000000..40b3403 --- /dev/null +++ b/ts/packages/core/test/mesh-session-fixtures.ts @@ -0,0 +1,216 @@ +import type { Frame } from "../src/generated/protocol.js"; +import type { Clock } from "../src/ports/clock.js"; +import type { IdentityPort } from "../src/ports/identity.js"; +import type { + Connection, + Listener, + Transport, +} from "../src/ports/transport.js"; +import type { createMeshSession } from "../src/domain/mesh-session.js"; +import { deviceIdFromFillHex } from "./hex.js"; + +export const deviceA = deviceIdFromFillHex("11"); +export const deviceB = deviceIdFromFillHex("22"); + +export const testIdentityDeviceId = deviceIdFromFillHex("ee"); +export const testIdentity: IdentityPort = { + deviceId: testIdentityDeviceId, + identityKey: { alg: -7, "public-key": new Uint8Array() }, + sign: async () => Promise.resolve(new Uint8Array()), + verify: async () => Promise.resolve(true), + deriveDeviceId: async () => Promise.resolve(testIdentityDeviceId), +}; +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. +export const EVENTS_THROUGH_REMOTE_HANDSHAKE = 4; +export const EVENTS_THROUGH_TIMEOUT = 4; +export const EVENTS_THROUGH_THREE_GOSSIPS = 6; +export const EVENTS_THROUGH_PING_ROUND_TRIP = 5; +export const EVENTS_THROUGH_FAILURE = 4; +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. +export const RECONNECT_DELAY_MS = 100; +export const RECONNECT_MAX_ATTEMPTS = 2; +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. +export const TEST_TOKEN_SIGNATURE_BYTE = 3; +export const TEST_INCOMING_REQUEST_ID = 7; +export const OVERRIDE_TOKEN_BYTE = 9; +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; + + get connection(): Readonly { + 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(); + }, + }; + } + + /** True once this connection's own close() has actually been invoked -- lets a test assert that a caller closed the link, distinct from the link merely ending its receive stream on its own (see endStream). */ + get isClosed(): boolean { + return this.ended; + } + + /** Ends the receive stream as if the remote hung up cleanly, without going through this side's own close() -- unlike close(), this leaves the session's own state untouched so a test can observe how the session itself reacts to a graceful remote end. */ + endStream(): void { + this.ended = true; + this.wake(); + } + + push(frame: Frame): void { + this.inbound.push(frame); + this.wake(); + } + + fail(error: Error): void { + this.failure = error; + this.wake(); + } + + private readonly wakeWaiters: (() => void)[] = []; + + private wake(): void { + for (const wake of this.wakeWaiters.splice(0)) { + wake(); + } + } + + private stream(): AsyncIterable { + return { + [Symbol.asyncIterator]: () => ({ + next: async (): Promise> => this.nextFrame(), + }), + }; + } + + 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); + }); + } + } +} + +export function fakeTransport(): { + transport: Transport; + connection: FakeConnection; +} { + const connection = new FakeConnection(); + const transport: Transport = { + connect: async (address: string): Promise => { + 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 }; +} + +/** A transport that hands out a fresh FakeConnection on every connect() call, so one attempt's failure doesn't leak into the next -- unlike fakeTransport()'s single shared connection, which stays broken forever once failed. */ +export function multiConnectionTransport(): { + transport: Transport; + connections: FakeConnection[]; +} { + const connections: FakeConnection[] = []; + const transport: Transport = { + connect: async (address: string): Promise => { + if (address !== "ws://node") { + return Promise.reject(new Error(`connect to ${address} failed`)); + } + 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 }; +} + +/** Narrows an IteratorResult to its yielded value, failing the test outright if the iterator has actually ended -- none of this file's own async iterators ever end, so a done:true result always indicates a broken assumption in the test itself, never legitimate data. */ +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; +} + +export const TIMEOUT_MARKER = "timeout" as const; +export const SHORT_WAIT_MS = 20; + +/** Races a promise against a short real-time wait, resolving to TIMEOUT_MARKER if the promise hasn't settled yet -- used to prove a promise genuinely settled *now*, from the action just taken, rather than merely settling *eventually* by some unrelated later event (e.g. a trailing session.close() emitting one final event that would otherwise silently satisfy an unconsumed waiter and mask a missing emit() call). */ +export async function withinShortWait( + promise: Readonly>, +): Promise { + return Promise.race([ + promise, + new Promise((resolve) => { + setTimeout(() => { + resolve(TIMEOUT_MARKER); + }, SHORT_WAIT_MS); + }), + ]); +} + +/** Resolves after the session has emitted at least `count` events, returning the latest. */ +export async function nthEvent( + session: ReturnType, + count: number, +): Promise { + const iterator = session.events[Symbol.asyncIterator](); + let last: unknown = null; + for (let i = 0; i < count; i++) { + const result = await iterator.next(); + last = result.value; + } + return last; +} + +export function gossipFor( + device: Uint8Array, + seconds = 1861833600, +): Frame { + return { + type: "gossip", + peers: [ + { device, addresses: ["203.0.113.5:4433"], "snapshot-seconds": seconds }, + ], + }; +} diff --git a/ts/packages/core/test/mesh-session-manage.test.ts b/ts/packages/core/test/mesh-session-manage.test.ts new file mode 100644 index 0000000..e961064 --- /dev/null +++ b/ts/packages/core/test/mesh-session-manage.test.ts @@ -0,0 +1,364 @@ +import { describe, expect, it, vi } from "vitest"; +import type { + CapabilityScope, + CapabilityToken, + ManageCommand, + ManageRequestFrame, + ManageResponseFrame, +} from "../src/generated/protocol.js"; +import { + createMeshSession, + type IncomingManageRequest, + type ManageOutcome, +} from "../src/domain/mesh-session.js"; +import { + EVENTS_THROUGH_FAILURE, + EVENTS_THROUGH_REMOTE_HANDSHAKE, + MANAGE_REQUEST_TIMEOUT_MS, + OVERRIDE_TOKEN_BYTE, + TEST_INCOMING_REQUEST_ID, + TEST_TOKEN_SIGNATURE_BYTE, + TIMEOUT_MARKER, + fakeTransport, + nthEvent, + testClock, + testIdentity, + withinShortWait, + yielded, +} from "./mesh-session-fixtures.js"; + +describe("capability tokens and manage-request plumbing", () => { + const testCommand: ManageCommand = { + verb: "exec:proc", + params: { verb: "exec.list" }, + }; + const testScope: CapabilityScope = { kind: "folder" }; + const testToken: CapabilityToken = [ + new Uint8Array([1]), + {}, + new Uint8Array([2]), + new Uint8Array([TEST_TOKEN_SIGNATURE_BYTE]), + ]; + + it("attaches the current token to every manage-request it sends", async () => { + const { transport, connection } = fakeTransport(); + const session = createMeshSession(transport, testIdentity, testClock); + await session.connect("ws://node", ["core/management"]); + session.setToken(testToken); + const pending = session.sendManageRequest(testCommand, testScope); + await Promise.resolve(); + const sentRequest = connection.sent.at(-1) as ManageRequestFrame; + expect(sentRequest.type).toBe("manage-request"); + expect(sentRequest.command).toEqual(testCommand); + expect(sentRequest.scope).toEqual(testScope); + expect(sentRequest.token).toEqual(testToken); + await session.close(); + await expect(pending).rejects.toThrow(); + }); + + it("does not attach a token to a manage-request when none has been set", async () => { + const { transport, connection } = fakeTransport(); + const session = createMeshSession(transport, testIdentity, testClock); + await session.connect("ws://node", ["core/management"]); + const pending = session.sendManageRequest(testCommand, testScope); + await Promise.resolve(); + const sentRequest = connection.sent.at(-1) as ManageRequestFrame; + expect(sentRequest.token).toBeUndefined(); + await session.close(); + await expect(pending).rejects.toThrow(); + }); + + it("attaches a per-call token override even when no session-global token has been set", async () => { + const { transport, connection } = fakeTransport(); + const session = createMeshSession(transport, testIdentity, testClock); + await session.connect("ws://node", ["core/management"]); + const pending = session.sendManageRequest( + testCommand, + testScope, + undefined, + testToken, + ); + await Promise.resolve(); + const sentRequest = connection.sent.at(-1) as ManageRequestFrame; + expect(sentRequest.token).toEqual(testToken); + await session.close(); + await expect(pending).rejects.toThrow(); + }); + + it("a per-call token override takes precedence over the session-global token for that one request only", async () => { + const { transport, connection } = fakeTransport(); + const session = createMeshSession(transport, testIdentity, testClock); + await session.connect("ws://node", ["core/management"]); + session.setToken(testToken); + const overrideToken: CapabilityToken = [ + new Uint8Array([OVERRIDE_TOKEN_BYTE]), + {}, + new Uint8Array([OVERRIDE_TOKEN_BYTE]), + new Uint8Array([OVERRIDE_TOKEN_BYTE]), + ]; + + const overridden = session.sendManageRequest( + testCommand, + testScope, + undefined, + overrideToken, + ); + await Promise.resolve(); + expect((connection.sent.at(-1) as ManageRequestFrame).token).toEqual( + overrideToken, + ); + + // The very next request, with no override of its own, must fall back to setToken's session-global value -- the override applies to the one call it was passed to, not for the rest of the session. + const usingSessionDefault = session.sendManageRequest( + testCommand, + testScope, + ); + await Promise.resolve(); + expect((connection.sent.at(-1) as ManageRequestFrame).token).toEqual( + testToken, + ); + + await session.close(); + await expect(overridden).rejects.toThrow(); + await expect(usingSessionDefault).rejects.toThrow(); + }); + + it("assigns sequentially increasing request-ids to successive sendManageRequest calls", async () => { + const { transport, connection } = fakeTransport(); + const session = createMeshSession(transport, testIdentity, testClock); + await session.connect("ws://node", ["core/management"]); + const first = session.sendManageRequest(testCommand, testScope); + await Promise.resolve(); + const firstId = (connection.sent.at(-1) as ManageRequestFrame)[ + "request-id" + ]; + const second = session.sendManageRequest(testCommand, testScope); + await Promise.resolve(); + const secondId = (connection.sent.at(-1) as ManageRequestFrame)[ + "request-id" + ]; + expect(secondId).toBe(firstId + 1); + await session.close(); + await expect(first).rejects.toThrow(); + await expect(second).rejects.toThrow(); + }); + + it("refuses sendManageRequest while not connected", async () => { + const { transport } = fakeTransport(); + const session = createMeshSession(transport, testIdentity, testClock); + await expect( + session.sendManageRequest(testCommand, testScope), + ).rejects.toThrow("not connected"); + }); + + it("refuses sendManageRequest once the connection has failed and closed, not just before the first connect", async () => { + const { transport, connection } = fakeTransport(); + const session = createMeshSession(transport, testIdentity, testClock); + await session.connect("ws://node", ["core/management"]); + connection.fail(new Error("dropped")); + await nthEvent(session, EVENTS_THROUGH_FAILURE); + await expect( + session.sendManageRequest(testCommand, testScope), + ).rejects.toThrow("not connected"); + }); + + it("never times out a request when no timeoutMs is given", async () => { + vi.useFakeTimers(); + try { + const { transport, connection } = fakeTransport(); + const session = createMeshSession(transport, testIdentity, testClock); + await session.connect("ws://node", ["core/management"]); + const pending = session.sendManageRequest(testCommand, testScope); + await vi.advanceTimersByTimeAsync(1); + const sentRequest = connection.sent.at(-1) as ManageRequestFrame; + connection.push({ + type: "manage-response", + "request-id": sentRequest["request-id"], + outcome: { result: "ok" }, + } satisfies ManageResponseFrame); + await expect(pending).resolves.toEqual({ result: "ok" }); + await session.close(); + } finally { + vi.useRealTimers(); + } + }); + + it("resolves sendManageRequest only with the outcome of the matching manage-response", async () => { + const { transport, connection } = fakeTransport(); + const session = createMeshSession(transport, testIdentity, testClock); + await session.connect("ws://node", ["core/management"]); + const pending = session.sendManageRequest(testCommand, testScope); + await Promise.resolve(); + const sentRequest = connection.sent.at(-1) as ManageRequestFrame; + const requestId = sentRequest["request-id"]; + + // A response for a different request-id must not resolve this pending request. + connection.push({ + type: "manage-response", + "request-id": requestId + 1, + outcome: { result: "error", code: "wrong-request" }, + } satisfies ManageResponseFrame); + connection.push({ + type: "manage-response", + "request-id": requestId, + outcome: { result: "ok" }, + } satisfies ManageResponseFrame); + + const outcome: ManageOutcome = await pending; + expect(outcome).toEqual({ result: "ok" }); + await session.close(); + }); + + it("rejects a pending sendManageRequest when the session is closed before a response arrives", async () => { + const { transport } = fakeTransport(); + const session = createMeshSession(transport, testIdentity, testClock); + await session.connect("ws://node", ["core/management"]); + const pending = session.sendManageRequest(testCommand, testScope); + await session.close(); + await expect(pending).rejects.toThrow( + "connection closed before a response arrived", + ); + }); + + it("rejects a pending sendManageRequest when the connection disconnects before a response arrives", async () => { + const { transport, connection } = fakeTransport(); + const session = createMeshSession(transport, testIdentity, testClock); + await session.connect("ws://node", ["core/management"]); + const pending = session.sendManageRequest(testCommand, testScope); + connection.fail(new Error("dropped")); + await expect(pending).rejects.toThrow( + "disconnected before a response arrived", + ); + }); + + it("resolves with a timeout outcome, not a hang, when no response arrives within timeoutMs", async () => { + vi.useFakeTimers(); + try { + const { transport } = fakeTransport(); + const session = createMeshSession(transport, testIdentity, testClock); + await session.connect("ws://node", ["core/management"]); + const pending = session.sendManageRequest( + testCommand, + testScope, + undefined, + undefined, + MANAGE_REQUEST_TIMEOUT_MS, + ); + await vi.advanceTimersByTimeAsync(MANAGE_REQUEST_TIMEOUT_MS); + await expect(pending).resolves.toEqual({ + result: "error", + code: "timeout", + }); + await session.close(); + } finally { + vi.useRealTimers(); + } + }); + + it("does not time out a request whose response arrives before timeoutMs elapses", async () => { + vi.useFakeTimers(); + try { + const { transport, connection } = fakeTransport(); + const session = createMeshSession(transport, testIdentity, testClock); + await session.connect("ws://node", ["core/management"]); + const pending = session.sendManageRequest( + testCommand, + testScope, + undefined, + undefined, + MANAGE_REQUEST_TIMEOUT_MS, + ); + await vi.advanceTimersByTimeAsync(0); + const sentRequest = connection.sent.at(-1) as ManageRequestFrame; + connection.push({ + type: "manage-response", + "request-id": sentRequest["request-id"], + outcome: { result: "ok" }, + } satisfies ManageResponseFrame); + await expect(pending).resolves.toEqual({ result: "ok" }); + await vi.advanceTimersByTimeAsync(MANAGE_REQUEST_TIMEOUT_MS); + await session.close(); + } finally { + vi.useRealTimers(); + } + }); + + it("surfaces an incoming manage-request on incomingManageRequests, and sends the response frame from respond()", async () => { + const { transport, connection } = fakeTransport(); + const session = createMeshSession(transport, testIdentity, testClock); + const eventsDone = nthEvent(session, EVENTS_THROUGH_REMOTE_HANDSHAKE - 1); + await session.connect("ws://node", ["core/management"]); + await eventsDone; + + const incomingDone = (async (): Promise< + IteratorResult + > => { + const iterator = session.incomingManageRequests[Symbol.asyncIterator](); + return iterator.next(); + })(); + + connection.push({ + type: "manage-request", + "request-id": TEST_INCOMING_REQUEST_ID, + command: testCommand, + scope: testScope, + token: testToken, + } satisfies ManageRequestFrame); + + const incomingResult = await incomingDone; + expect(incomingResult.done).toBe(false); + const incoming = yielded(incomingResult); + expect(incoming.requestId).toBe(TEST_INCOMING_REQUEST_ID); + expect(incoming.command).toEqual(testCommand); + expect(incoming.scope).toEqual(testScope); + expect(incoming.token).toEqual(testToken); + + // Consume the "received manage-request" event that is already backlogged. + const eventsIterator = session.events[Symbol.asyncIterator](); + await eventsIterator.next(); + + await incoming.respond({ result: "ok" }); + const sentResponse = connection.sent.at(-1) as ManageResponseFrame; + expect(sentResponse).toEqual({ + type: "manage-response", + "request-id": TEST_INCOMING_REQUEST_ID, + outcome: { result: "ok" }, + } satisfies ManageResponseFrame); + // Must already be available -- not merely eventually rescued by session.close()'s own trailing emit(), which would otherwise mask a missing emit() call inside respond(). + const responseResult = await withinShortWait(eventsIterator.next()); + expect(responseResult).not.toBe(TIMEOUT_MARKER); + const responseEvent = yielded( + responseResult as IteratorResult<{ + frameLog: { direction: string; frame: { type: string } }[]; + }>, + ); + expect(responseEvent.frameLog.at(-1)).toEqual({ + direction: "sent", + frame: sentResponse, + }); + await session.close(); + }); + + it("delivers a manage-request queued before anyone was iterating incomingManageRequests, from the backlog", async () => { + const { transport, connection } = fakeTransport(); + const session = createMeshSession(transport, testIdentity, testClock); + const eventsDone = nthEvent(session, EVENTS_THROUGH_REMOTE_HANDSHAKE - 1); + await session.connect("ws://node", ["core/management"]); + await eventsDone; + + const nextEventDone = nthEvent(session, 1); + connection.push({ + type: "manage-request", + "request-id": TEST_INCOMING_REQUEST_ID, + command: testCommand, + scope: testScope, + } satisfies ManageRequestFrame); + await nextEventDone; + + const iterator = session.incomingManageRequests[Symbol.asyncIterator](); + const result = await iterator.next(); + expect(result.done).toBe(false); + expect(yielded(result).requestId).toBe(TEST_INCOMING_REQUEST_ID); + await session.close(); + }); +}); diff --git a/ts/packages/core/test/mesh-session-reconnect.test.ts b/ts/packages/core/test/mesh-session-reconnect.test.ts new file mode 100644 index 0000000..23e2dd7 --- /dev/null +++ b/ts/packages/core/test/mesh-session-reconnect.test.ts @@ -0,0 +1,220 @@ +import { describe, expect, it, vi } from "vitest"; +import type { + Connection, + Listener, + Transport, +} from "../src/ports/transport.js"; +import { + HANDSHAKE_TIMEOUT_MS, + createMeshSession, +} from "../src/domain/mesh-session.js"; +import { + EVENTS_PER_RECONNECT_ROUND, + EVENTS_THROUGH_FAILURE, + EVENTS_THROUGH_FIRST_RECONNECT, + EVENTS_THROUGH_STALE_TIMER_REGRESSION, + FakeConnection, + RECONNECT_DELAY_MS, + RECONNECT_MAX_ATTEMPTS, + fakeTransport, + multiConnectionTransport, + nthEvent, + testClock, + testIdentity, +} from "./mesh-session-fixtures.js"; + +describe("reconnect policy", () => { + it("never reconnects when no policy is given, matching today's default behavior", async () => { + const { transport, connection } = fakeTransport(); + const session = createMeshSession(transport, testIdentity, testClock); + await session.connect("ws://node", ["core/data"]); + const eventsDone = nthEvent(session, EVENTS_THROUGH_FAILURE); + connection.fail(new Error("dropped")); + const event = (await eventsDone) as { + state: { status: string; reason: string }; + }; + expect(event.state.status).toBe("closed"); + expect(event.state.reason).toBe("dropped"); + }); + + it("retries with backoff through correctly-numbered attempts, then gives up once max attempts are exhausted", async () => { + vi.useFakeTimers(); + try { + const { transport, connection } = fakeTransport(); + const session = createMeshSession(transport, testIdentity, testClock, { + maxAttempts: RECONNECT_MAX_ATTEMPTS, + delayMs: () => RECONNECT_DELAY_MS, + }); + await session.connect("ws://node", ["core/data"]); + connection.fail(new Error("dropped")); + + // Events: connecting, connected(pending), reconnecting(attempt 1). + const firstReconnect = (await nthEvent( + session, + EVENTS_THROUGH_FIRST_RECONNECT, + )) as { state: { status: string; attempt?: number } }; + expect(firstReconnect.state.status).toBe("reconnecting"); + expect(firstReconnect.state.attempt).toBe(1); + + await vi.advanceTimersByTimeAsync(RECONNECT_DELAY_MS); + // Events: connecting, connected(pending) -- the still-broken connection fails again immediately, giving reconnecting(attempt 2). + const secondReconnect = (await nthEvent( + session, + EVENTS_PER_RECONNECT_ROUND, + )) as { state: { status: string; attempt?: number } }; + expect(secondReconnect.state.status).toBe("reconnecting"); + expect(secondReconnect.state.attempt).toBe(2); + + await vi.advanceTimersByTimeAsync(RECONNECT_DELAY_MS); + // Events: connecting, connected(pending) -- the final attempt also fails, and with attempts exhausted this falls through to closed. + const finalOutcome = (await nthEvent( + session, + EVENTS_PER_RECONNECT_ROUND, + )) as { state: { status: string; reason: string } }; + expect(finalOutcome.state.status).toBe("closed"); + expect(finalOutcome.state.reason).toBe("dropped"); + } finally { + vi.useRealTimers(); + } + }); + + it("clears the handshake timeout on reconnect, so a stale timer from the previous attempt cannot corrupt the new one", async () => { + vi.useFakeTimers(); + try { + const { transport, connections } = multiConnectionTransport(); + const session = createMeshSession(transport, testIdentity, testClock, { + maxAttempts: 1, + delayMs: () => 0, + }); + const eventsDone = nthEvent( + session, + EVENTS_THROUGH_STALE_TIMER_REGRESSION, + ); + await session.connect("ws://node", ["core/data"]); + await vi.advanceTimersByTimeAsync(HANDSHAKE_TIMEOUT_MS - 1); + connections[0]?.fail(new Error("dropped")); + // Reaches the ORIGINAL attempt's own handshake-timeout instant. The reconnect (0ms backoff) completes first against a fresh connection, resetting the handshake to pending for the new attempt; an uncleared stale timer would then fire at this very instant and clobber it. + await vi.advanceTimersByTimeAsync(1); + await session.sendPing(); + const event = (await eventsDone) as { + state: { status: string; handshake?: { status: string } }; + }; + expect(event.state.status).toBe("connected"); + expect(event.state.handshake?.status).toBe("pending"); + await session.close(); + } finally { + vi.useRealTimers(); + } + }); + + it("cancels the handshake timeout when close() is called before it fires", async () => { + vi.useFakeTimers(); + try { + const { transport } = fakeTransport(); + const session = createMeshSession(transport, testIdentity, testClock); + await session.connect("ws://node", ["core/data"]); + expect(vi.getTimerCount()).toBeGreaterThan(0); + await session.close(); + expect(vi.getTimerCount()).toBe(0); + } finally { + vi.useRealTimers(); + } + }); + + it("cancels a pending scheduled reconnect when close() is called before it fires", async () => { + vi.useFakeTimers(); + try { + const { transport, connections } = multiConnectionTransport(); + const session = createMeshSession(transport, testIdentity, testClock, { + maxAttempts: 1, + delayMs: () => RECONNECT_DELAY_MS, + }); + await session.connect("ws://node", ["core/data"]); + connections[0]?.fail(new Error("dropped")); + await vi.advanceTimersByTimeAsync(0); + expect(vi.getTimerCount()).toBeGreaterThan(0); + await session.close(); + expect(vi.getTimerCount()).toBe(0); + await vi.advanceTimersByTimeAsync(RECONNECT_DELAY_MS); + expect(connections).toHaveLength(1); + } finally { + vi.useRealTimers(); + } + }); + + it("finalizes as closed by you when close() is called while a reconnect is pending", async () => { + vi.useFakeTimers(); + try { + const { transport, connections } = multiConnectionTransport(); + const session = createMeshSession(transport, testIdentity, testClock, { + maxAttempts: 1, + delayMs: () => RECONNECT_DELAY_MS, + }); + await session.connect("ws://node", ["core/data"]); + connections[0]?.fail(new Error("dropped")); + const reconnecting = (await nthEvent( + session, + EVENTS_THROUGH_FIRST_RECONNECT, + )) as { state: { status: string } }; + expect(reconnecting.state.status).toBe("reconnecting"); + const eventsDone = nthEvent(session, 1); + await session.close(); + const event = (await eventsDone) as { + state: { status: string; reason?: string }; + }; + expect(event.state.status).toBe("closed"); + expect((event.state as { reason: string }).reason).toBe("closed by you"); + } finally { + vi.useRealTimers(); + } + }); + + it("treats a failed retry dial itself as a disconnect, not a silently swallowed error", async () => { + vi.useFakeTimers(); + try { + const connections: FakeConnection[] = []; + let calls = 0; + const transport: Transport = { + connect: async (address: string): Promise => { + calls += 1; + if (address !== "ws://node") { + return Promise.reject(new Error(`connect to ${address} failed`)); + } + if (calls > 1) { + return Promise.reject(new Error("dial failed on retry")); + } + const next = new FakeConnection(); + connections.push(next); + return Promise.resolve(next.connection); + }, + listen: async (): Promise => + Promise.reject(new Error("client-only transport")), + }; + const session = createMeshSession(transport, testIdentity, testClock, { + maxAttempts: 1, + delayMs: () => RECONNECT_DELAY_MS, + }); + await session.connect("ws://node", ["core/data"]); + connections[0]?.fail(new Error("dropped")); + await nthEvent(session, EVENTS_THROUGH_FIRST_RECONNECT); + 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; + } + } + expect(event?.state.status).toBe("closed"); + expect(event?.state.reason).toBe("dial failed on retry"); + } finally { + vi.useRealTimers(); + } + }); +}); diff --git a/ts/packages/core/test/mesh-session-revocation.test.ts b/ts/packages/core/test/mesh-session-revocation.test.ts new file mode 100644 index 0000000..a6875f5 --- /dev/null +++ b/ts/packages/core/test/mesh-session-revocation.test.ts @@ -0,0 +1,143 @@ +import { describe, expect, it } from "vitest"; +import type { + RevocationAnnounceFrame, + RevocationEntry, +} from "../src/generated/protocol.js"; +import { createMeshSession } from "../src/domain/mesh-session.js"; +import { + EVENTS_THROUGH_FAILURE, + EVENTS_THROUGH_REMOTE_HANDSHAKE, + TEST_TOKEN_SIGNATURE_BYTE, + TIMEOUT_MARKER, + fakeTransport, + nthEvent, + testClock, + testIdentity, + withinShortWait, + yielded, +} from "./mesh-session-fixtures.js"; + +describe("revocation-announce plumbing", () => { + const ENTRY_B_PROTECTED_HEADER_BYTE = 11; + const ENTRY_B_PAYLOAD_BYTE = 12; + const testEntryA: RevocationEntry = [ + new Uint8Array([1]), + {}, + new Uint8Array([2]), + new Uint8Array([TEST_TOKEN_SIGNATURE_BYTE]), + ]; + const testEntryB: RevocationEntry = [ + new Uint8Array([ENTRY_B_PROTECTED_HEADER_BYTE]), + {}, + new Uint8Array([ENTRY_B_PAYLOAD_BYTE]), + new Uint8Array([TEST_TOKEN_SIGNATURE_BYTE + 1]), + ]; + + it("sends a revocation-announce frame carrying the given entries", async () => { + const { transport, connection } = fakeTransport(); + const session = createMeshSession(transport, testIdentity, testClock); + await session.connect("ws://node", ["core/management"]); + + await session.sendRevocationAnnounce([testEntryA, testEntryB]); + + const sentFrame = connection.sent.at(-1) as RevocationAnnounceFrame; + expect(sentFrame).toEqual({ + type: "revocation-announce", + entries: [testEntryA, testEntryB], + } satisfies RevocationAnnounceFrame); + await session.close(); + }); + + it("emits a session event after sending a revocation-announce", async () => { + const { transport } = fakeTransport(); + const session = createMeshSession(transport, testIdentity, testClock); + const eventsDone = nthEvent(session, EVENTS_THROUGH_REMOTE_HANDSHAKE - 1); + await session.connect("ws://node", ["core/management"]); + await eventsDone; + + const eventsIterator = session.events[Symbol.asyncIterator](); + await session.sendRevocationAnnounce([testEntryA]); + // Must already be available -- not merely eventually rescued by session.close()'s own trailing emit(), which would otherwise mask a missing emit() call in sendRevocationAnnounce. + const result = await withinShortWait(eventsIterator.next()); + expect(result).not.toBe(TIMEOUT_MARKER); + const event = yielded( + result as IteratorResult<{ + frameLog: { direction: string; frame: { type: string } }[]; + }>, + ); + expect(event.frameLog.at(-1)).toEqual({ + direction: "sent", + frame: { + type: "revocation-announce", + entries: [testEntryA], + }, + }); + await session.close(); + }); + + it("refuses sendRevocationAnnounce while not connected", async () => { + const { transport } = fakeTransport(); + const session = createMeshSession(transport, testIdentity, testClock); + await expect(session.sendRevocationAnnounce([testEntryA])).rejects.toThrow( + "not connected", + ); + }); + + it("refuses sendRevocationAnnounce once the connection has failed and closed, not just before the first connect", async () => { + const { transport, connection } = fakeTransport(); + const session = createMeshSession(transport, testIdentity, testClock); + await session.connect("ws://node", ["core/management"]); + connection.fail(new Error("dropped")); + await nthEvent(session, EVENTS_THROUGH_FAILURE); + await expect(session.sendRevocationAnnounce([testEntryA])).rejects.toThrow( + "not connected", + ); + }); + + it("flattens an incoming revocation-announce frame's entries onto revocationAnnouncements, one item per entry", async () => { + const { transport, connection } = fakeTransport(); + const session = createMeshSession(transport, testIdentity, testClock); + await session.connect("ws://node", ["core/management"]); + + const received: RevocationEntry[] = []; + const receivedBoth = (async (): Promise => { + const iterator = session.revocationAnnouncements[Symbol.asyncIterator](); + const first = await iterator.next(); + expect(first.done).toBe(false); + received.push(yielded(first)); + const second = await iterator.next(); + expect(second.done).toBe(false); + received.push(yielded(second)); + })(); + + connection.push({ + type: "revocation-announce", + entries: [testEntryA, testEntryB], + } satisfies RevocationAnnounceFrame); + + await receivedBoth; + expect(received).toEqual([testEntryA, testEntryB]); + await session.close(); + }); + + it("delivers a revocation entry queued before anyone was iterating revocationAnnouncements, from the backlog", async () => { + const { transport, connection } = fakeTransport(); + const session = createMeshSession(transport, testIdentity, testClock); + const eventsDone = nthEvent(session, EVENTS_THROUGH_REMOTE_HANDSHAKE - 1); + await session.connect("ws://node", ["core/management"]); + await eventsDone; + + const nextEventDone = nthEvent(session, 1); + connection.push({ + type: "revocation-announce", + entries: [testEntryA], + } satisfies RevocationAnnounceFrame); + await nextEventDone; + + const iterator = session.revocationAnnouncements[Symbol.asyncIterator](); + const result = await iterator.next(); + expect(result.done).toBe(false); + expect(yielded(result)).toEqual(testEntryA); + await session.close(); + }); +}); diff --git a/ts/packages/core/test/mesh-session-routing.test.ts b/ts/packages/core/test/mesh-session-routing.test.ts new file mode 100644 index 0000000..901f7e9 --- /dev/null +++ b/ts/packages/core/test/mesh-session-routing.test.ts @@ -0,0 +1,248 @@ +import { describe, expect, it } from "vitest"; +import type { + CapabilityScope, + Frame, + ManageCommand, + ManageRequestFrame, + ManageResponseFrame, + RelayDataFrame, +} from "../src/generated/protocol.js"; +import { + createMeshSession, + type IncomingManageRequest, + type ManageOutcome, +} from "../src/domain/mesh-session.js"; +import { + messageFromFrame, + tryDecodeFrame, +} from "../src/adapters/frame-codec.js"; +import { + TEST_INCOMING_REQUEST_ID, + deviceA, + deviceB, + fakeTransport, + nthEvent, + testClock, + testIdentity, +} from "./mesh-session-fixtures.js"; + +describe("relay routing", () => { + const testCommand: ManageCommand = { + verb: "exec:proc", + params: { verb: "exec.list" }, + }; + const testScope: CapabilityScope = { kind: "folder" }; + + /** Decodes a relay-data frame's opaque payload back into the Frame it wraps, failing the test outright if it isn't one -- every relay-data frame this suite sends is expected to wrap a real nested frame. */ + function unwrapRelayData(frame: Frame): Frame { + expect(frame.type).toBe("relay-data"); + const inner = tryDecodeFrame((frame as RelayDataFrame).payload); + if (inner === null) { + throw new Error( + "expected the relay-data payload to decode as a nested Frame", + ); + } + return inner; + } + + /** The frame at `index` (negative counts from the end, matching Array.prototype.at), failing the test outright if there isn't one there -- avoids both a non-null assertion and an `as` cast that would silently paper over an empty/short array. */ + function frameAt(frames: readonly Frame[], index: number): Frame { + const frame = frames.at(index); + if (frame === undefined) { + throw new Error( + `expected a frame at index ${String(index)}, got ${String(frames.length)} frames`, + ); + } + return frame; + } + + const LAST_SENT = -1; + const SECOND_TO_LAST_SENT = -2; + + // Events since session start: connecting, connected, self-advert sent -- then, for a manage-request addressed to a device this session isn't already paired with, a relay-connect-sent event and a relay-data-sent event. + const EVENTS_THROUGH_FIRST_RELAY_REQUEST = 5; + // Incremental events a further sendManageRequest call emits on top of EVENTS_THROUGH_FIRST_RELAY_REQUEST: just the relay-data-sent event when the target is already paired, or a relay-connect-sent event plus a relay-data-sent event when it re-pairs to a new target. + const EVENTS_PER_RELAY_REQUEST_SAME_TARGET = 1; + const EVENTS_PER_RELAY_REQUEST_NEW_TARGET = 2; + + it("establishes a relay-connect pairing before sending a manage-request with a targetDevice, wrapped in relay-data", async () => { + const { transport, connection } = fakeTransport(); + const session = createMeshSession(transport, testIdentity, testClock); + await session.connect("ws://node", ["core/management"]); + const eventsDone = nthEvent(session, EVENTS_THROUGH_FIRST_RELAY_REQUEST); + const pending = session.sendManageRequest(testCommand, testScope, deviceA); + await eventsDone; + + const relayConnect = frameAt(connection.sent, SECOND_TO_LAST_SENT); + expect(relayConnect).toEqual({ + type: "relay-connect", + "target-device": deviceA, + }); + + const relayData = frameAt(connection.sent, LAST_SENT); + const inner = unwrapRelayData(relayData) as ManageRequestFrame; + expect(inner.type).toBe("manage-request"); + expect(inner.command).toEqual(testCommand); + expect(inner.scope).toEqual(testScope); + + await session.close(); + await expect(pending).rejects.toThrow(); + }); + + it("reuses an established pairing rather than sending a second relay-connect for the same target", async () => { + const { transport, connection } = fakeTransport(); + const session = createMeshSession(transport, testIdentity, testClock); + await session.connect("ws://node", ["core/management"]); + const firstDone = nthEvent(session, EVENTS_THROUGH_FIRST_RELAY_REQUEST); + const first = session.sendManageRequest(testCommand, testScope, deviceA); + await firstDone; + const secondDone = nthEvent(session, EVENTS_PER_RELAY_REQUEST_SAME_TARGET); + const second = session.sendManageRequest(testCommand, testScope, deviceA); + await secondDone; + + const relayConnects = connection.sent.filter( + (frame) => frame.type === "relay-connect", + ); + expect(relayConnects).toHaveLength(1); + const relayDataFrames = connection.sent.filter( + (frame) => frame.type === "relay-data", + ); + expect(relayDataFrames).toHaveLength(2); + await session.close(); + await expect(first).rejects.toThrow(); + await expect(second).rejects.toThrow(); + }); + + it("sends a fresh relay-connect when a later request targets a different device", async () => { + const { transport, connection } = fakeTransport(); + const session = createMeshSession(transport, testIdentity, testClock); + await session.connect("ws://node", ["core/management"]); + const firstDone = nthEvent(session, EVENTS_THROUGH_FIRST_RELAY_REQUEST); + const first = session.sendManageRequest(testCommand, testScope, deviceA); + await firstDone; + const secondDone = nthEvent(session, EVENTS_PER_RELAY_REQUEST_NEW_TARGET); + const second = session.sendManageRequest(testCommand, testScope, deviceB); + await secondDone; + + const relayConnects = connection.sent.filter( + (frame) => frame.type === "relay-connect", + ); + expect(relayConnects).toEqual([ + { type: "relay-connect", "target-device": deviceA }, + { type: "relay-connect", "target-device": deviceB }, + ]); + await session.close(); + await expect(first).rejects.toThrow(); + await expect(second).rejects.toThrow(); + }); + + it("dispatches a relay-data frame wrapping a manage-request into incomingManageRequests, with fromDevice set from the establishing relay-inbound", async () => { + const { transport, connection } = fakeTransport(); + const session = createMeshSession(transport, testIdentity, testClock); + await session.connect("ws://node", ["core/management"]); + + const incomingDone = (async (): Promise => { + const iterator = session.incomingManageRequests[Symbol.asyncIterator](); + const result = await iterator.next(); + return result.value as IncomingManageRequest; + })(); + + connection.push({ type: "relay-inbound", "source-device": deviceA }); + const wrapped: ManageRequestFrame = { + type: "manage-request", + "request-id": TEST_INCOMING_REQUEST_ID, + command: testCommand, + scope: testScope, + }; + connection.push({ + type: "relay-data", + payload: messageFromFrame(wrapped), + } satisfies RelayDataFrame); + + const incoming = await incomingDone; + expect(incoming.requestId).toBe(TEST_INCOMING_REQUEST_ID); + expect(incoming.command).toEqual(testCommand); + expect(incoming.scope).toEqual(testScope); + expect(incoming.fromDevice).toEqual(deviceA); + + await incoming.respond({ result: "ok" }); + const sentResponse = frameAt(connection.sent, LAST_SENT); + const innerResponse = unwrapRelayData(sentResponse); + expect(innerResponse).toEqual({ + type: "manage-response", + "request-id": TEST_INCOMING_REQUEST_ID, + outcome: { result: "ok" }, + } satisfies ManageResponseFrame); + await session.close(); + }); + + it("resolves a pending sendManageRequest from a relay-data frame wrapping the matching manage-response", async () => { + const { transport, connection } = fakeTransport(); + const session = createMeshSession(transport, testIdentity, testClock); + await session.connect("ws://node", ["core/management"]); + const eventsDone = nthEvent(session, EVENTS_THROUGH_FIRST_RELAY_REQUEST); + const pending = session.sendManageRequest(testCommand, testScope, deviceA); + await eventsDone; + const sentRelayData = frameAt(connection.sent, LAST_SENT); + const sentRequest = unwrapRelayData(sentRelayData) as ManageRequestFrame; + const requestId = sentRequest["request-id"]; + + connection.push({ + type: "relay-data", + payload: messageFromFrame({ + type: "manage-response", + "request-id": requestId, + outcome: { result: "ok" }, + } satisfies ManageResponseFrame), + } satisfies RelayDataFrame); + + const outcome: ManageOutcome = await pending; + expect(outcome).toEqual({ result: "ok" }); + await session.close(); + }); + + it("leaves a relay-data frame whose payload does not decode as a recognized frame as ordinary opaque data", async () => { + const { transport, connection } = fakeTransport(); + const session = createMeshSession(transport, testIdentity, testClock); + await session.connect("ws://node", ["core/management"]); + // A CBOR break byte on its own -- undecodable as a complete value, the same convention webrtc-transport.test.ts uses for an invalid payload. + const CBOR_BREAK_BYTE = 0xff; + const undecodable: RelayDataFrame = { + type: "relay-data", + payload: new Uint8Array([CBOR_BREAK_BYTE]), + }; + // Events: connecting, connected, self-advert sent, opaque relay-data received. + const EVENTS_THROUGH_OPAQUE_RELAY_DATA = 4; + const eventsDone = nthEvent(session, EVENTS_THROUGH_OPAQUE_RELAY_DATA); + connection.push(undecodable); + const event = (await eventsDone) as { + frameLog: { direction: string; frame: Frame }[]; + }; + expect(event.frameLog.at(-1)).toEqual({ + direction: "received", + frame: undecodable, + }); + await session.close(); + }); + + it("leaves a relay-data frame whose payload decodes as a recognized but non-manage frame as ordinary opaque data", async () => { + const { transport, connection } = fakeTransport(); + const session = createMeshSession(transport, testIdentity, testClock); + await session.connect("ws://node", ["core/management"]); + const wrapped: RelayDataFrame = { + type: "relay-data", + payload: messageFromFrame({ type: "ping" }), + }; + const EVENTS_THROUGH_OPAQUE_RELAY_DATA = 4; + const eventsDone = nthEvent(session, EVENTS_THROUGH_OPAQUE_RELAY_DATA); + connection.push(wrapped); + const event = (await eventsDone) as { + frameLog: { direction: string; frame: Frame }[]; + }; + expect(event.frameLog.at(-1)).toEqual({ + direction: "received", + frame: wrapped, + }); + await session.close(); + }); +}); diff --git a/ts/packages/core/test/mesh-session.test.ts b/ts/packages/core/test/mesh-session.test.ts deleted file mode 100644 index 1c5dbb9..0000000 --- a/ts/packages/core/test/mesh-session.test.ts +++ /dev/null @@ -1,1761 +0,0 @@ -import { describe, expect, it, vi } from "vitest"; -import type { - CapabilityScope, - CapabilityToken, - Frame, - GossipFrame, - HandshakeFrame, - ManageCommand, - ManageRequestFrame, - ManageResponseFrame, - RelayDataFrame, - RevocationAnnounceFrame, - RevocationEntry, -} from "../src/generated/protocol.js"; -import type { Clock } from "../src/ports/clock.js"; -import type { IdentityPort } from "../src/ports/identity.js"; -import type { - Connection, - Listener, - Transport, -} from "../src/ports/transport.js"; -import { - HANDSHAKE_TIMEOUT_MS, - acceptMeshSession, - createMeshSession, - type IncomingManageRequest, - type ManageOutcome, -} from "../src/domain/mesh-session.js"; -import { - messageFromFrame, - tryDecodeFrame, -} from "../src/adapters/frame-codec.js"; -import { deviceIdFromFillHex } from "./hex.js"; - -const deviceA = deviceIdFromFillHex("11"); -const deviceB = deviceIdFromFillHex("22"); - -const testIdentityDeviceId = deviceIdFromFillHex("ee"); -const testIdentity: IdentityPort = { - deviceId: testIdentityDeviceId, - identityKey: { alg: -7, "public-key": new Uint8Array() }, - sign: async () => Promise.resolve(new Uint8Array()), - verify: async () => Promise.resolve(true), - deriveDeviceId: async () => Promise.resolve(testIdentityDeviceId), -}; -const MS_PER_SECOND = 1000; -const TEST_CLOCK_NOW_MS = 1_700_000_000_000; -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. -const EVENTS_THROUGH_REMOTE_HANDSHAKE = 4; -const EVENTS_THROUGH_TIMEOUT = 4; -const EVENTS_THROUGH_THREE_GOSSIPS = 6; -const EVENTS_THROUGH_PING_ROUND_TRIP = 5; -const EVENTS_THROUGH_FAILURE = 4; -const SNAPSHOT_FIRST = 100; -const SNAPSHOT_SECOND = 200; -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. -const RECONNECT_DELAY_MS = 100; -const RECONNECT_MAX_ATTEMPTS = 2; -const EVENTS_THROUGH_FIRST_RECONNECT = 4; -const EVENTS_PER_RECONNECT_ROUND = 4; -const EVENTS_THROUGH_STALE_TIMER_REGRESSION = 8; - -/** An in-memory Connection the test drives: pushes arrive on the receive iteration, sends are recorded. */ -class FakeConnection { - sent: Frame[] = []; - private readonly inbound: Frame[] = []; - private ended = false; - private failure: Error | null = null; - - get connection(): Readonly { - 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(); - }, - }; - } - - /** True once this connection's own close() has actually been invoked -- lets a test assert that a caller closed the link, distinct from the link merely ending its receive stream on its own (see endStream). */ - get isClosed(): boolean { - return this.ended; - } - - /** Ends the receive stream as if the remote hung up cleanly, without going through this side's own close() -- unlike close(), this leaves the session's own state untouched so a test can observe how the session itself reacts to a graceful remote end. */ - endStream(): void { - this.ended = true; - this.wake(); - } - - push(frame: Frame): void { - this.inbound.push(frame); - this.wake(); - } - - fail(error: Error): void { - this.failure = error; - this.wake(); - } - - private readonly wakeWaiters: (() => void)[] = []; - - private wake(): void { - for (const wake of this.wakeWaiters.splice(0)) { - wake(); - } - } - - private stream(): AsyncIterable { - return { - [Symbol.asyncIterator]: () => ({ - next: async (): Promise> => this.nextFrame(), - }), - }; - } - - 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); - }); - } - } -} - -function fakeTransport(): { transport: Transport; connection: FakeConnection } { - const connection = new FakeConnection(); - const transport: Transport = { - connect: async (address: string): Promise => { - 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 }; -} - -/** A transport that hands out a fresh FakeConnection on every connect() call, so one attempt's failure doesn't leak into the next -- unlike fakeTransport()'s single shared connection, which stays broken forever once failed. */ -function multiConnectionTransport(): { - transport: Transport; - connections: FakeConnection[]; -} { - const connections: FakeConnection[] = []; - const transport: Transport = { - connect: async (address: string): Promise => { - if (address !== "ws://node") { - return Promise.reject(new Error(`connect to ${address} failed`)); - } - 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 }; -} - -/** Narrows an IteratorResult to its yielded value, failing the test outright if the iterator has actually ended -- none of this file's own async iterators ever end, so a done:true result always indicates a broken assumption in the test itself, never legitimate data. */ -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; -} - -const TIMEOUT_MARKER = "timeout" as const; -const SHORT_WAIT_MS = 20; - -/** Races a promise against a short real-time wait, resolving to TIMEOUT_MARKER if the promise hasn't settled yet -- used to prove a promise genuinely settled *now*, from the action just taken, rather than merely settling *eventually* by some unrelated later event (e.g. a trailing session.close() emitting one final event that would otherwise silently satisfy an unconsumed waiter and mask a missing emit() call). */ -async function withinShortWait( - promise: Readonly>, -): Promise { - return Promise.race([ - promise, - new Promise((resolve) => { - setTimeout(() => { - resolve(TIMEOUT_MARKER); - }, SHORT_WAIT_MS); - }), - ]); -} - -/** Resolves after the session has emitted at least `count` events, returning the latest. */ -async function nthEvent( - session: ReturnType, - count: number, -): Promise { - const iterator = session.events[Symbol.asyncIterator](); - let last: unknown = null; - for (let i = 0; i < count; i++) { - const result = await iterator.next(); - last = result.value; - } - return last; -} - -function gossipFor( - device: Uint8Array, - seconds = 1861833600, -): Frame { - return { - type: "gossip", - peers: [ - { device, addresses: ["203.0.113.5:4433"], "snapshot-seconds": seconds }, - ], - }; -} - -describe("createMeshSession", () => { - it("connects, sends the local handshake, and negotiates against the node's answer", async () => { - const { transport, connection } = fakeTransport(); - const session = createMeshSession(transport, testIdentity, testClock); - // Events: connecting, connected(handshake sent/pending), self-advert sent, connected/negotiated - const eventsDone = nthEvent(session, EVENTS_THROUGH_REMOTE_HANDSHAKE); - await session.connect("ws://node", ["core/management", "core/data"]); - - expect(connection.sent[0]).toEqual({ - type: "handshake", - version: 1, - domains: ["core/management", "core/data"], - } satisfies HandshakeFrame); - - const answer: HandshakeFrame = { - type: "handshake", - version: 1, - domains: ["core/data", "core/exec"], - }; - connection.push(answer); - const event = (await eventsDone) as { - state: { - status: string; - handshake: { status: string; sharedDomains: string[] }; - }; - }; - expect(event.state.status).toBe("connected"); - expect(event.state.handshake.status).toBe("negotiated"); - expect(event.state.handshake.sharedDomains).toEqual(["core/data"]); - await session.close(); - }); - - it("sends a self-advertisement gossip frame right after the handshake", async () => { - const { transport, connection } = fakeTransport(); - const session = createMeshSession(transport, testIdentity, testClock); - // Events: connecting, connected(handshake sent/pending), self-advert sent - const eventsDone = nthEvent(session, EVENTS_THROUGH_REMOTE_HANDSHAKE - 1); - await session.connect("ws://node", ["core/data"]); - await eventsDone; - - expect(connection.sent[0]?.type).toBe("handshake"); - const selfAdvert = connection.sent[1] as GossipFrame; - expect(selfAdvert).toEqual({ - type: "gossip", - peers: [ - { - device: testIdentityDeviceId, - addresses: [], - "snapshot-seconds": Math.floor(TEST_CLOCK_NOW_MS / MS_PER_SECOND), - }, - ], - } satisfies GossipFrame); - expect(selfAdvert.peers[0]?.device).toEqual(testIdentity.deviceId); - await session.close(); - }); - - it("advertises this node's own given addresses in its self-advert", async () => { - const { transport, connection } = fakeTransport(); - const session = createMeshSession( - transport, - testIdentity, - testClock, - null, - ["10.0.0.1:9000", "203.0.113.5:9000"], - ); - const eventsDone = nthEvent(session, EVENTS_THROUGH_REMOTE_HANDSHAKE - 1); - await session.connect("ws://node", ["core/data"]); - await eventsDone; - - const selfAdvert = connection.sent[1] as GossipFrame; - expect(selfAdvert.peers[0]?.addresses).toEqual([ - "10.0.0.1:9000", - "203.0.113.5:9000", - ]); - await session.close(); - }); - - it("sendGossipUpdate re-sends a fresh self-advert with the given extensions merged in", async () => { - const { transport, connection } = fakeTransport(); - const session = createMeshSession(transport, testIdentity, testClock); - await session.connect("ws://node", ["core/data"]); - - await session.sendGossipUpdate({ "presence/status": "idle" }); - - const updated = connection.sent.at(-1) as GossipFrame; - expect(updated).toEqual({ - type: "gossip", - peers: [ - { - device: testIdentityDeviceId, - addresses: [], - "snapshot-seconds": Math.floor(TEST_CLOCK_NOW_MS / MS_PER_SECOND), - "presence/status": "idle", - }, - ], - } satisfies GossipFrame); - await session.close(); - }); - - it("sendGossipUpdate rejects an extension key that collides with a mandatory peer-advert field", async () => { - const { transport } = fakeTransport(); - const session = createMeshSession(transport, testIdentity, testClock); - await session.connect("ws://node", ["core/data"]); - - await expect( - session.sendGossipUpdate({ device: "spoofed" }), - ).rejects.toThrow(/collides with a mandatory peer-advert field/); - await session.close(); - }); - - it("sendGossipUpdate rejects a bare, non-domain-qualified extension key", async () => { - const { transport } = fakeTransport(); - const session = createMeshSession(transport, testIdentity, testClock); - await session.connect("ws://node", ["core/data"]); - - await expect( - session.sendGossipUpdate({ presence: "idle" }), - ).rejects.toThrow(/must be domain-qualified/); - await session.close(); - }); - - it("sendGossipUpdate rejects an extension key that collides with the mandatory addresses field", async () => { - const { transport } = fakeTransport(); - const session = createMeshSession(transport, testIdentity, testClock); - await session.connect("ws://node", ["core/data"]); - - await expect(session.sendGossipUpdate({ addresses: [] })).rejects.toThrow( - /collides with a mandatory peer-advert field/, - ); - await session.close(); - }); - - it("sendGossipUpdate rejects an extension key that collides with the mandatory snapshot-seconds field", async () => { - const { transport } = fakeTransport(); - const session = createMeshSession(transport, testIdentity, testClock); - await session.connect("ws://node", ["core/data"]); - - await expect( - session.sendGossipUpdate({ "snapshot-seconds": 0 }), - ).rejects.toThrow(/collides with a mandatory peer-advert field/); - await session.close(); - }); - - it("sendGossipUpdate rejects an extension key with a domain-qualified prefix but trailing garbage after it", async () => { - const { transport } = fakeTransport(); - const session = createMeshSession(transport, testIdentity, testClock); - await session.connect("ws://node", ["core/data"]); - - await expect( - session.sendGossipUpdate({ "presence/status!": "idle" }), - ).rejects.toThrow(/must be domain-qualified/); - await session.close(); - }); - - it("sendGossipUpdate rejects an extension key that only matches a domain-qualified pattern partway through the string", async () => { - const { transport } = fakeTransport(); - const session = createMeshSession(transport, testIdentity, testClock); - await session.connect("ws://node", ["core/data"]); - - await expect( - session.sendGossipUpdate({ "1presence/status": "idle" }), - ).rejects.toThrow(/must be domain-qualified/); - await session.close(); - }); - - it("sendGossipUpdate with no extensions re-sends a plain self-advert", async () => { - const { transport, connection } = fakeTransport(); - const session = createMeshSession(transport, testIdentity, testClock); - await session.connect("ws://node", ["core/data"]); - - await session.sendGossipUpdate(); - - const updated = connection.sent.at(-1) as GossipFrame; - expect(updated).toEqual({ - type: "gossip", - peers: [ - { - device: testIdentityDeviceId, - addresses: [], - "snapshot-seconds": Math.floor(TEST_CLOCK_NOW_MS / MS_PER_SECOND), - }, - ], - } satisfies GossipFrame); - await session.close(); - }); - - it("emits a session event after sending a gossip update", async () => { - const { transport } = fakeTransport(); - const session = createMeshSession(transport, testIdentity, testClock); - const eventsDone = nthEvent(session, EVENTS_THROUGH_REMOTE_HANDSHAKE - 1); - await session.connect("ws://node", ["core/data"]); - await eventsDone; - - const eventsIterator = session.events[Symbol.asyncIterator](); - await session.sendGossipUpdate(); - // Must already be available -- not merely eventually rescued by session.close()'s own trailing emit(), which would otherwise mask a missing emit() call in sendGossipUpdate. - const result = await withinShortWait(eventsIterator.next()); - expect(result).not.toBe(TIMEOUT_MARKER); - const event = yielded( - result as IteratorResult<{ - frameLog: { direction: string; frame: { type: string } }[]; - }>, - ); - expect(event.frameLog.at(-1)?.direction).toBe("sent"); - expect(event.frameLog.at(-1)?.frame.type).toBe("gossip"); - await session.close(); - }); - - it("refuses sendGossipUpdate while not connected", async () => { - const { transport } = fakeTransport(); - const session = createMeshSession(transport, testIdentity, testClock); - await expect(session.sendGossipUpdate()).rejects.toThrow("not connected"); - }); - - it("refuses sendGossipUpdate once the connection has failed and closed, not just before the first connect", async () => { - const { transport, connection } = fakeTransport(); - const session = createMeshSession(transport, testIdentity, testClock); - await session.connect("ws://node", ["core/data"]); - connection.fail(new Error("dropped")); - await nthEvent(session, EVENTS_THROUGH_FAILURE); - await expect(session.sendGossipUpdate()).rejects.toThrow("not connected"); - }); - - it("excludes the retired core/federation domain even when both sides offer it", async () => { - const { transport, connection } = fakeTransport(); - const session = createMeshSession(transport, testIdentity, testClock); - // Events: connecting, connected, connected/rejected - const eventsDone = nthEvent(session, EVENTS_THROUGH_REMOTE_HANDSHAKE); - await session.connect("ws://node", ["core/federation"]); - connection.push({ - type: "handshake", - version: 1, - domains: ["core/federation"], - }); - const event = (await eventsDone) as { - state: { handshake: { status: string } }; - }; - expect(event.state.handshake.status).toBe("rejected"); - await session.close(); - }); - - it("marks the handshake unanswered after the timeout instead of hanging", async () => { - vi.useFakeTimers(); - try { - const { transport } = fakeTransport(); - const session = createMeshSession(transport, testIdentity, testClock); - await session.connect("ws://node", ["core/data"]); - await vi.advanceTimersByTimeAsync(HANDSHAKE_TIMEOUT_MS); - // Events so far: connecting, connected -- then the timeout emits unanswered - const event = (await nthEvent(session, EVENTS_THROUGH_TIMEOUT)) as { - state: { handshake: { status: string } }; - }; - expect(event.state.handshake.status).toBe("unanswered"); - await session.close(); - } finally { - vi.useRealTimers(); - } - }); - - it("assembles the peer directory from gossip, latest advert per device winning", async () => { - const { transport, connection } = fakeTransport(); - const session = createMeshSession(transport, testIdentity, testClock); - await session.connect("ws://node", ["core/data"]); - connection.push(gossipFor(deviceA, SNAPSHOT_FIRST)); - connection.push(gossipFor(deviceB, SNAPSHOT_SECOND)); - connection.push(gossipFor(deviceA, SNAPSHOT_UPDATED)); - // Events: connecting, connected, then one per gossip push -- drain to the last - const event = (await nthEvent(session, EVENTS_THROUGH_THREE_GOSSIPS)) as { - directory: { - device: Uint8Array; - advert: { "snapshot-seconds": number }; - }[]; - }; - expect(event.directory.length).toBe(2); - const entryA = event.directory.find( - (entry) => entry.advert["snapshot-seconds"] === SNAPSHOT_UPDATED, - ); - expect(entryA).toBeDefined(); - expect(event.directory[0]?.device).toEqual(deviceA); - expect(event.directory[1]?.device).toEqual(deviceB); - await session.close(); - }); - - it("records sent and received frames in the frame log in order", async () => { - const { transport, connection } = fakeTransport(); - const session = createMeshSession(transport, testIdentity, testClock); - await session.connect("ws://node", ["core/data"]); - await session.sendPing(); - connection.push({ type: "ping" }); - // Events: connecting, connected, ping sent, ping received - const event = (await nthEvent(session, EVENTS_THROUGH_PING_ROUND_TRIP)) as { - frameLog: { direction: string; frame: { type: string } }[]; - }; - expect(event.frameLog.map((entry) => entry.direction)).toEqual([ - "sent", - "sent", - "sent", - "received", - ]); - await session.close(); - }); - - it("closes with the node's reason when the receive iteration rejects", async () => { - const { transport, connection } = fakeTransport(); - const session = createMeshSession(transport, testIdentity, testClock); - await session.connect("ws://node", ["core/data"]); - connection.fail(new Error("node closed abruptly")); - // Events: connecting, connected, closed - const event = (await nthEvent(session, EVENTS_THROUGH_FAILURE)) as { - state: { status: string; reason: string }; - }; - expect(event.state.status).toBe("closed"); - expect(event.state.reason).toBe("node closed abruptly"); - }); - - it("refuses a second connect on the same session", async () => { - const { transport } = fakeTransport(); - const session = createMeshSession(transport, testIdentity, testClock); - await session.connect("ws://node", ["core/data"]); - await expect(session.connect("ws://node", ["core/data"])).rejects.toThrow( - "connects once", - ); - await session.close(); - }); - - it("refuses a ping while not connected", async () => { - const { transport } = fakeTransport(); - const session = createMeshSession(transport, testIdentity, testClock); - await expect(session.sendPing()).rejects.toThrow("not connected"); - }); - - it("refuses a ping once the connection has failed and the session is closed, not just before the first connect", async () => { - const { transport, connection } = fakeTransport(); - const session = createMeshSession(transport, testIdentity, testClock); - await session.connect("ws://node", ["core/data"]); - connection.fail(new Error("dropped")); - await nthEvent(session, EVENTS_THROUGH_FAILURE); - await expect(session.sendPing()).rejects.toThrow("not connected"); - }); - - it("close() while connected finalizes state as closed by you, and actually closes the underlying connection", async () => { - const { transport, connection } = fakeTransport(); - const session = createMeshSession(transport, testIdentity, testClock); - const eventsDone = nthEvent(session, EVENTS_THROUGH_FAILURE); - await session.connect("ws://node", ["core/data"]); - await session.close(); - const event = (await eventsDone) as { - state: { status: string; reason?: string }; - }; - expect(event.state.status).toBe("closed"); - expect((event.state as { reason: string }).reason).toBe("closed by you"); - expect(connection.isClosed).toBe(true); - }); - - it("closes a dial that only completes after close() was already called, instead of wiring it up", async () => { - // A plain `let` reassigned only inside the Promise executor below loses its non-null narrowing by the time it's called several `await`s later -- an object property isn't narrowed the same way a bare closed-over variable is, so this sidesteps that entirely. - const dialResolver: { - resolve: ((connection: Connection) => void) | null; - } = { resolve: null }; - const transport: Transport = { - connect: async (): Promise => - new Promise((resolve) => { - dialResolver.resolve = resolve; - }), - listen: async (): Promise => - Promise.reject(new Error("client-only transport")), - }; - const session = createMeshSession(transport, testIdentity, testClock); - const eventsIterator = session.events[Symbol.asyncIterator](); - const connectPromise = session.connect("ws://node", ["core/data"]); - await eventsIterator.next(); // connecting - await session.close(); - const lateConnection = new FakeConnection(); - if (dialResolver.resolve === null) - throw new Error("expected resolveDial to be set"); - dialResolver.resolve(lateConnection.connection); - await connectPromise; - expect(lateConnection.sent).toHaveLength(0); - expect(lateConnection.isClosed).toBe(true); - const closedEvent = yielded(await eventsIterator.next()) as { - state: { status: string; reason?: string }; - }; - expect(closedEvent.state.status).toBe("closed"); - expect((closedEvent.state as { reason: string }).reason).toBe( - "closed by you", - ); - }); - - it("treats a clean end of the receive stream as a disconnect when still connected", async () => { - const { transport, connection } = fakeTransport(); - const session = createMeshSession(transport, testIdentity, testClock); - await session.connect("ws://node", ["core/data"]); - const eventsDone = nthEvent(session, EVENTS_THROUGH_FAILURE); - connection.endStream(); - const event = (await eventsDone) as { - state: { status: string; reason: string }; - }; - expect(event.state.status).toBe("closed"); - expect(event.state.reason).toBe("node closed the connection"); - }); - - it("ignores a frame that was already queued when close() is called, instead of applying it", async () => { - const { transport, connection } = fakeTransport(); - const session = createMeshSession(transport, testIdentity, testClock); - const iterator = session.events[Symbol.asyncIterator](); - await session.connect("ws://node", ["core/data"]); - connection.push(gossipFor(deviceA)); - await session.close(); - for (let i = 0; i < EVENTS_THROUGH_FAILURE; i++) { - await iterator.next(); - } - const result = await withinShortWait(iterator.next()); - expect(result).toBe(TIMEOUT_MARKER); - }); - - it("does not negotiate against a second handshake frame once the first has already settled the outcome", async () => { - const { transport, connection } = fakeTransport(); - const session = createMeshSession(transport, testIdentity, testClock); - const eventsDone = nthEvent(session, EVENTS_THROUGH_REMOTE_HANDSHAKE); - await session.connect("ws://node", ["core/management", "core/data"]); - connection.push({ - type: "handshake", - version: 1, - domains: ["core/data", "core/exec"], - } satisfies HandshakeFrame); - await eventsDone; - - const secondEventDone = nthEvent(session, 1); - connection.push({ - type: "handshake", - version: 1, - domains: ["core/federation"], - } satisfies HandshakeFrame); - const event = (await secondEventDone) as { - state: { - status: string; - handshake: { status: string; sharedDomains: string[] }; - }; - }; - expect(event.state.handshake.status).toBe("negotiated"); - expect(event.state.handshake.sharedDomains).toEqual(["core/data"]); - await session.close(); - }); - - it("keeps the handshake negotiated after HANDSHAKE_TIMEOUT_MS elapses, instead of flipping to unanswered", async () => { - vi.useFakeTimers(); - try { - const { transport, connection } = fakeTransport(); - const session = createMeshSession(transport, testIdentity, testClock); - const eventsDone = nthEvent(session, EVENTS_THROUGH_REMOTE_HANDSHAKE); - await session.connect("ws://node", ["core/data"]); - connection.push({ - type: "handshake", - version: 1, - domains: ["core/data"], - } satisfies HandshakeFrame); - await eventsDone; - - await vi.advanceTimersByTimeAsync(HANDSHAKE_TIMEOUT_MS); - await session.sendPing(); - const lastFrame = connection.sent.at(-1) as { type: string }; - expect(lastFrame.type).toBe("ping"); - // sendPing itself would have thrown if state had reverted away from "connected", and negotiate() already proved the handshake status. A direct re-check below confirms the handshake status specifically stayed "negotiated". - const stillConnectedEvent = (await nthEvent(session, 1)) as { - state: { handshake?: { status: string } }; - }; - expect(stillConnectedEvent.state.handshake?.status).toBe("negotiated"); - await session.close(); - } finally { - vi.useRealTimers(); - } - }); - - it("records a specific reason when the handshake is rejected for sharing no domains or version", async () => { - const { transport, connection } = fakeTransport(); - const session = createMeshSession(transport, testIdentity, testClock); - const eventsDone = nthEvent(session, EVENTS_THROUGH_REMOTE_HANDSHAKE); - await session.connect("ws://node", ["core/federation"]); - connection.push({ - type: "handshake", - version: 1, - domains: ["core/federation"], - }); - const event = (await eventsDone) as { - state: { handshake: { status: string; reason?: string } }; - }; - expect(event.state.handshake.status).toBe("rejected"); - expect((event.state.handshake as { reason: string }).reason).toBe( - "no shared domains or version", - ); - await session.close(); - }); - - it("events iterator resolves a live event, delivered after the wait began, with done: false", async () => { - const { transport } = fakeTransport(); - const session = createMeshSession(transport, testIdentity, testClock); - const iterator = session.events[Symbol.asyncIterator](); - // No event has been emitted yet, so this call registers a waiter rather than draining the backlog. - const pending = iterator.next(); - await session.connect("ws://node", ["core/data"]); - const result = await pending; - expect(result.done).toBe(false); - await session.close(); - }); - - it("events iterator resolves a backlogged event, queued before anyone was iterating, with done: false", async () => { - const { transport } = fakeTransport(); - const session = createMeshSession(transport, testIdentity, testClock); - await session.connect("ws://node", ["core/data"]); - const iterator = session.events[Symbol.asyncIterator](); - const result = await iterator.next(); - expect(result.done).toBe(false); - await session.close(); - }); -}); - -describe("reconnect policy", () => { - it("never reconnects when no policy is given, matching today's default behavior", async () => { - const { transport, connection } = fakeTransport(); - const session = createMeshSession(transport, testIdentity, testClock); - await session.connect("ws://node", ["core/data"]); - const eventsDone = nthEvent(session, EVENTS_THROUGH_FAILURE); - connection.fail(new Error("dropped")); - const event = (await eventsDone) as { - state: { status: string; reason: string }; - }; - expect(event.state.status).toBe("closed"); - expect(event.state.reason).toBe("dropped"); - }); - - it("retries with backoff through correctly-numbered attempts, then gives up once max attempts are exhausted", async () => { - vi.useFakeTimers(); - try { - const { transport, connection } = fakeTransport(); - const session = createMeshSession(transport, testIdentity, testClock, { - maxAttempts: RECONNECT_MAX_ATTEMPTS, - delayMs: () => RECONNECT_DELAY_MS, - }); - await session.connect("ws://node", ["core/data"]); - connection.fail(new Error("dropped")); - - // Events: connecting, connected(pending), reconnecting(attempt 1). - const firstReconnect = (await nthEvent( - session, - EVENTS_THROUGH_FIRST_RECONNECT, - )) as { state: { status: string; attempt?: number } }; - expect(firstReconnect.state.status).toBe("reconnecting"); - expect(firstReconnect.state.attempt).toBe(1); - - await vi.advanceTimersByTimeAsync(RECONNECT_DELAY_MS); - // Events: connecting, connected(pending) -- the still-broken connection fails again immediately, giving reconnecting(attempt 2). - const secondReconnect = (await nthEvent( - session, - EVENTS_PER_RECONNECT_ROUND, - )) as { state: { status: string; attempt?: number } }; - expect(secondReconnect.state.status).toBe("reconnecting"); - expect(secondReconnect.state.attempt).toBe(2); - - await vi.advanceTimersByTimeAsync(RECONNECT_DELAY_MS); - // Events: connecting, connected(pending) -- the final attempt also fails, and with attempts exhausted this falls through to closed. - const finalOutcome = (await nthEvent( - session, - EVENTS_PER_RECONNECT_ROUND, - )) as { state: { status: string; reason: string } }; - expect(finalOutcome.state.status).toBe("closed"); - expect(finalOutcome.state.reason).toBe("dropped"); - } finally { - vi.useRealTimers(); - } - }); - - it("clears the handshake timeout on reconnect, so a stale timer from the previous attempt cannot corrupt the new one", async () => { - vi.useFakeTimers(); - try { - const { transport, connections } = multiConnectionTransport(); - const session = createMeshSession(transport, testIdentity, testClock, { - maxAttempts: 1, - delayMs: () => 0, - }); - const eventsDone = nthEvent( - session, - EVENTS_THROUGH_STALE_TIMER_REGRESSION, - ); - await session.connect("ws://node", ["core/data"]); - await vi.advanceTimersByTimeAsync(HANDSHAKE_TIMEOUT_MS - 1); - connections[0]?.fail(new Error("dropped")); - // Reaches the ORIGINAL attempt's own handshake-timeout instant. The reconnect (0ms backoff) completes first against a fresh connection, resetting the handshake to pending for the new attempt; an uncleared stale timer would then fire at this very instant and clobber it. - await vi.advanceTimersByTimeAsync(1); - await session.sendPing(); - const event = (await eventsDone) as { - state: { status: string; handshake?: { status: string } }; - }; - expect(event.state.status).toBe("connected"); - expect(event.state.handshake?.status).toBe("pending"); - await session.close(); - } finally { - vi.useRealTimers(); - } - }); - - it("cancels the handshake timeout when close() is called before it fires", async () => { - vi.useFakeTimers(); - try { - const { transport } = fakeTransport(); - const session = createMeshSession(transport, testIdentity, testClock); - await session.connect("ws://node", ["core/data"]); - expect(vi.getTimerCount()).toBeGreaterThan(0); - await session.close(); - expect(vi.getTimerCount()).toBe(0); - } finally { - vi.useRealTimers(); - } - }); - - it("cancels a pending scheduled reconnect when close() is called before it fires", async () => { - vi.useFakeTimers(); - try { - const { transport, connections } = multiConnectionTransport(); - const session = createMeshSession(transport, testIdentity, testClock, { - maxAttempts: 1, - delayMs: () => RECONNECT_DELAY_MS, - }); - await session.connect("ws://node", ["core/data"]); - connections[0]?.fail(new Error("dropped")); - await vi.advanceTimersByTimeAsync(0); - expect(vi.getTimerCount()).toBeGreaterThan(0); - await session.close(); - expect(vi.getTimerCount()).toBe(0); - await vi.advanceTimersByTimeAsync(RECONNECT_DELAY_MS); - expect(connections).toHaveLength(1); - } finally { - vi.useRealTimers(); - } - }); - - it("finalizes as closed by you when close() is called while a reconnect is pending", async () => { - vi.useFakeTimers(); - try { - const { transport, connections } = multiConnectionTransport(); - const session = createMeshSession(transport, testIdentity, testClock, { - maxAttempts: 1, - delayMs: () => RECONNECT_DELAY_MS, - }); - await session.connect("ws://node", ["core/data"]); - connections[0]?.fail(new Error("dropped")); - const reconnecting = (await nthEvent( - session, - EVENTS_THROUGH_FIRST_RECONNECT, - )) as { state: { status: string } }; - expect(reconnecting.state.status).toBe("reconnecting"); - const eventsDone = nthEvent(session, 1); - await session.close(); - const event = (await eventsDone) as { - state: { status: string; reason?: string }; - }; - expect(event.state.status).toBe("closed"); - expect((event.state as { reason: string }).reason).toBe("closed by you"); - } finally { - vi.useRealTimers(); - } - }); - - it("treats a failed retry dial itself as a disconnect, not a silently swallowed error", async () => { - vi.useFakeTimers(); - try { - const connections: FakeConnection[] = []; - let calls = 0; - const transport: Transport = { - connect: async (address: string): Promise => { - calls += 1; - if (address !== "ws://node") { - return Promise.reject(new Error(`connect to ${address} failed`)); - } - if (calls > 1) { - return Promise.reject(new Error("dial failed on retry")); - } - const next = new FakeConnection(); - connections.push(next); - return Promise.resolve(next.connection); - }, - listen: async (): Promise => - Promise.reject(new Error("client-only transport")), - }; - const session = createMeshSession(transport, testIdentity, testClock, { - maxAttempts: 1, - delayMs: () => RECONNECT_DELAY_MS, - }); - await session.connect("ws://node", ["core/data"]); - connections[0]?.fail(new Error("dropped")); - await nthEvent(session, EVENTS_THROUGH_FIRST_RECONNECT); - 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; - } - } - expect(event?.state.status).toBe("closed"); - expect(event?.state.reason).toBe("dial failed on retry"); - } finally { - vi.useRealTimers(); - } - }); -}); - -const TEST_TOKEN_SIGNATURE_BYTE = 3; -const TEST_INCOMING_REQUEST_ID = 7; -const OVERRIDE_TOKEN_BYTE = 9; -const MANAGE_REQUEST_TIMEOUT_MS = 5000; - -describe("capability tokens and manage-request plumbing", () => { - const testCommand: ManageCommand = { - verb: "exec:proc", - params: { verb: "exec.list" }, - }; - const testScope: CapabilityScope = { kind: "folder" }; - const testToken: CapabilityToken = [ - new Uint8Array([1]), - {}, - new Uint8Array([2]), - new Uint8Array([TEST_TOKEN_SIGNATURE_BYTE]), - ]; - - it("attaches the current token to every manage-request it sends", async () => { - const { transport, connection } = fakeTransport(); - const session = createMeshSession(transport, testIdentity, testClock); - await session.connect("ws://node", ["core/management"]); - session.setToken(testToken); - const pending = session.sendManageRequest(testCommand, testScope); - await Promise.resolve(); - const sentRequest = connection.sent.at(-1) as ManageRequestFrame; - expect(sentRequest.type).toBe("manage-request"); - expect(sentRequest.command).toEqual(testCommand); - expect(sentRequest.scope).toEqual(testScope); - expect(sentRequest.token).toEqual(testToken); - await session.close(); - await expect(pending).rejects.toThrow(); - }); - - it("does not attach a token to a manage-request when none has been set", async () => { - const { transport, connection } = fakeTransport(); - const session = createMeshSession(transport, testIdentity, testClock); - await session.connect("ws://node", ["core/management"]); - const pending = session.sendManageRequest(testCommand, testScope); - await Promise.resolve(); - const sentRequest = connection.sent.at(-1) as ManageRequestFrame; - expect(sentRequest.token).toBeUndefined(); - await session.close(); - await expect(pending).rejects.toThrow(); - }); - - it("attaches a per-call token override even when no session-global token has been set", async () => { - const { transport, connection } = fakeTransport(); - const session = createMeshSession(transport, testIdentity, testClock); - await session.connect("ws://node", ["core/management"]); - const pending = session.sendManageRequest( - testCommand, - testScope, - undefined, - testToken, - ); - await Promise.resolve(); - const sentRequest = connection.sent.at(-1) as ManageRequestFrame; - expect(sentRequest.token).toEqual(testToken); - await session.close(); - await expect(pending).rejects.toThrow(); - }); - - it("a per-call token override takes precedence over the session-global token for that one request only", async () => { - const { transport, connection } = fakeTransport(); - const session = createMeshSession(transport, testIdentity, testClock); - await session.connect("ws://node", ["core/management"]); - session.setToken(testToken); - const overrideToken: CapabilityToken = [ - new Uint8Array([OVERRIDE_TOKEN_BYTE]), - {}, - new Uint8Array([OVERRIDE_TOKEN_BYTE]), - new Uint8Array([OVERRIDE_TOKEN_BYTE]), - ]; - - const overridden = session.sendManageRequest( - testCommand, - testScope, - undefined, - overrideToken, - ); - await Promise.resolve(); - expect((connection.sent.at(-1) as ManageRequestFrame).token).toEqual( - overrideToken, - ); - - // The very next request, with no override of its own, must fall back to setToken's session-global value -- the override applies to the one call it was passed to, not for the rest of the session. - const usingSessionDefault = session.sendManageRequest( - testCommand, - testScope, - ); - await Promise.resolve(); - expect((connection.sent.at(-1) as ManageRequestFrame).token).toEqual( - testToken, - ); - - await session.close(); - await expect(overridden).rejects.toThrow(); - await expect(usingSessionDefault).rejects.toThrow(); - }); - - it("assigns sequentially increasing request-ids to successive sendManageRequest calls", async () => { - const { transport, connection } = fakeTransport(); - const session = createMeshSession(transport, testIdentity, testClock); - await session.connect("ws://node", ["core/management"]); - const first = session.sendManageRequest(testCommand, testScope); - await Promise.resolve(); - const firstId = (connection.sent.at(-1) as ManageRequestFrame)[ - "request-id" - ]; - const second = session.sendManageRequest(testCommand, testScope); - await Promise.resolve(); - const secondId = (connection.sent.at(-1) as ManageRequestFrame)[ - "request-id" - ]; - expect(secondId).toBe(firstId + 1); - await session.close(); - await expect(first).rejects.toThrow(); - await expect(second).rejects.toThrow(); - }); - - it("refuses sendManageRequest while not connected", async () => { - const { transport } = fakeTransport(); - const session = createMeshSession(transport, testIdentity, testClock); - await expect( - session.sendManageRequest(testCommand, testScope), - ).rejects.toThrow("not connected"); - }); - - it("refuses sendManageRequest once the connection has failed and closed, not just before the first connect", async () => { - const { transport, connection } = fakeTransport(); - const session = createMeshSession(transport, testIdentity, testClock); - await session.connect("ws://node", ["core/management"]); - connection.fail(new Error("dropped")); - await nthEvent(session, EVENTS_THROUGH_FAILURE); - await expect( - session.sendManageRequest(testCommand, testScope), - ).rejects.toThrow("not connected"); - }); - - it("never times out a request when no timeoutMs is given", async () => { - vi.useFakeTimers(); - try { - const { transport, connection } = fakeTransport(); - const session = createMeshSession(transport, testIdentity, testClock); - await session.connect("ws://node", ["core/management"]); - const pending = session.sendManageRequest(testCommand, testScope); - await vi.advanceTimersByTimeAsync(1); - const sentRequest = connection.sent.at(-1) as ManageRequestFrame; - connection.push({ - type: "manage-response", - "request-id": sentRequest["request-id"], - outcome: { result: "ok" }, - } satisfies ManageResponseFrame); - await expect(pending).resolves.toEqual({ result: "ok" }); - await session.close(); - } finally { - vi.useRealTimers(); - } - }); - - it("resolves sendManageRequest only with the outcome of the matching manage-response", async () => { - const { transport, connection } = fakeTransport(); - const session = createMeshSession(transport, testIdentity, testClock); - await session.connect("ws://node", ["core/management"]); - const pending = session.sendManageRequest(testCommand, testScope); - await Promise.resolve(); - const sentRequest = connection.sent.at(-1) as ManageRequestFrame; - const requestId = sentRequest["request-id"]; - - // A response for a different request-id must not resolve this pending request. - connection.push({ - type: "manage-response", - "request-id": requestId + 1, - outcome: { result: "error", code: "wrong-request" }, - } satisfies ManageResponseFrame); - connection.push({ - type: "manage-response", - "request-id": requestId, - outcome: { result: "ok" }, - } satisfies ManageResponseFrame); - - const outcome: ManageOutcome = await pending; - expect(outcome).toEqual({ result: "ok" }); - await session.close(); - }); - - it("rejects a pending sendManageRequest when the session is closed before a response arrives", async () => { - const { transport } = fakeTransport(); - const session = createMeshSession(transport, testIdentity, testClock); - await session.connect("ws://node", ["core/management"]); - const pending = session.sendManageRequest(testCommand, testScope); - await session.close(); - await expect(pending).rejects.toThrow( - "connection closed before a response arrived", - ); - }); - - it("rejects a pending sendManageRequest when the connection disconnects before a response arrives", async () => { - const { transport, connection } = fakeTransport(); - const session = createMeshSession(transport, testIdentity, testClock); - await session.connect("ws://node", ["core/management"]); - const pending = session.sendManageRequest(testCommand, testScope); - connection.fail(new Error("dropped")); - await expect(pending).rejects.toThrow( - "disconnected before a response arrived", - ); - }); - - it("resolves with a timeout outcome, not a hang, when no response arrives within timeoutMs", async () => { - vi.useFakeTimers(); - try { - const { transport } = fakeTransport(); - const session = createMeshSession(transport, testIdentity, testClock); - await session.connect("ws://node", ["core/management"]); - const pending = session.sendManageRequest( - testCommand, - testScope, - undefined, - undefined, - MANAGE_REQUEST_TIMEOUT_MS, - ); - await vi.advanceTimersByTimeAsync(MANAGE_REQUEST_TIMEOUT_MS); - await expect(pending).resolves.toEqual({ - result: "error", - code: "timeout", - }); - await session.close(); - } finally { - vi.useRealTimers(); - } - }); - - it("does not time out a request whose response arrives before timeoutMs elapses", async () => { - vi.useFakeTimers(); - try { - const { transport, connection } = fakeTransport(); - const session = createMeshSession(transport, testIdentity, testClock); - await session.connect("ws://node", ["core/management"]); - const pending = session.sendManageRequest( - testCommand, - testScope, - undefined, - undefined, - MANAGE_REQUEST_TIMEOUT_MS, - ); - await vi.advanceTimersByTimeAsync(0); - const sentRequest = connection.sent.at(-1) as ManageRequestFrame; - connection.push({ - type: "manage-response", - "request-id": sentRequest["request-id"], - outcome: { result: "ok" }, - } satisfies ManageResponseFrame); - await expect(pending).resolves.toEqual({ result: "ok" }); - await vi.advanceTimersByTimeAsync(MANAGE_REQUEST_TIMEOUT_MS); - await session.close(); - } finally { - vi.useRealTimers(); - } - }); - - it("surfaces an incoming manage-request on incomingManageRequests, and sends the response frame from respond()", async () => { - const { transport, connection } = fakeTransport(); - const session = createMeshSession(transport, testIdentity, testClock); - const eventsDone = nthEvent(session, EVENTS_THROUGH_REMOTE_HANDSHAKE - 1); - await session.connect("ws://node", ["core/management"]); - await eventsDone; - - const incomingDone = (async (): Promise< - IteratorResult - > => { - const iterator = session.incomingManageRequests[Symbol.asyncIterator](); - return iterator.next(); - })(); - - connection.push({ - type: "manage-request", - "request-id": TEST_INCOMING_REQUEST_ID, - command: testCommand, - scope: testScope, - token: testToken, - } satisfies ManageRequestFrame); - - const incomingResult = await incomingDone; - expect(incomingResult.done).toBe(false); - const incoming = yielded(incomingResult); - expect(incoming.requestId).toBe(TEST_INCOMING_REQUEST_ID); - expect(incoming.command).toEqual(testCommand); - expect(incoming.scope).toEqual(testScope); - expect(incoming.token).toEqual(testToken); - - // Consume the "received manage-request" event that is already backlogged. - const eventsIterator = session.events[Symbol.asyncIterator](); - await eventsIterator.next(); - - await incoming.respond({ result: "ok" }); - const sentResponse = connection.sent.at(-1) as ManageResponseFrame; - expect(sentResponse).toEqual({ - type: "manage-response", - "request-id": TEST_INCOMING_REQUEST_ID, - outcome: { result: "ok" }, - } satisfies ManageResponseFrame); - // Must already be available -- not merely eventually rescued by session.close()'s own trailing emit(), which would otherwise mask a missing emit() call inside respond(). - const responseResult = await withinShortWait(eventsIterator.next()); - expect(responseResult).not.toBe(TIMEOUT_MARKER); - const responseEvent = yielded( - responseResult as IteratorResult<{ - frameLog: { direction: string; frame: { type: string } }[]; - }>, - ); - expect(responseEvent.frameLog.at(-1)).toEqual({ - direction: "sent", - frame: sentResponse, - }); - await session.close(); - }); - - it("delivers a manage-request queued before anyone was iterating incomingManageRequests, from the backlog", async () => { - const { transport, connection } = fakeTransport(); - const session = createMeshSession(transport, testIdentity, testClock); - const eventsDone = nthEvent(session, EVENTS_THROUGH_REMOTE_HANDSHAKE - 1); - await session.connect("ws://node", ["core/management"]); - await eventsDone; - - const nextEventDone = nthEvent(session, 1); - connection.push({ - type: "manage-request", - "request-id": TEST_INCOMING_REQUEST_ID, - command: testCommand, - scope: testScope, - } satisfies ManageRequestFrame); - await nextEventDone; - - const iterator = session.incomingManageRequests[Symbol.asyncIterator](); - const result = await iterator.next(); - expect(result.done).toBe(false); - expect(yielded(result).requestId).toBe(TEST_INCOMING_REQUEST_ID); - await session.close(); - }); -}); - -describe("revocation-announce plumbing", () => { - const ENTRY_B_PROTECTED_HEADER_BYTE = 11; - const ENTRY_B_PAYLOAD_BYTE = 12; - const testEntryA: RevocationEntry = [ - new Uint8Array([1]), - {}, - new Uint8Array([2]), - new Uint8Array([TEST_TOKEN_SIGNATURE_BYTE]), - ]; - const testEntryB: RevocationEntry = [ - new Uint8Array([ENTRY_B_PROTECTED_HEADER_BYTE]), - {}, - new Uint8Array([ENTRY_B_PAYLOAD_BYTE]), - new Uint8Array([TEST_TOKEN_SIGNATURE_BYTE + 1]), - ]; - - it("sends a revocation-announce frame carrying the given entries", async () => { - const { transport, connection } = fakeTransport(); - const session = createMeshSession(transport, testIdentity, testClock); - await session.connect("ws://node", ["core/management"]); - - await session.sendRevocationAnnounce([testEntryA, testEntryB]); - - const sentFrame = connection.sent.at(-1) as RevocationAnnounceFrame; - expect(sentFrame).toEqual({ - type: "revocation-announce", - entries: [testEntryA, testEntryB], - } satisfies RevocationAnnounceFrame); - await session.close(); - }); - - it("emits a session event after sending a revocation-announce", async () => { - const { transport } = fakeTransport(); - const session = createMeshSession(transport, testIdentity, testClock); - const eventsDone = nthEvent(session, EVENTS_THROUGH_REMOTE_HANDSHAKE - 1); - await session.connect("ws://node", ["core/management"]); - await eventsDone; - - const eventsIterator = session.events[Symbol.asyncIterator](); - await session.sendRevocationAnnounce([testEntryA]); - // Must already be available -- not merely eventually rescued by session.close()'s own trailing emit(), which would otherwise mask a missing emit() call in sendRevocationAnnounce. - const result = await withinShortWait(eventsIterator.next()); - expect(result).not.toBe(TIMEOUT_MARKER); - const event = yielded( - result as IteratorResult<{ - frameLog: { direction: string; frame: { type: string } }[]; - }>, - ); - expect(event.frameLog.at(-1)).toEqual({ - direction: "sent", - frame: { - type: "revocation-announce", - entries: [testEntryA], - }, - }); - await session.close(); - }); - - it("refuses sendRevocationAnnounce while not connected", async () => { - const { transport } = fakeTransport(); - const session = createMeshSession(transport, testIdentity, testClock); - await expect(session.sendRevocationAnnounce([testEntryA])).rejects.toThrow( - "not connected", - ); - }); - - it("refuses sendRevocationAnnounce once the connection has failed and closed, not just before the first connect", async () => { - const { transport, connection } = fakeTransport(); - const session = createMeshSession(transport, testIdentity, testClock); - await session.connect("ws://node", ["core/management"]); - connection.fail(new Error("dropped")); - await nthEvent(session, EVENTS_THROUGH_FAILURE); - await expect(session.sendRevocationAnnounce([testEntryA])).rejects.toThrow( - "not connected", - ); - }); - - it("flattens an incoming revocation-announce frame's entries onto revocationAnnouncements, one item per entry", async () => { - const { transport, connection } = fakeTransport(); - const session = createMeshSession(transport, testIdentity, testClock); - await session.connect("ws://node", ["core/management"]); - - const received: RevocationEntry[] = []; - const receivedBoth = (async (): Promise => { - const iterator = session.revocationAnnouncements[Symbol.asyncIterator](); - const first = await iterator.next(); - expect(first.done).toBe(false); - received.push(yielded(first)); - const second = await iterator.next(); - expect(second.done).toBe(false); - received.push(yielded(second)); - })(); - - connection.push({ - type: "revocation-announce", - entries: [testEntryA, testEntryB], - } satisfies RevocationAnnounceFrame); - - await receivedBoth; - expect(received).toEqual([testEntryA, testEntryB]); - await session.close(); - }); - - it("delivers a revocation entry queued before anyone was iterating revocationAnnouncements, from the backlog", async () => { - const { transport, connection } = fakeTransport(); - const session = createMeshSession(transport, testIdentity, testClock); - const eventsDone = nthEvent(session, EVENTS_THROUGH_REMOTE_HANDSHAKE - 1); - await session.connect("ws://node", ["core/management"]); - await eventsDone; - - const nextEventDone = nthEvent(session, 1); - connection.push({ - type: "revocation-announce", - entries: [testEntryA], - } satisfies RevocationAnnounceFrame); - await nextEventDone; - - const iterator = session.revocationAnnouncements[Symbol.asyncIterator](); - const result = await iterator.next(); - expect(result.done).toBe(false); - expect(yielded(result)).toEqual(testEntryA); - await session.close(); - }); -}); - -describe("relay routing", () => { - const testCommand: ManageCommand = { - verb: "exec:proc", - params: { verb: "exec.list" }, - }; - const testScope: CapabilityScope = { kind: "folder" }; - - /** Decodes a relay-data frame's opaque payload back into the Frame it wraps, failing the test outright if it isn't one -- every relay-data frame this suite sends is expected to wrap a real nested frame. */ - function unwrapRelayData(frame: Frame): Frame { - expect(frame.type).toBe("relay-data"); - const inner = tryDecodeFrame((frame as RelayDataFrame).payload); - if (inner === null) { - throw new Error( - "expected the relay-data payload to decode as a nested Frame", - ); - } - return inner; - } - - /** The frame at `index` (negative counts from the end, matching Array.prototype.at), failing the test outright if there isn't one there -- avoids both a non-null assertion and an `as` cast that would silently paper over an empty/short array. */ - function frameAt(frames: readonly Frame[], index: number): Frame { - const frame = frames.at(index); - if (frame === undefined) { - throw new Error( - `expected a frame at index ${String(index)}, got ${String(frames.length)} frames`, - ); - } - return frame; - } - - const LAST_SENT = -1; - const SECOND_TO_LAST_SENT = -2; - - // Events since session start: connecting, connected, self-advert sent -- then, for a manage-request addressed to a device this session isn't already paired with, a relay-connect-sent event and a relay-data-sent event. - const EVENTS_THROUGH_FIRST_RELAY_REQUEST = 5; - // Incremental events a further sendManageRequest call emits on top of EVENTS_THROUGH_FIRST_RELAY_REQUEST: just the relay-data-sent event when the target is already paired, or a relay-connect-sent event plus a relay-data-sent event when it re-pairs to a new target. - const EVENTS_PER_RELAY_REQUEST_SAME_TARGET = 1; - const EVENTS_PER_RELAY_REQUEST_NEW_TARGET = 2; - - it("establishes a relay-connect pairing before sending a manage-request with a targetDevice, wrapped in relay-data", async () => { - const { transport, connection } = fakeTransport(); - const session = createMeshSession(transport, testIdentity, testClock); - await session.connect("ws://node", ["core/management"]); - const eventsDone = nthEvent(session, EVENTS_THROUGH_FIRST_RELAY_REQUEST); - const pending = session.sendManageRequest(testCommand, testScope, deviceA); - await eventsDone; - - const relayConnect = frameAt(connection.sent, SECOND_TO_LAST_SENT); - expect(relayConnect).toEqual({ - type: "relay-connect", - "target-device": deviceA, - }); - - const relayData = frameAt(connection.sent, LAST_SENT); - const inner = unwrapRelayData(relayData) as ManageRequestFrame; - expect(inner.type).toBe("manage-request"); - expect(inner.command).toEqual(testCommand); - expect(inner.scope).toEqual(testScope); - - await session.close(); - await expect(pending).rejects.toThrow(); - }); - - it("reuses an established pairing rather than sending a second relay-connect for the same target", async () => { - const { transport, connection } = fakeTransport(); - const session = createMeshSession(transport, testIdentity, testClock); - await session.connect("ws://node", ["core/management"]); - const firstDone = nthEvent(session, EVENTS_THROUGH_FIRST_RELAY_REQUEST); - const first = session.sendManageRequest(testCommand, testScope, deviceA); - await firstDone; - const secondDone = nthEvent(session, EVENTS_PER_RELAY_REQUEST_SAME_TARGET); - const second = session.sendManageRequest(testCommand, testScope, deviceA); - await secondDone; - - const relayConnects = connection.sent.filter( - (frame) => frame.type === "relay-connect", - ); - expect(relayConnects).toHaveLength(1); - const relayDataFrames = connection.sent.filter( - (frame) => frame.type === "relay-data", - ); - expect(relayDataFrames).toHaveLength(2); - await session.close(); - await expect(first).rejects.toThrow(); - await expect(second).rejects.toThrow(); - }); - - it("sends a fresh relay-connect when a later request targets a different device", async () => { - const { transport, connection } = fakeTransport(); - const session = createMeshSession(transport, testIdentity, testClock); - await session.connect("ws://node", ["core/management"]); - const firstDone = nthEvent(session, EVENTS_THROUGH_FIRST_RELAY_REQUEST); - const first = session.sendManageRequest(testCommand, testScope, deviceA); - await firstDone; - const secondDone = nthEvent(session, EVENTS_PER_RELAY_REQUEST_NEW_TARGET); - const second = session.sendManageRequest(testCommand, testScope, deviceB); - await secondDone; - - const relayConnects = connection.sent.filter( - (frame) => frame.type === "relay-connect", - ); - expect(relayConnects).toEqual([ - { type: "relay-connect", "target-device": deviceA }, - { type: "relay-connect", "target-device": deviceB }, - ]); - await session.close(); - await expect(first).rejects.toThrow(); - await expect(second).rejects.toThrow(); - }); - - it("dispatches a relay-data frame wrapping a manage-request into incomingManageRequests, with fromDevice set from the establishing relay-inbound", async () => { - const { transport, connection } = fakeTransport(); - const session = createMeshSession(transport, testIdentity, testClock); - await session.connect("ws://node", ["core/management"]); - - const incomingDone = (async (): Promise => { - const iterator = session.incomingManageRequests[Symbol.asyncIterator](); - const result = await iterator.next(); - return result.value as IncomingManageRequest; - })(); - - connection.push({ type: "relay-inbound", "source-device": deviceA }); - const wrapped: ManageRequestFrame = { - type: "manage-request", - "request-id": TEST_INCOMING_REQUEST_ID, - command: testCommand, - scope: testScope, - }; - connection.push({ - type: "relay-data", - payload: messageFromFrame(wrapped), - } satisfies RelayDataFrame); - - const incoming = await incomingDone; - expect(incoming.requestId).toBe(TEST_INCOMING_REQUEST_ID); - expect(incoming.command).toEqual(testCommand); - expect(incoming.scope).toEqual(testScope); - expect(incoming.fromDevice).toEqual(deviceA); - - await incoming.respond({ result: "ok" }); - const sentResponse = frameAt(connection.sent, LAST_SENT); - const innerResponse = unwrapRelayData(sentResponse); - expect(innerResponse).toEqual({ - type: "manage-response", - "request-id": TEST_INCOMING_REQUEST_ID, - outcome: { result: "ok" }, - } satisfies ManageResponseFrame); - await session.close(); - }); - - it("resolves a pending sendManageRequest from a relay-data frame wrapping the matching manage-response", async () => { - const { transport, connection } = fakeTransport(); - const session = createMeshSession(transport, testIdentity, testClock); - await session.connect("ws://node", ["core/management"]); - const eventsDone = nthEvent(session, EVENTS_THROUGH_FIRST_RELAY_REQUEST); - const pending = session.sendManageRequest(testCommand, testScope, deviceA); - await eventsDone; - const sentRelayData = frameAt(connection.sent, LAST_SENT); - const sentRequest = unwrapRelayData(sentRelayData) as ManageRequestFrame; - const requestId = sentRequest["request-id"]; - - connection.push({ - type: "relay-data", - payload: messageFromFrame({ - type: "manage-response", - "request-id": requestId, - outcome: { result: "ok" }, - } satisfies ManageResponseFrame), - } satisfies RelayDataFrame); - - const outcome: ManageOutcome = await pending; - expect(outcome).toEqual({ result: "ok" }); - await session.close(); - }); - - it("leaves a relay-data frame whose payload does not decode as a recognized frame as ordinary opaque data", async () => { - const { transport, connection } = fakeTransport(); - const session = createMeshSession(transport, testIdentity, testClock); - await session.connect("ws://node", ["core/management"]); - // A CBOR break byte on its own -- undecodable as a complete value, the same convention webrtc-transport.test.ts uses for an invalid payload. - const CBOR_BREAK_BYTE = 0xff; - const undecodable: RelayDataFrame = { - type: "relay-data", - payload: new Uint8Array([CBOR_BREAK_BYTE]), - }; - // Events: connecting, connected, self-advert sent, opaque relay-data received. - const EVENTS_THROUGH_OPAQUE_RELAY_DATA = 4; - const eventsDone = nthEvent(session, EVENTS_THROUGH_OPAQUE_RELAY_DATA); - connection.push(undecodable); - const event = (await eventsDone) as { - frameLog: { direction: string; frame: Frame }[]; - }; - expect(event.frameLog.at(-1)).toEqual({ - direction: "received", - frame: undecodable, - }); - await session.close(); - }); - - it("leaves a relay-data frame whose payload decodes as a recognized but non-manage frame as ordinary opaque data", async () => { - const { transport, connection } = fakeTransport(); - const session = createMeshSession(transport, testIdentity, testClock); - await session.connect("ws://node", ["core/management"]); - const wrapped: RelayDataFrame = { - type: "relay-data", - payload: messageFromFrame({ type: "ping" }), - }; - const EVENTS_THROUGH_OPAQUE_RELAY_DATA = 4; - const eventsDone = nthEvent(session, EVENTS_THROUGH_OPAQUE_RELAY_DATA); - connection.push(wrapped); - const event = (await eventsDone) as { - frameLog: { direction: string; frame: Frame }[]; - }; - expect(event.frameLog.at(-1)).toEqual({ - direction: "received", - frame: wrapped, - }); - await session.close(); - }); -}); - -describe("acceptMeshSession", () => { - it("wires up handshake and self-advert immediately, with no dial step at all", async () => { - const fake = new FakeConnection(); - const session = await acceptMeshSession( - fake.connection, - testIdentity, - ["core/data"], - { clock: testClock, label: "peer-over-tcp" }, - ); - - expect(fake.sent[0]).toEqual({ - type: "handshake", - version: 1, - domains: ["core/data"], - } satisfies HandshakeFrame); - const selfAdvert = fake.sent[1] as GossipFrame; - expect(selfAdvert.peers[0]?.device).toEqual(testIdentity.deviceId); - await session.close(); - }); - - it("advertises this node's own given addresses in its self-advert", async () => { - const fake = new FakeConnection(); - const session = await acceptMeshSession( - fake.connection, - testIdentity, - ["core/data"], - { clock: testClock, addresses: ["192.168.1.10:9000"] }, - ); - - const selfAdvert = fake.sent[1] as GossipFrame; - expect(selfAdvert.peers[0]?.addresses).toEqual(["192.168.1.10:9000"]); - await session.close(); - }); - - it("advertises no addresses in its self-advert when none are given, rather than a stray default", async () => { - const fake = new FakeConnection(); - const session = await acceptMeshSession(fake.connection, testIdentity, [ - "core/data", - ]); - - const selfAdvert = fake.sent[1] as GossipFrame; - expect(selfAdvert.peers[0]?.addresses).toEqual([]); - await session.close(); - }); - - it("labels its connection state 'accepted' when no label is given", async () => { - const fake = new FakeConnection(); - const session = await acceptMeshSession( - fake.connection, - testIdentity, - ["core/data"], - { clock: testClock }, - ); - const event = (await nthEvent(session, 1)) as { - state: { address: string }; - }; - expect(event.state.address).toBe("accepted"); - await session.close(); - }); - - it("negotiates against the remote's own handshake exactly like the dial side", async () => { - const fake = new FakeConnection(); - const session = await acceptMeshSession(fake.connection, testIdentity, [ - "core/management", - "core/data", - ]); - const eventsDone = nthEvent(session, EVENTS_THROUGH_REMOTE_HANDSHAKE - 1); - fake.push({ - type: "handshake", - version: 1, - domains: ["core/data", "core/exec"], - }); - const event = (await eventsDone) as { - state: { - status: string; - handshake: { status: string; sharedDomains: string[] }; - }; - }; - expect(event.state.status).toBe("connected"); - expect(event.state.handshake.status).toBe("negotiated"); - expect(event.state.handshake.sharedDomains).toEqual(["core/data"]); - await session.close(); - }); - - it("resolves peerDeviceId from the remote's own first self-advert", async () => { - const fake = new FakeConnection(); - const session = await acceptMeshSession(fake.connection, testIdentity, [ - "core/data", - ]); - fake.push(gossipFor(deviceB)); - await expect(session.peerDeviceId).resolves.toEqual(deviceB); - await session.close(); - }); - - it("peerDeviceId resolves from the first advert and never changes on a later one", async () => { - const fake = new FakeConnection(); - const session = await acceptMeshSession(fake.connection, testIdentity, [ - "core/data", - ]); - fake.push(gossipFor(deviceA)); - await expect(session.peerDeviceId).resolves.toEqual(deviceA); - fake.push(gossipFor(deviceB)); - // Same promise, already settled -- a second, different advert cannot retroactively change what it resolved to. - await expect(session.peerDeviceId).resolves.toEqual(deviceA); - await session.close(); - }); - - it("refuses connect(): the session is already connected by construction, with nothing to dial", async () => { - const fake = new FakeConnection(); - const session = await acceptMeshSession(fake.connection, testIdentity, [ - "core/data", - ]); - await expect(session.connect("ws://node", ["core/data"])).rejects.toThrow( - "connects once", - ); - await session.close(); - }); - - it("close() closes the underlying connection and rejects pending manage-requests, same as the dial side", async () => { - const fake = new FakeConnection(); - const session = await acceptMeshSession(fake.connection, testIdentity, [ - "core/data", - ]); - const pending = session.sendManageRequest( - { verb: "exec:proc", params: { verb: "exec.list" } }, - { kind: "folder" }, - ); - await session.close(); - await expect(pending).rejects.toThrow(); - }); -}); diff --git a/ts/packages/core/test/token-delegation-narrowing.test.ts b/ts/packages/core/test/token-delegation-narrowing.test.ts new file mode 100644 index 0000000..cf9f0d2 --- /dev/null +++ b/ts/packages/core/test/token-delegation-narrowing.test.ts @@ -0,0 +1,615 @@ +import { beforeAll, describe, expect, it } from "vitest"; +import { verifyCapabilityToken } from "../src/domain/tokens.js"; +import type { IdentityPort } from "../src/ports/identity.js"; +import type { + CapabilityScope, + CapabilityToken, + DeviceId, + RevocationClaims, + TokenClaims, +} from "../src/generated/protocol.js"; +import { + HOUR_MS, + REVOKED_SHORTLY_BEFORE_NOW_MS, + encodeBuf, + fixedClock, + generateEs256Identity, + neverRevoked, + nextTokenId, + revocationView, + signToken, +} from "./tokens-fixtures.js"; + +describe("verifyCapabilityToken - delegation narrowing", () => { + let issuer: IdentityPort; + let bearerIdentity: IdentityPort; + let bearerDeviceId: DeviceId; + + beforeAll(async () => { + issuer = await generateEs256Identity(); + // Kept in full, not just its device-id: a delegated token's own self-certifying issuer-key must belong to whichever identity actually signs it, so the tests that have this device delegate a narrower token need to sign as it, not merely reference its device-id. + bearerIdentity = await generateEs256Identity(); + bearerDeviceId = bearerIdentity.deviceId; + }); + + const now = 1_893_456_000_000; + const workScope: CapabilityScope = { kind: "folder", path: "/work" }; + + it("accepts a delegated token whose expiry narrows the parent's", async () => { + const rootExpiry = now + 2 * HOUR_MS; + const root = await signToken(issuer, { + tokenId: nextTokenId(), + bearer: bearerDeviceId, + scope: workScope, + expires: rootExpiry, + }); + + const delegate = await generateEs256Identity(); + // Signed by bearerIdentity, not issuer: the root's bearer is the one delegating, so it must be the actual signer of the child token -- a self-certifying token's issuer-key has to belong to whoever actually signed it (checked independently by "wrong_issuer"), and delegation requires the child's issuer to equal the parent's bearer. + const claims: TokenClaims = { + "token-id": nextTokenId(), + issuer: bearerDeviceId, + "issuer-key": bearerIdentity.identityKey, + bearer: delegate.deviceId, + capability: "exec:pty", + scope: { kind: "folder", path: "/work/subdir" }, + expires: now + HOUR_MS, + parent: encodeBuf(root), + }; + const payload = encodeBuf(claims); + const protectedHeader = encodeBuf({}); + const toBeSigned = encodeBuf([ + "Signature1", + protectedHeader, + new Uint8Array(0), + payload, + ]); + const signature = await bearerIdentity.sign(toBeSigned); + const delegated: CapabilityToken = [ + protectedHeader, + {}, + payload, + signature, + ]; + + const verdict = await verifyCapabilityToken(delegated, { + identity: issuer, + clock: fixedClock(now), + revocation: neverRevoked, + }); + + expect(verdict.ok).toBe(true); + }); + + it("rejects a delegated token whose expiry exceeds its parent's", async () => { + const rootExpiry = now + HOUR_MS; + const root = await signToken(issuer, { + tokenId: nextTokenId(), + bearer: bearerDeviceId, + scope: workScope, + expires: rootExpiry, + }); + + const delegate = await generateEs256Identity(); + const claims: TokenClaims = { + "token-id": nextTokenId(), + issuer: bearerDeviceId, + "issuer-key": bearerIdentity.identityKey, + bearer: delegate.deviceId, + capability: "exec:pty", + scope: { kind: "folder", path: "/work/subdir" }, + expires: rootExpiry + HOUR_MS, // wider than the parent -- must be rejected + parent: encodeBuf(root), + }; + const payload = encodeBuf(claims); + const protectedHeader = encodeBuf({}); + const toBeSigned = encodeBuf([ + "Signature1", + protectedHeader, + new Uint8Array(0), + payload, + ]); + const signature = await bearerIdentity.sign(toBeSigned); + const widened: CapabilityToken = [protectedHeader, {}, payload, signature]; + + const verdict = await verifyCapabilityToken(widened, { + identity: issuer, + clock: fixedClock(now), + revocation: neverRevoked, + }); + + expect(verdict).toEqual({ ok: false, reason: "delegation_exceeds_parent" }); + }); + + it("rejects a delegated token whose ancestor is revoked, even though the leaf itself is not", async () => { + const rootTokenId = nextTokenId(); + const root = await signToken(issuer, { + tokenId: rootTokenId, + bearer: bearerDeviceId, + scope: workScope, + expires: now + 2 * HOUR_MS, + }); + + const delegate = await generateEs256Identity(); + const claims: TokenClaims = { + "token-id": nextTokenId(), + issuer: bearerDeviceId, + "issuer-key": bearerIdentity.identityKey, + bearer: delegate.deviceId, + capability: "exec:pty", + scope: { kind: "folder", path: "/work/subdir" }, + expires: now + HOUR_MS, + parent: encodeBuf(root), + }; + const payload = encodeBuf(claims); + const protectedHeader = encodeBuf({}); + const toBeSigned = encodeBuf([ + "Signature1", + protectedHeader, + new Uint8Array(0), + payload, + ]); + const signature = await bearerIdentity.sign(toBeSigned); + const delegated: CapabilityToken = [ + protectedHeader, + {}, + payload, + signature, + ]; + + // Only the ROOT is revoked, by the root's own issuer -- the sweep must reach it through the delegation chain, not stop at the leaf. + const rootRevokedByIssuer: RevocationClaims = { + "token-id": rootTokenId, + issuer: issuer.deviceId, + "issuer-key": issuer.identityKey, + "revoked-at": now - REVOKED_SHORTLY_BEFORE_NOW_MS, + }; + + const verdict = await verifyCapabilityToken(delegated, { + identity: issuer, + clock: fixedClock(now), + revocation: revocationView([rootRevokedByIssuer]), + }); + + expect(verdict).toEqual({ ok: false, reason: "parent_invalid" }); + }); + + // -- Delegation must narrow scope and capability, not just expiry -- + + interface DelegatedSeed { + tokenId: Uint8Array; + bearer: DeviceId; + scope: CapabilityScope; + capability?: string; + expires: number; + parent: CapabilityToken; + delegationsRemaining?: number; + } + + /** Signs a child token as `identity` with an explicit parent token embedded -- the parent's own claims are recoverable by the verifier's own recursion, so they are not restated here. Deliberately does none of mintCapabilityToken's own narrowing checks -- tests exercising verifyCapabilityToken's own enforcement need to construct chains mint would refuse to produce. */ + async function signDelegated( + identity: IdentityPort, + seed: DelegatedSeed, + ): Promise { + const claims: TokenClaims = { + "token-id": seed.tokenId, + issuer: identity.deviceId, + "issuer-key": identity.identityKey, + bearer: seed.bearer, + capability: seed.capability ?? "exec:pty", + scope: seed.scope, + expires: seed.expires, + parent: encodeBuf(seed.parent), + ...(seed.delegationsRemaining !== undefined + ? { "delegations-remaining": seed.delegationsRemaining } + : {}), + }; + const payload = encodeBuf(claims); + const protectedHeader = encodeBuf({}); + const toBeSigned = encodeBuf([ + "Signature1", + protectedHeader, + new Uint8Array(0), + payload, + ]); + const signature = await identity.sign(toBeSigned); + return [protectedHeader, {}, payload, signature]; + } + + /** A root /work-folder token plus a helper to delegate under it, signed by the root's bearer (bearerIdentity), keeping the common expiry/scope consistent across the narrowing tests. */ + async function delegateUnderWorkRoot( + childScope: Readonly, + childCapability?: string, + ): Promise { + const root = await signToken(issuer, { + tokenId: nextTokenId(), + bearer: bearerDeviceId, + scope: { kind: "folder", path: "/work" }, + expires: now + 2 * HOUR_MS, + }); + const delegate = await generateEs256Identity(); + return signDelegated(bearerIdentity, { + tokenId: nextTokenId(), + bearer: delegate.deviceId, + scope: childScope, + ...(childCapability !== undefined ? { capability: childCapability } : {}), + expires: now + HOUR_MS, + parent: root, + }); + } + + it("accepts a delegated token with the same scope as its parent", async () => { + const delegated = await delegateUnderWorkRoot({ + kind: "folder", + path: "/work", + }); + + const verdict = await verifyCapabilityToken(delegated, { + identity: issuer, + clock: fixedClock(now), + revocation: neverRevoked, + }); + + expect(verdict.ok).toBe(true); + }); + + it("accepts a delegated token whose path descends from the parent's", async () => { + const delegated = await delegateUnderWorkRoot({ + kind: "folder", + path: "/work/subdir/deeper", + }); + + const verdict = await verifyCapabilityToken(delegated, { + identity: issuer, + clock: fixedClock(now), + revocation: neverRevoked, + }); + + expect(verdict.ok).toBe(true); + }); + + it("rejects a delegated token whose scope kind differs from the parent's", async () => { + // kind:"org" is a different kind of authority, not a narrower one -- even though its path textually starts with /work + const delegated = await delegateUnderWorkRoot({ + kind: "org", + path: "/work", + }); + + const verdict = await verifyCapabilityToken(delegated, { + identity: issuer, + clock: fixedClock(now), + revocation: neverRevoked, + }); + + expect(verdict).toEqual({ + ok: false, + reason: "delegation_exceeds_parent", + }); + }); + + it("rejects a delegated token whose path is a sibling, not a descendant", async () => { + const delegated = await delegateUnderWorkRoot({ + kind: "folder", + path: "/home/private", + }); + + const verdict = await verifyCapabilityToken(delegated, { + identity: issuer, + clock: fixedClock(now), + revocation: neverRevoked, + }); + + expect(verdict).toEqual({ + ok: false, + reason: "delegation_exceeds_parent", + }); + }); + + it("rejects a delegated token whose path merely prefixes the parent's without a segment boundary", async () => { + // "/workbook" starts with "/work" as a string but is a different path, not a descendant + const delegated = await delegateUnderWorkRoot({ + kind: "folder", + path: "/workbook", + }); + + const verdict = await verifyCapabilityToken(delegated, { + identity: issuer, + clock: fixedClock(now), + revocation: neverRevoked, + }); + + expect(verdict).toEqual({ + ok: false, + reason: "delegation_exceeds_parent", + }); + }); + + it("rejects a delegated token whose path uses a .. segment to escape the parent's", async () => { + // Purely lexical prefix comparison would accept "/work/../org" under "/work"; a path that normalises outside the parent is a widening, so any "." or ".." segment fails the narrowing comparison + const delegated = await delegateUnderWorkRoot({ + kind: "folder", + path: "/work/../org", + }); + + const verdict = await verifyCapabilityToken(delegated, { + identity: issuer, + clock: fixedClock(now), + revocation: neverRevoked, + }); + + expect(verdict).toEqual({ + ok: false, + reason: "delegation_exceeds_parent", + }); + }); + + it("rejects a delegated token with a nested .. segment even when it stays inside the parent", async () => { + // "/work/a/../b" normalises to "/work/b" which would narrow, but relative segments are rejected wholesale: fail-closed rather than reimplementing path normalisation + const delegated = await delegateUnderWorkRoot({ + kind: "folder", + path: "/work/a/../b", + }); + + const verdict = await verifyCapabilityToken(delegated, { + identity: issuer, + clock: fixedClock(now), + revocation: neverRevoked, + }); + + expect(verdict).toEqual({ + ok: false, + reason: "delegation_exceeds_parent", + }); + }); + + it("accepts a delegated token whose child path carries a trailing slash under the parent", async () => { + const delegated = await delegateUnderWorkRoot({ + kind: "folder", + path: "/work/", + }); + + const verdict = await verifyCapabilityToken(delegated, { + identity: issuer, + clock: fixedClock(now), + revocation: neverRevoked, + }); + + expect(verdict.ok).toBe(true); + }); + + it("rejects a delegated token whose child path differs only in case", async () => { + const delegated = await delegateUnderWorkRoot({ + kind: "folder", + path: "/Work", + }); + + const verdict = await verifyCapabilityToken(delegated, { + identity: issuer, + clock: fixedClock(now), + revocation: neverRevoked, + }); + + expect(verdict).toEqual({ + ok: false, + reason: "delegation_exceeds_parent", + }); + }); + + it("rejects a delegated token with no path under a path-narrowed parent", async () => { + // Absent path means the kind's whole-scope root, which is wider than the parent's /work + const delegated = await delegateUnderWorkRoot({ kind: "folder" }); + + const verdict = await verifyCapabilityToken(delegated, { + identity: issuer, + clock: fixedClock(now), + revocation: neverRevoked, + }); + + expect(verdict).toEqual({ + ok: false, + reason: "delegation_exceeds_parent", + }); + }); + + it("rejects a delegated token whose capability verb differs from the parent's", async () => { + const delegated = await delegateUnderWorkRoot( + { kind: "folder", path: "/work" }, + "exec:proc", + ); + + const verdict = await verifyCapabilityToken(delegated, { + identity: issuer, + clock: fixedClock(now), + revocation: neverRevoked, + }); + + expect(verdict).toEqual({ + ok: false, + reason: "delegation_exceeds_parent", + }); + }); + + it("rejects a delegated token whose delegations-remaining is not strictly less than its parent's", async () => { + const root = await signToken(issuer, { + tokenId: nextTokenId(), + bearer: bearerDeviceId, + scope: { kind: "folder", path: "/work" }, + expires: now + 2 * HOUR_MS, + delegationsRemaining: 1, + }); + const delegate = await generateEs256Identity(); + // Equal to the parent's own delegations-remaining (1), not strictly less -- this is the exact unbounded-admission gap the claim exists to close: without this check, any bearer of a bounded grant could mint an equally-unbounded child. + const delegated = await signDelegated(bearerIdentity, { + tokenId: nextTokenId(), + bearer: delegate.deviceId, + scope: { kind: "folder", path: "/work" }, + expires: now + HOUR_MS, + parent: root, + delegationsRemaining: 1, + }); + + const verdict = await verifyCapabilityToken(delegated, { + identity: issuer, + clock: fixedClock(now), + revocation: neverRevoked, + }); + + expect(verdict).toEqual({ + ok: false, + reason: "delegation_exceeds_parent", + }); + }); + + it("rejects an unbounded delegated token under a parent that itself bounds re-delegation", async () => { + const root = await signToken(issuer, { + tokenId: nextTokenId(), + bearer: bearerDeviceId, + scope: { kind: "folder", path: "/work" }, + expires: now + 2 * HOUR_MS, + delegationsRemaining: 1, + }); + const delegate = await generateEs256Identity(); + // No delegations-remaining at all -- unbounded, which is wider than the parent's bounded 1. + const delegated = await signDelegated(bearerIdentity, { + tokenId: nextTokenId(), + bearer: delegate.deviceId, + scope: { kind: "folder", path: "/work" }, + expires: now + HOUR_MS, + parent: root, + }); + + const verdict = await verifyCapabilityToken(delegated, { + identity: issuer, + clock: fixedClock(now), + revocation: neverRevoked, + }); + + expect(verdict).toEqual({ + ok: false, + reason: "delegation_exceeds_parent", + }); + }); + + it("accepts a delegated token whose delegations-remaining strictly narrows its parent's, and reports the chain's root and depth", async () => { + const root = await signToken(issuer, { + tokenId: nextTokenId(), + bearer: bearerDeviceId, + scope: { kind: "folder", path: "/work" }, + expires: now + 2 * HOUR_MS, + delegationsRemaining: 1, + }); + const delegate = await generateEs256Identity(); + const delegated = await signDelegated(bearerIdentity, { + tokenId: nextTokenId(), + bearer: delegate.deviceId, + scope: { kind: "folder", path: "/work" }, + expires: now + HOUR_MS, + parent: root, + delegationsRemaining: 0, + }); + + const verdict = await verifyCapabilityToken(delegated, { + identity: issuer, + clock: fixedClock(now), + revocation: neverRevoked, + }); + + expect(verdict.ok).toBe(true); + if (!verdict.ok) return; + expect(verdict.rootIssuer).toEqual(issuer.deviceId); + expect(verdict.depth).toBe(1); + }); + + it("reports depth 0 and itself as the root for a root grant with no parent", async () => { + const token = await signToken(issuer, { + tokenId: nextTokenId(), + bearer: bearerDeviceId, + scope: workScope, + expires: now + HOUR_MS, + }); + + const verdict = await verifyCapabilityToken(token, { + identity: issuer, + clock: fixedClock(now), + revocation: neverRevoked, + }); + + expect(verdict.ok).toBe(true); + if (!verdict.ok) return; + expect(verdict.rootIssuer).toEqual(issuer.deviceId); + expect(verdict.depth).toBe(0); + }); + + async function delegateUnderRootRoot( + childPath: string, + ): Promise { + const root = await signToken(issuer, { + tokenId: nextTokenId(), + bearer: bearerDeviceId, + scope: { kind: "folder", path: "/" }, + expires: now + 2 * HOUR_MS, + }); + const delegate = await generateEs256Identity(); + return signDelegated(bearerIdentity, { + tokenId: nextTokenId(), + bearer: delegate.deviceId, + scope: { kind: "folder", path: childPath }, + expires: now + HOUR_MS, + parent: root, + }); + } + + it("accepts any well-formed child path under a whole-root parent path", async () => { + const delegated = await delegateUnderRootRoot("/anything/at/all"); + + const verdict = await verifyCapabilityToken(delegated, { + identity: issuer, + clock: fixedClock(now), + revocation: neverRevoked, + }); + + expect(verdict.ok).toBe(true); + }); + + it("rejects an empty child path", async () => { + const delegated = await delegateUnderWorkRoot({ kind: "folder", path: "" }); + + const verdict = await verifyCapabilityToken(delegated, { + identity: issuer, + clock: fixedClock(now), + revocation: neverRevoked, + }); + + expect(verdict).toEqual({ + ok: false, + reason: "delegation_exceeds_parent", + }); + }); + + it("applies expectedBearer to the leaf only, never to ancestors in the chain", async () => { + // The parent's bearer is the child's issuer (bearerIdentity), NOT the leaf's presenter: a leaf presented by its own delegate must verify even though the ancestor's bearer differs. + const root = await signToken(issuer, { + tokenId: nextTokenId(), + bearer: bearerDeviceId, + scope: { kind: "folder", path: "/work" }, + expires: now + 2 * HOUR_MS, + }); + const delegate = await generateEs256Identity(); + const delegated = await signDelegated(bearerIdentity, { + tokenId: nextTokenId(), + bearer: delegate.deviceId, + scope: { kind: "folder", path: "/work" }, + expires: now + HOUR_MS, + parent: root, + }); + + const verdict = await verifyCapabilityToken(delegated, { + identity: issuer, + clock: fixedClock(now), + revocation: neverRevoked, + expectedBearer: delegate.deviceId, + }); + + expect(verdict.ok).toBe(true); + }); +}); diff --git a/ts/packages/core/test/token-minting-and-grants.test.ts b/ts/packages/core/test/token-minting-and-grants.test.ts new file mode 100644 index 0000000..7efcd17 --- /dev/null +++ b/ts/packages/core/test/token-minting-and-grants.test.ts @@ -0,0 +1,584 @@ +import { beforeAll, describe, expect, it } from "vitest"; +import { + canGrant, + mintCapabilityToken, + mintRevocationEntry, + verifyCapabilityToken, + verifyRevocationEntry, +} from "../src/domain/tokens.js"; +import type { IdentityPort } from "../src/ports/identity.js"; +import type { + CapabilityScope, + CapabilityToken, +} from "../src/generated/protocol.js"; +import { + HOUR_MS, + P256_SIGNATURE_BYTE_LENGTH, + ROOM_MEMBER_ROOM_PATH, + fixedClock, + generateEs256Identity, + neverRevoked, + nextTokenId, +} from "./tokens-fixtures.js"; + +describe("mintCapabilityToken", () => { + let issuer: IdentityPort; + let bearerIdentity: IdentityPort; + + beforeAll(async () => { + issuer = await generateEs256Identity(); + bearerIdentity = await generateEs256Identity(); + }); + + const now = 1_893_456_000_000; + const workScope: CapabilityScope = { kind: "folder", path: "/work" }; + + it("mints a root token that verifyCapabilityToken accepts", async () => { + const verdict = await mintCapabilityToken({ + identity: issuer, + clock: fixedClock(now), + tokenId: nextTokenId(), + bearer: bearerIdentity.deviceId, + capability: "exec:pty", + scope: workScope, + expires: now + HOUR_MS, + }); + expect(verdict.ok).toBe(true); + if (!verdict.ok) return; + + const verified = await verifyCapabilityToken(verdict.token, { + identity: issuer, + clock: fixedClock(now), + revocation: neverRevoked, + }); + expect(verified.ok).toBe(true); + if (!verified.ok) return; + expect(verified.claims.bearer).toEqual(bearerIdentity.deviceId); + expect(verified.claims.capability).toBe("exec:pty"); + }); + + it("mints a delegated token, signed as the parent's own bearer, that verifyCapabilityToken accepts", async () => { + const rootVerdict = await mintCapabilityToken({ + identity: issuer, + clock: fixedClock(now), + tokenId: nextTokenId(), + bearer: bearerIdentity.deviceId, + capability: "room:member", + scope: { kind: "room", path: ROOM_MEMBER_ROOM_PATH }, + expires: now + HOUR_MS, + delegationsRemaining: 1, + }); + expect(rootVerdict.ok).toBe(true); + if (!rootVerdict.ok) return; + + const delegate = await generateEs256Identity(); + const delegatedVerdict = await mintCapabilityToken({ + identity: bearerIdentity, + clock: fixedClock(now), + tokenId: nextTokenId(), + bearer: delegate.deviceId, + capability: "room:member", + scope: { kind: "room", path: ROOM_MEMBER_ROOM_PATH }, + expires: now + HOUR_MS, + delegationsRemaining: 0, + parent: rootVerdict.token, + }); + expect(delegatedVerdict.ok).toBe(true); + if (!delegatedVerdict.ok) return; + + const verified = await verifyCapabilityToken(delegatedVerdict.token, { + identity: issuer, + clock: fixedClock(now), + revocation: neverRevoked, + }); + expect(verified.ok).toBe(true); + }); + + it("refuses to mint an already-expired token", async () => { + const verdict = await mintCapabilityToken({ + identity: issuer, + clock: fixedClock(now), + tokenId: nextTokenId(), + bearer: bearerIdentity.deviceId, + capability: "exec:pty", + scope: workScope, + expires: now - 1, + }); + expect(verdict).toEqual({ ok: false, reason: "already_expired" }); + }); + + it("refuses to mint against a malformed parent", async () => { + const malformedParent: CapabilityToken = [ + new Uint8Array(), + {}, + null, + new Uint8Array(P256_SIGNATURE_BYTE_LENGTH), + ]; + const verdict = await mintCapabilityToken({ + identity: bearerIdentity, + clock: fixedClock(now), + tokenId: nextTokenId(), + bearer: (await generateEs256Identity()).deviceId, + capability: "exec:pty", + scope: workScope, + expires: now + HOUR_MS, + parent: malformedParent, + }); + expect(verdict).toEqual({ ok: false, reason: "parent_malformed" }); + }); + + it("refuses to mint a delegation the issuer's own device does not hold the parent's bearer for", async () => { + const rootVerdict = await mintCapabilityToken({ + identity: issuer, + clock: fixedClock(now), + tokenId: nextTokenId(), + bearer: bearerIdentity.deviceId, + capability: "exec:pty", + scope: workScope, + expires: now + HOUR_MS, + }); + expect(rootVerdict.ok).toBe(true); + if (!rootVerdict.ok) return; + + // Minting as `issuer` itself, not `bearerIdentity` -- the parent's bearer is bearerIdentity, not issuer, so issuer cannot delegate from it. + const verdict = await mintCapabilityToken({ + identity: issuer, + clock: fixedClock(now), + tokenId: nextTokenId(), + bearer: (await generateEs256Identity()).deviceId, + capability: "exec:pty", + scope: workScope, + expires: now + HOUR_MS, + parent: rootVerdict.token, + }); + expect(verdict).toEqual({ ok: false, reason: "parent_bearer_mismatch" }); + }); + + it("refuses to mint a delegation whose expiry exceeds its parent's", async () => { + const rootVerdict = await mintCapabilityToken({ + identity: issuer, + clock: fixedClock(now), + tokenId: nextTokenId(), + bearer: bearerIdentity.deviceId, + capability: "exec:pty", + scope: workScope, + expires: now + HOUR_MS, + }); + expect(rootVerdict.ok).toBe(true); + if (!rootVerdict.ok) return; + + const verdict = await mintCapabilityToken({ + identity: bearerIdentity, + clock: fixedClock(now), + tokenId: nextTokenId(), + bearer: (await generateEs256Identity()).deviceId, + capability: "exec:pty", + scope: workScope, + expires: now + 2 * HOUR_MS, + parent: rootVerdict.token, + }); + expect(verdict).toEqual({ ok: false, reason: "expires_exceeds_parent" }); + }); + + it("refuses to mint a delegation whose scope does not narrow its parent's", async () => { + const rootVerdict = await mintCapabilityToken({ + identity: issuer, + clock: fixedClock(now), + tokenId: nextTokenId(), + bearer: bearerIdentity.deviceId, + capability: "exec:pty", + scope: workScope, + expires: now + HOUR_MS, + }); + expect(rootVerdict.ok).toBe(true); + if (!rootVerdict.ok) return; + + const verdict = await mintCapabilityToken({ + identity: bearerIdentity, + clock: fixedClock(now), + tokenId: nextTokenId(), + bearer: (await generateEs256Identity()).deviceId, + capability: "exec:pty", + scope: { kind: "folder", path: "/elsewhere" }, + expires: now + HOUR_MS, + parent: rootVerdict.token, + }); + expect(verdict).toEqual({ ok: false, reason: "scope_does_not_narrow" }); + }); + + it("refuses to mint a delegation with a different capability than its parent's", async () => { + const rootVerdict = await mintCapabilityToken({ + identity: issuer, + clock: fixedClock(now), + tokenId: nextTokenId(), + bearer: bearerIdentity.deviceId, + capability: "exec:pty", + scope: workScope, + expires: now + HOUR_MS, + }); + expect(rootVerdict.ok).toBe(true); + if (!rootVerdict.ok) return; + + const verdict = await mintCapabilityToken({ + identity: bearerIdentity, + clock: fixedClock(now), + tokenId: nextTokenId(), + bearer: (await generateEs256Identity()).deviceId, + capability: "exec:proc", + scope: workScope, + expires: now + HOUR_MS, + parent: rootVerdict.token, + }); + expect(verdict).toEqual({ ok: false, reason: "capability_mismatch" }); + }); + + it("refuses to mint a delegation whose delegations-remaining is not strictly less than its parent's", async () => { + const rootVerdict = await mintCapabilityToken({ + identity: issuer, + clock: fixedClock(now), + tokenId: nextTokenId(), + bearer: bearerIdentity.deviceId, + capability: "room:member", + scope: { kind: "room", path: ROOM_MEMBER_ROOM_PATH }, + expires: now + HOUR_MS, + delegationsRemaining: 1, + }); + expect(rootVerdict.ok).toBe(true); + if (!rootVerdict.ok) return; + + // Equal to the parent's own delegations-remaining (1), not strictly less. + const verdict = await mintCapabilityToken({ + identity: bearerIdentity, + clock: fixedClock(now), + tokenId: nextTokenId(), + bearer: (await generateEs256Identity()).deviceId, + capability: "room:member", + scope: { kind: "room", path: ROOM_MEMBER_ROOM_PATH }, + expires: now + HOUR_MS, + delegationsRemaining: 1, + parent: rootVerdict.token, + }); + expect(verdict).toEqual({ ok: false, reason: "delegation_exceeds_parent" }); + }); + + it("refuses to mint an unbounded delegation under a parent that itself bounds re-delegation", async () => { + const rootVerdict = await mintCapabilityToken({ + identity: issuer, + clock: fixedClock(now), + tokenId: nextTokenId(), + bearer: bearerIdentity.deviceId, + capability: "room:member", + scope: { kind: "room", path: ROOM_MEMBER_ROOM_PATH }, + expires: now + HOUR_MS, + delegationsRemaining: 1, + }); + expect(rootVerdict.ok).toBe(true); + if (!rootVerdict.ok) return; + + // No delegationsRemaining at all -- unbounded, which is wider than the parent's bounded 1. + const verdict = await mintCapabilityToken({ + identity: bearerIdentity, + clock: fixedClock(now), + tokenId: nextTokenId(), + bearer: (await generateEs256Identity()).deviceId, + capability: "room:member", + scope: { kind: "room", path: ROOM_MEMBER_ROOM_PATH }, + expires: now + HOUR_MS, + parent: rootVerdict.token, + }); + expect(verdict).toEqual({ ok: false, reason: "delegation_exceeds_parent" }); + }); + + it("mints a second, independent root-level grant for a different bearer even though an earlier root grant this issuer minted has delegationsRemaining: 0", async () => { + const firstRoot = await mintCapabilityToken({ + identity: issuer, + clock: fixedClock(now), + tokenId: nextTokenId(), + bearer: bearerIdentity.deviceId, + capability: "room:member", + scope: { kind: "room", path: ROOM_MEMBER_ROOM_PATH }, + expires: now + HOUR_MS, + delegationsRemaining: 0, + }); + expect(firstRoot.ok).toBe(true); + + // No `parent` at all -- a second, independent root-level grant for a different bearer, never checked against firstRoot's own (unrelated) delegationsRemaining. + const secondBearer = await generateEs256Identity(); + const secondRoot = await mintCapabilityToken({ + identity: issuer, + clock: fixedClock(now), + tokenId: nextTokenId(), + bearer: secondBearer.deviceId, + capability: "room:member", + scope: { kind: "room", path: ROOM_MEMBER_ROOM_PATH }, + expires: now + HOUR_MS, + delegationsRemaining: 0, + }); + expect(secondRoot.ok).toBe(true); + if (!secondRoot.ok) return; + + const verified = await verifyCapabilityToken(secondRoot.token, { + identity: issuer, + clock: fixedClock(now), + revocation: neverRevoked, + }); + expect(verified.ok).toBe(true); + }); +}); + +describe("canGrant", () => { + let issuer: IdentityPort; + let bearerIdentity: IdentityPort; + + beforeAll(async () => { + issuer = await generateEs256Identity(); + bearerIdentity = await generateEs256Identity(); + }); + + const now = 1_893_456_000_000; + const workScope: CapabilityScope = { kind: "folder", path: "/work" }; + + it("agrees with a real mint's own verdict: true when narrowing succeeds", async () => { + const rootVerdict = await mintCapabilityToken({ + identity: issuer, + clock: fixedClock(now), + tokenId: nextTokenId(), + bearer: bearerIdentity.deviceId, + capability: "room:member", + scope: { kind: "room", path: ROOM_MEMBER_ROOM_PATH }, + expires: now + HOUR_MS, + delegationsRemaining: 1, + }); + expect(rootVerdict.ok).toBe(true); + if (!rootVerdict.ok) return; + + const candidate = { + capability: "room:member", + scope: { kind: "room", path: ROOM_MEMBER_ROOM_PATH } as CapabilityScope, + expires: now + HOUR_MS, + delegationsRemaining: 0, + }; + expect( + canGrant(rootVerdict.token, bearerIdentity.deviceId, candidate, now), + ).toBe(true); + + const delegatedVerdict = await mintCapabilityToken({ + identity: bearerIdentity, + clock: fixedClock(now), + tokenId: nextTokenId(), + bearer: (await generateEs256Identity()).deviceId, + ...candidate, + parent: rootVerdict.token, + }); + expect(delegatedVerdict.ok).toBe(true); + }); + + it("agrees with a real mint's own verdict: false when the querying device does not hold the token's bearer", async () => { + const rootVerdict = await mintCapabilityToken({ + identity: issuer, + clock: fixedClock(now), + tokenId: nextTokenId(), + bearer: bearerIdentity.deviceId, + capability: "exec:pty", + scope: workScope, + expires: now + HOUR_MS, + }); + expect(rootVerdict.ok).toBe(true); + if (!rootVerdict.ok) return; + + expect( + canGrant( + rootVerdict.token, + issuer.deviceId, + { + capability: "exec:pty", + scope: workScope, + expires: now + HOUR_MS, + }, + now, + ), + ).toBe(false); + }); + + it("agrees with a real mint's own verdict: false when the candidate's expiry exceeds the held token's", async () => { + const rootVerdict = await mintCapabilityToken({ + identity: issuer, + clock: fixedClock(now), + tokenId: nextTokenId(), + bearer: bearerIdentity.deviceId, + capability: "exec:pty", + scope: workScope, + expires: now + HOUR_MS, + }); + expect(rootVerdict.ok).toBe(true); + if (!rootVerdict.ok) return; + + expect( + canGrant( + rootVerdict.token, + bearerIdentity.deviceId, + { + capability: "exec:pty", + scope: workScope, + expires: now + HOUR_MS + 1, + }, + now, + ), + ).toBe(false); + }); + + it("agrees with a real mint's own verdict: false when the candidate's scope does not narrow the held token's", async () => { + const rootVerdict = await mintCapabilityToken({ + identity: issuer, + clock: fixedClock(now), + tokenId: nextTokenId(), + bearer: bearerIdentity.deviceId, + capability: "exec:pty", + scope: workScope, + expires: now + HOUR_MS, + }); + expect(rootVerdict.ok).toBe(true); + if (!rootVerdict.ok) return; + + expect( + canGrant( + rootVerdict.token, + bearerIdentity.deviceId, + { + capability: "exec:pty", + scope: { kind: "folder", path: "/elsewhere" }, + expires: now + HOUR_MS, + }, + now, + ), + ).toBe(false); + }); + + it("agrees with a real mint's own verdict: false when the candidate's capability differs", async () => { + const rootVerdict = await mintCapabilityToken({ + identity: issuer, + clock: fixedClock(now), + tokenId: nextTokenId(), + bearer: bearerIdentity.deviceId, + capability: "exec:pty", + scope: workScope, + expires: now + HOUR_MS, + }); + expect(rootVerdict.ok).toBe(true); + if (!rootVerdict.ok) return; + + expect( + canGrant( + rootVerdict.token, + bearerIdentity.deviceId, + { + capability: "room:member", + scope: workScope, + expires: now + HOUR_MS, + }, + now, + ), + ).toBe(false); + }); + + it("agrees with a real mint's own verdict: false when the candidate's delegations-remaining would not be strictly less than the held token's", async () => { + const rootVerdict = await mintCapabilityToken({ + identity: issuer, + clock: fixedClock(now), + tokenId: nextTokenId(), + bearer: bearerIdentity.deviceId, + capability: "room:member", + scope: { kind: "room", path: ROOM_MEMBER_ROOM_PATH }, + expires: now + HOUR_MS, + delegationsRemaining: 1, + }); + expect(rootVerdict.ok).toBe(true); + if (!rootVerdict.ok) return; + + expect( + canGrant( + rootVerdict.token, + bearerIdentity.deviceId, + { + capability: "room:member", + scope: { kind: "room", path: ROOM_MEMBER_ROOM_PATH }, + expires: now + HOUR_MS, + delegationsRemaining: 1, + }, + now, + ), + ).toBe(false); + }); + + it("returns false for a candidate that is already expired, without needing to consult the held token at all", async () => { + const rootVerdict = await mintCapabilityToken({ + identity: issuer, + clock: fixedClock(now), + tokenId: nextTokenId(), + bearer: bearerIdentity.deviceId, + capability: "exec:pty", + scope: workScope, + expires: now + HOUR_MS, + }); + expect(rootVerdict.ok).toBe(true); + if (!rootVerdict.ok) return; + + expect( + canGrant( + rootVerdict.token, + bearerIdentity.deviceId, + { + capability: "exec:pty", + scope: workScope, + expires: now - 1, + }, + now, + ), + ).toBe(false); + }); + + it("returns false for a malformed held token", () => { + const malformedToken: CapabilityToken = [ + new Uint8Array(), + {}, + null, + new Uint8Array(P256_SIGNATURE_BYTE_LENGTH), + ]; + expect( + canGrant( + malformedToken, + bearerIdentity.deviceId, + { + capability: "exec:pty", + scope: workScope, + expires: now + HOUR_MS, + }, + now, + ), + ).toBe(false); + }); +}); + +describe("mintRevocationEntry", () => { + it("mints a revocation entry that verifyRevocationEntry accepts", async () => { + const issuer = await generateEs256Identity(); + const tokenId = nextTokenId(); + + const entry = await mintRevocationEntry({ + identity: issuer, + tokenId, + revokedAt: 1_893_456_000_000, + }); + + const verdict = await verifyRevocationEntry(entry, { identity: issuer }); + expect(verdict).toEqual({ + ok: true, + claims: { + "token-id": tokenId, + issuer: issuer.deviceId, + "issuer-key": issuer.identityKey, + "revoked-at": 1_893_456_000_000, + }, + }); + }); +}); diff --git a/ts/packages/core/test/token-revocation-and-storage.test.ts b/ts/packages/core/test/token-revocation-and-storage.test.ts new file mode 100644 index 0000000..6695c41 --- /dev/null +++ b/ts/packages/core/test/token-revocation-and-storage.test.ts @@ -0,0 +1,183 @@ +import { beforeAll, describe, expect, it } from "vitest"; +import { createMemoryStorage } from "../src/adapters/memory-storage.js"; +import { createSystemClock } from "../src/adapters/system-clock.js"; +import { verifyRevocationEntry } from "../src/domain/tokens.js"; +import type { IdentityPort } from "../src/ports/identity.js"; +import type { + RevocationClaims, + RevocationEntry, +} from "../src/generated/protocol.js"; +import { + LOW_BYTE_MASK, + P256_SIGNATURE_BYTE_LENGTH, + buf, + encodeBuf, + equalBytes, + generateEs256Identity, + nextTokenId, + signRevocationEntry, +} from "./tokens-fixtures.js"; + +describe("verifyRevocationEntry", () => { + let issuer: IdentityPort; + + beforeAll(async () => { + issuer = await generateEs256Identity(); + }); + + it("accepts a validly signed, self-certifying revocation entry and returns its claims", async () => { + const tokenId = nextTokenId(); + const entry = await signRevocationEntry(issuer, tokenId); + + const verdict = await verifyRevocationEntry(entry, { identity: issuer }); + + expect(verdict.ok).toBe(true); + if (verdict.ok) { + expect(equalBytes(verdict.claims["token-id"], tokenId)).toBe(true); + expect(equalBytes(verdict.claims.issuer, issuer.deviceId)).toBe(true); + } + }); + + it("rejects an entry whose signature doesn't verify against its embedded issuer-key", async () => { + const entry = await signRevocationEntry(issuer, nextTokenId()); + // Corrupt the signature bytes themselves: the identity port only supplies crypto primitives and checks against the entry's own embedded key, so swapping port instances proves nothing (the neighbouring test covers exactly that) -- a genuinely bad signature must be forged in the bytes. + const [protectedHeader, unprotected, payload, signature] = entry; + const tampered = Uint8Array.from(signature); + const firstByte = tampered.at(0); + if (firstByte === undefined) { + throw new Error("test setup: signature has no bytes to corrupt"); + } + tampered[0] = firstByte ^ LOW_BYTE_MASK; + const forged: RevocationEntry = [ + protectedHeader, + unprotected, + payload, + tampered, + ]; + + const verdict = await verifyRevocationEntry(forged, { identity: issuer }); + + expect(verdict).toEqual({ ok: false, reason: "bad_signature" }); + }); + + it("rejects an entry whose issuer-key does not derive its claimed issuer device-id", async () => { + // Sign as one identity but claim a different issuer: the signature verifies (it was really signed by the embedded key) but sha256(public-key) != the claimed issuer, so the entry is not self-certifying. + const actualSigner = await generateEs256Identity(); + const claimedIssuer = await generateEs256Identity(); + const claims: RevocationClaims = { + "token-id": nextTokenId(), + issuer: claimedIssuer.deviceId, + "issuer-key": actualSigner.identityKey, + "revoked-at": 0, + }; + const payload = encodeBuf(claims); + const protectedHeader = encodeBuf({}); + const toBeSigned = encodeBuf([ + "Signature1", + protectedHeader, + new Uint8Array(0), + payload, + ]); + const signature = await actualSigner.sign(toBeSigned); + const forged: RevocationEntry = [protectedHeader, {}, payload, signature]; + + const verdict = await verifyRevocationEntry(forged, { + identity: actualSigner, + }); + + expect(verdict).toEqual({ ok: false, reason: "wrong_issuer" }); + }); + + it("rejects an entry whose payload doesn't parse as revocation-claims", async () => { + const payload = encodeBuf({ nonsense: true }); + const protectedHeader = encodeBuf({}); + const toBeSigned = encodeBuf([ + "Signature1", + protectedHeader, + new Uint8Array(0), + payload, + ]); + const signature = await issuer.sign(toBeSigned); + const malformed: RevocationEntry = [ + protectedHeader, + {}, + payload, + signature, + ]; + + const verdict = await verifyRevocationEntry(malformed, { + identity: issuer, + }); + + expect(verdict).toEqual({ ok: false, reason: "malformed" }); + }); + + it("ignores bearer identity: an entry is about the issuer's token, not who presented the frame", async () => { + const entry = await signRevocationEntry(issuer, nextTokenId()); + const anyOtherViewer = await generateEs256Identity(); + + const verdict = await verifyRevocationEntry(entry, { + identity: anyOtherViewer, + }); + + // The identity port supplies only crypto primitives (verify/derive); verification succeeds regardless of which port instance performs it. + expect(verdict.ok).toBe(true); + }); + + it("returns malformed for an entry whose payload bytes are not CBOR, instead of throwing", async () => { + // This function's whole purpose is ingesting hostile gossiped entries: a lone top-level CBOR BREAK byte must produce the malformed verdict, never a throw + const garbage = buf(Buffer.from("ff", "hex")); + const hostile: RevocationEntry = [ + encodeBuf({}), + {}, + garbage, + new Uint8Array(P256_SIGNATURE_BYTE_LENGTH), + ]; + + const verdict = await verifyRevocationEntry(hostile, { identity: issuer }); + + expect(verdict).toEqual({ ok: false, reason: "malformed" }); + }); + + it("returns malformed for an entry whose payload map keys are not canonically ordered, instead of throwing", async () => { + const nonCanonical = buf(Buffer.from("a2627a7a01616102", "hex")); + const hostile: RevocationEntry = [ + encodeBuf({}), + {}, + nonCanonical, + new Uint8Array(P256_SIGNATURE_BYTE_LENGTH), + ]; + + const verdict = await verifyRevocationEntry(hostile, { identity: issuer }); + + expect(verdict).toEqual({ ok: false, reason: "malformed" }); + }); +}); + +describe("createMemoryStorage / createSystemClock", () => { + it("round-trips a value through memory storage", async () => { + const storage = createMemoryStorage(); + const sampleBytes = new TextEncoder().encode("sample"); + await storage.set("k", sampleBytes); + expect(await storage.get("k")).toEqual(sampleBytes); + await storage.delete("k"); + expect(await storage.get("k")).toBeUndefined(); + }); + + it("lists keys by prefix", async () => { + const storage = createMemoryStorage(); + await storage.set("a/1", new Uint8Array()); + await storage.set("a/2", new Uint8Array()); + await storage.set("b/1", new Uint8Array()); + expect(new Set(await storage.keys("a/"))).toEqual(new Set(["a/1", "a/2"])); + }); + + it("reports the current time", () => { + const clock = createSystemClock(); + const before = Date.now(); + const reported = clock.now(); + const after = Date.now(); + expect(reported).toBeGreaterThanOrEqual(before); + expect(reported).toBeLessThanOrEqual(after); + }); +}); diff --git a/ts/packages/core/test/token-verification-core.test.ts b/ts/packages/core/test/token-verification-core.test.ts new file mode 100644 index 0000000..27bc88f --- /dev/null +++ b/ts/packages/core/test/token-verification-core.test.ts @@ -0,0 +1,240 @@ +import { beforeAll, describe, expect, it } from "vitest"; +import { verifyCapabilityToken } from "../src/domain/tokens.js"; +import type { IdentityPort } from "../src/ports/identity.js"; +import type { + CapabilityScope, + CapabilityToken, + DeviceId, + RevocationClaims, + TokenClaims, +} from "../src/generated/protocol.js"; +import { + HOUR_MS, + LOW_BYTE_MASK, + P256_SIGNATURE_BYTE_LENGTH, + REVOKED_SHORTLY_BEFORE_NOW_MS, + buf, + encodeBuf, + fixedClock, + generateEs256Identity, + neverRevoked, + nextTokenId, + revocationView, + signToken, +} from "./tokens-fixtures.js"; + +describe("verifyCapabilityToken", () => { + let issuer: IdentityPort; + let bearerIdentity: IdentityPort; + let bearerDeviceId: DeviceId; + + beforeAll(async () => { + issuer = await generateEs256Identity(); + // Kept in full, not just its device-id: a delegated token's own self-certifying issuer-key must belong to whichever identity actually signs it, so the tests that have this device delegate a narrower token need to sign as it, not merely reference its device-id. + bearerIdentity = await generateEs256Identity(); + bearerDeviceId = bearerIdentity.deviceId; + }); + + const now = 1_893_456_000_000; + const workScope: CapabilityScope = { kind: "folder", path: "/work" }; + + it("accepts a validly signed, unexpired, unrevoked token", async () => { + const token = await signToken(issuer, { + tokenId: nextTokenId(), + bearer: bearerDeviceId, + scope: workScope, + expires: now + HOUR_MS, + }); + + const verdict = await verifyCapabilityToken(token, { + identity: issuer, + clock: fixedClock(now), + revocation: neverRevoked, + }); + + expect(verdict.ok).toBe(true); + }); + + it("rejects a token whose signature doesn't match its claimed issuer", async () => { + const token = await signToken(issuer, { + tokenId: nextTokenId(), + bearer: bearerDeviceId, + scope: workScope, + expires: now + HOUR_MS, + }); + const tampered: CapabilityToken = [ + token[0], + token[1], + token[2], + new Uint8Array(P256_SIGNATURE_BYTE_LENGTH), + ]; + + const verdict = await verifyCapabilityToken(tampered, { + identity: issuer, + clock: fixedClock(now), + revocation: neverRevoked, + }); + + expect(verdict).toEqual({ ok: false, reason: "bad_signature" }); + }); + + it("rejects an expired token", async () => { + const token = await signToken(issuer, { + tokenId: nextTokenId(), + bearer: bearerDeviceId, + scope: workScope, + expires: now - 1, + }); + + const verdict = await verifyCapabilityToken(token, { + identity: issuer, + clock: fixedClock(now), + revocation: neverRevoked, + }); + + expect(verdict).toEqual({ ok: false, reason: "expired" }); + }); + + it("rejects a token revoked by its own issuer's entry", async () => { + const tokenId = nextTokenId(); + const token = await signToken(issuer, { + tokenId, + bearer: bearerDeviceId, + scope: workScope, + expires: now + HOUR_MS, + }); + const ownIssuerEntry: RevocationClaims = { + "token-id": tokenId, + issuer: issuer.deviceId, + "issuer-key": issuer.identityKey, + "revoked-at": now - REVOKED_SHORTLY_BEFORE_NOW_MS, + }; + + const verdict = await verifyCapabilityToken(token, { + identity: issuer, + clock: fixedClock(now), + revocation: revocationView([ownIssuerEntry]), + }); + + expect(verdict).toEqual({ ok: false, reason: "revoked" }); + }); + + it("accepts a token whose revocation entry was signed by a third party, not its own issuer", async () => { + const tokenId = nextTokenId(); + const token = await signToken(issuer, { + tokenId, + bearer: bearerDeviceId, + scope: workScope, + expires: now + HOUR_MS, + }); + // Same token-id, but revoked-at attributed to a different issuer -- only a token's own issuer may revoke it, so this entry must not count. + const thirdParty = await generateEs256Identity(); + const thirdPartyEntry: RevocationClaims = { + "token-id": tokenId, + issuer: thirdParty.deviceId, + "issuer-key": thirdParty.identityKey, + "revoked-at": now - REVOKED_SHORTLY_BEFORE_NOW_MS, + }; + + const verdict = await verifyCapabilityToken(token, { + identity: issuer, + clock: fixedClock(now), + revocation: revocationView([thirdPartyEntry]), + }); + + expect(verdict.ok).toBe(true); + }); + + it("rejects a token presented by a device other than its bearer", async () => { + const token = await signToken(issuer, { + tokenId: nextTokenId(), + bearer: bearerDeviceId, + scope: workScope, + expires: now + HOUR_MS, + }); + const someoneElse = (await generateEs256Identity()).deviceId; + + const verdict = await verifyCapabilityToken(token, { + identity: issuer, + clock: fixedClock(now), + revocation: neverRevoked, + expectedBearer: someoneElse, + }); + + expect(verdict).toEqual({ ok: false, reason: "bearer_mismatch" }); + }); + + // -- Hostile input must produce verdicts, not throws -- + + it("returns malformed for a payload whose bytes are not CBOR at all", async () => { + // A wrapped CBOR break byte: decodes to a bstr rather than token-claims, so this exercises schema rejection of a decodable-but-wrong payload + const garbage = encodeBuf(buf(Buffer.from("ff", "hex"))); + const hostile: CapabilityToken = [ + encodeBuf({}), + {}, + garbage, + new Uint8Array(P256_SIGNATURE_BYTE_LENGTH), + ]; + + const verdict = await verifyCapabilityToken(hostile, { + identity: issuer, + clock: fixedClock(now), + revocation: neverRevoked, + }); + + expect(verdict).toEqual({ ok: false, reason: "malformed" }); + }); + + it("returns malformed for a payload whose CBOR map keys are not canonically ordered", async () => { + // CDE requires canonical (length-first) map-key ordering; cbor2's cdeDecodeOptions rejects this encoding + const reversedKeys = buf(Buffer.from("a2627a7a01616102", "hex")); + const hostile: CapabilityToken = [ + encodeBuf({}), + {}, + reversedKeys, + new Uint8Array(P256_SIGNATURE_BYTE_LENGTH), + ]; + + const verdict = await verifyCapabilityToken(hostile, { + identity: issuer, + clock: fixedClock(now), + revocation: neverRevoked, + }); + + expect(verdict).toEqual({ ok: false, reason: "malformed" }); + }); + + it("returns parent_invalid for a parent whose bytes are not CBOR", async () => { + // Raw, unwrapped 0xff (a top-level CBOR BREAK -- decode throws): wrapped via encode() it would become a well-formed bstr and exercise schema rejection instead of the decode-throw path this test exists for + const garbageParent = buf(new Uint8Array([LOW_BYTE_MASK])); + // Signed with real claims but a garbage parent field, so the token itself is otherwise well-formed and the failure is isolated to parent decoding + const claims: TokenClaims = { + "token-id": nextTokenId(), + issuer: bearerDeviceId, + "issuer-key": bearerIdentity.identityKey, + bearer: (await generateEs256Identity()).deviceId, + capability: "exec:pty", + scope: { kind: "folder", path: "/work" }, + expires: now + HOUR_MS, + parent: garbageParent, + }; + const payload = encodeBuf(claims); + const protectedHeader = encodeBuf({}); + const toBeSigned = encodeBuf([ + "Signature1", + protectedHeader, + new Uint8Array(0), + payload, + ]); + const signature = await bearerIdentity.sign(toBeSigned); + const hostile: CapabilityToken = [protectedHeader, {}, payload, signature]; + + const verdict = await verifyCapabilityToken(hostile, { + identity: issuer, + clock: fixedClock(now), + revocation: neverRevoked, + }); + + expect(verdict).toEqual({ ok: false, reason: "parent_invalid" }); + }); +}); diff --git a/ts/packages/core/test/tokens-fixtures.ts b/ts/packages/core/test/tokens-fixtures.ts new file mode 100644 index 0000000..958f912 --- /dev/null +++ b/ts/packages/core/test/tokens-fixtures.ts @@ -0,0 +1,144 @@ +import { webcrypto } from "node:crypto"; +import { cdeEncodeOptions, encode } from "cbor2"; +import { createNodeIdentity } from "../src/adapters/node-identity.js"; +import type { IdentityPort } from "../src/ports/identity.js"; +import type { Clock } from "../src/ports/clock.js"; +import type { + CapabilityScope, + CapabilityToken, + DeviceId, + RevocationClaims, + RevocationEntry, + TokenClaims, +} from "../src/generated/protocol.js"; +import type { RevocationCheck } from "../src/domain/tokens.js"; + +export const ES256 = -7; +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 +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 + +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]); +} + +/** Normalises to a fresh, non-shared, whole-buffer Uint8Array -- cbor2's encode() and array-literal Uint8Array construction both produce the broader Uint8Array, which the generated schemas' concrete Uint8Array fields correctly reject. */ +export function buf( + bytes: Uint8Array | ArrayLike, +): Uint8Array { + return Uint8Array.from(bytes); +} + +export function encodeBuf(value: unknown): Uint8Array { + return buf(encode(value, cdeEncodeOptions)); +} + +export async function generateEs256Identity(): Promise { + const keyPair = await webcrypto.subtle.generateKey( + { name: "ECDSA", namedCurve: "P-256" }, + true, + ["sign", "verify"], + ); + const publicKeyBytes = new Uint8Array( + await webcrypto.subtle.exportKey("raw", keyPair.publicKey), + ); + return createNodeIdentity(keyPair.privateKey, publicKeyBytes, ES256); +} + +export function fixedClock(atMs: number): Clock { + return { now: () => atMs }; +} + +export function equalBytes(a: Uint8Array, b: Uint8Array): boolean { + return a.length === b.length && a.every((byte, i) => byte === b[i]); +} + +export const neverRevoked: RevocationCheck = { + isRevoked: async () => Promise.resolve(false), +}; + +/** A RevocationCheck over an explicit set of already-verified revocation claims, applying the port's own contract: an entry only counts against a token when BOTH its token-id and its issuer match the token's own -- a revocation signed by some third party must not revoke someone else's token. */ +export function revocationView( + entries: readonly RevocationClaims[], +): RevocationCheck { + return { + isRevoked: async (tokenId, issuer) => + Promise.resolve( + entries.some( + (entry) => + equalBytes(entry["token-id"], tokenId) && + equalBytes(entry.issuer, issuer), + ), + ), + }; +} + +/** Builds and signs one revocation-entry (a cose-sign1 over revocation-claims) as `identity`, mirroring signToken's construction. */ +export async function signRevocationEntry( + identity: IdentityPort, + tokenId: Uint8Array, +): Promise { + const claims: RevocationClaims = { + "token-id": tokenId, + issuer: identity.deviceId, + "issuer-key": identity.identityKey, + "revoked-at": 0, + }; + const payload = encodeBuf(claims); + const protectedHeader = encodeBuf({}); + const toBeSigned = encodeBuf([ + "Signature1", + protectedHeader, + new Uint8Array(0), + payload, + ]); + const signature = await identity.sign(toBeSigned); + return [protectedHeader, {}, payload, signature]; +} + +export interface TokenSeed { + tokenId: Uint8Array; + bearer: DeviceId; + scope: CapabilityScope; + expires: number; + parent?: Uint8Array; + delegationsRemaining?: number; +} + +/** Builds and signs one capability token as `identity` -- explicit field-by-field construction rather than spreading a partial claims object, since TokenClaims' own `.catchall(z.unknown())` index signature (the spec's forward-compatible extension-field pattern) makes a spread-based `Omit` lose the specific field types. Deliberately does none of mintCapabilityToken's own narrowing checks -- tests exercising verifyCapabilityToken's own enforcement need to construct chains mint would refuse to produce. */ +export async function signToken( + identity: IdentityPort, + seed: TokenSeed, +): Promise { + const claims: TokenClaims = { + "token-id": seed.tokenId, + issuer: identity.deviceId, + "issuer-key": identity.identityKey, + bearer: seed.bearer, + capability: "exec:pty", + scope: seed.scope, + expires: seed.expires, + ...(seed.parent !== undefined ? { parent: seed.parent } : {}), + ...(seed.delegationsRemaining !== undefined + ? { "delegations-remaining": seed.delegationsRemaining } + : {}), + }; + + const payload = encodeBuf(claims); + const protectedHeader = encodeBuf({}); + const toBeSigned = encodeBuf([ + "Signature1", + protectedHeader, + new Uint8Array(0), + payload, + ]); + const signature = await identity.sign(toBeSigned); + return [protectedHeader, {}, payload, signature]; +} diff --git a/ts/packages/core/test/tokens.test.ts b/ts/packages/core/test/tokens.test.ts deleted file mode 100644 index 2da847f..0000000 --- a/ts/packages/core/test/tokens.test.ts +++ /dev/null @@ -1,1669 +0,0 @@ -import { webcrypto } from "node:crypto"; -import { beforeAll, describe, expect, it } from "vitest"; -import { cdeEncodeOptions, encode } from "cbor2"; -import { createNodeIdentity } from "../src/adapters/node-identity.js"; -import { createMemoryStorage } from "../src/adapters/memory-storage.js"; -import { createSystemClock } from "../src/adapters/system-clock.js"; -import { - canGrant, - mintCapabilityToken, - mintRevocationEntry, - verifyCapabilityToken, - verifyRevocationEntry, - type RevocationCheck, -} from "../src/domain/tokens.js"; -import type { IdentityPort } from "../src/ports/identity.js"; -import type { Clock } from "../src/ports/clock.js"; -import type { - CapabilityScope, - CapabilityToken, - DeviceId, - RevocationClaims, - RevocationEntry, - TokenClaims, -} from "../src/generated/protocol.js"; - -const ES256 = -7; -const HOUR_MS = 3_600_000; -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 -const P256_SIGNATURE_BYTE_LENGTH = 64; // raw ECDSA P-256 signature length -const DEVICE_ID_BYTE_LENGTH = 32; // SHA-256 digest length -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 -const LOW_BYTE_MASK = 0xff; // XOR operand keeping the corrupted byte within one octet when tampering with a signature in tests - -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]); -} - -/** Normalises to a fresh, non-shared, whole-buffer Uint8Array -- cbor2's encode() and array-literal Uint8Array construction both produce the broader Uint8Array, which the generated schemas' concrete Uint8Array fields correctly reject. */ -function buf(bytes: Uint8Array | ArrayLike): Uint8Array { - return Uint8Array.from(bytes); -} - -function encodeBuf(value: unknown): Uint8Array { - return buf(encode(value, cdeEncodeOptions)); -} - -async function generateEs256Identity(): Promise { - const keyPair = await webcrypto.subtle.generateKey( - { name: "ECDSA", namedCurve: "P-256" }, - true, - ["sign", "verify"], - ); - const publicKeyBytes = new Uint8Array( - await webcrypto.subtle.exportKey("raw", keyPair.publicKey), - ); - return createNodeIdentity(keyPair.privateKey, publicKeyBytes, ES256); -} - -function fixedClock(atMs: number): Clock { - return { now: () => atMs }; -} - -function equalBytes(a: Uint8Array, b: Uint8Array): boolean { - return a.length === b.length && a.every((byte, i) => byte === b[i]); -} - -const neverRevoked: RevocationCheck = { - isRevoked: async () => Promise.resolve(false), -}; - -/** A RevocationCheck over an explicit set of already-verified revocation claims, applying the port's own contract: an entry only counts against a token when BOTH its token-id and its issuer match the token's own -- a revocation signed by some third party must not revoke someone else's token. */ -function revocationView(entries: readonly RevocationClaims[]): RevocationCheck { - return { - isRevoked: async (tokenId, issuer) => - Promise.resolve( - entries.some( - (entry) => - equalBytes(entry["token-id"], tokenId) && - equalBytes(entry.issuer, issuer), - ), - ), - }; -} - -/** Builds and signs one revocation-entry (a cose-sign1 over revocation-claims) as `identity`, mirroring signToken's construction. */ -async function signRevocationEntry( - identity: IdentityPort, - tokenId: Uint8Array, -): Promise { - const claims: RevocationClaims = { - "token-id": tokenId, - issuer: identity.deviceId, - "issuer-key": identity.identityKey, - "revoked-at": 0, - }; - const payload = encodeBuf(claims); - const protectedHeader = encodeBuf({}); - const toBeSigned = encodeBuf([ - "Signature1", - protectedHeader, - new Uint8Array(0), - payload, - ]); - const signature = await identity.sign(toBeSigned); - return [protectedHeader, {}, payload, signature]; -} - -interface TokenSeed { - tokenId: Uint8Array; - bearer: DeviceId; - scope: CapabilityScope; - expires: number; - parent?: Uint8Array; - delegationsRemaining?: number; -} - -/** Builds and signs one capability token as `identity` -- explicit field-by-field construction rather than spreading a partial claims object, since TokenClaims' own `.catchall(z.unknown())` index signature (the spec's forward-compatible extension-field pattern) makes a spread-based Omit lose the specific field types. Deliberately does none of mintCapabilityToken's own narrowing checks -- tests exercising verifyCapabilityToken's own enforcement need to construct chains mint would refuse to produce. */ -async function signToken( - identity: IdentityPort, - seed: TokenSeed, -): Promise { - const claims: TokenClaims = { - "token-id": seed.tokenId, - issuer: identity.deviceId, - "issuer-key": identity.identityKey, - bearer: seed.bearer, - capability: "exec:pty", - scope: seed.scope, - expires: seed.expires, - ...(seed.parent !== undefined ? { parent: seed.parent } : {}), - ...(seed.delegationsRemaining !== undefined - ? { "delegations-remaining": seed.delegationsRemaining } - : {}), - }; - - const payload = encodeBuf(claims); - const protectedHeader = encodeBuf({}); - const toBeSigned = encodeBuf([ - "Signature1", - protectedHeader, - new Uint8Array(0), - payload, - ]); - const signature = await identity.sign(toBeSigned); - return [protectedHeader, {}, payload, signature]; -} - -describe("verifyCapabilityToken", () => { - let issuer: IdentityPort; - let bearerIdentity: IdentityPort; - let bearerDeviceId: DeviceId; - - beforeAll(async () => { - issuer = await generateEs256Identity(); - // Kept in full, not just its device-id: a delegated token's own self-certifying issuer-key must belong to whichever identity actually signs it, so the tests that have this device delegate a narrower token need to sign as it, not merely reference its device-id. - bearerIdentity = await generateEs256Identity(); - bearerDeviceId = bearerIdentity.deviceId; - }); - - const now = 1_893_456_000_000; - const workScope: CapabilityScope = { kind: "folder", path: "/work" }; - - it("accepts a validly signed, unexpired, unrevoked token", async () => { - const token = await signToken(issuer, { - tokenId: nextTokenId(), - bearer: bearerDeviceId, - scope: workScope, - expires: now + HOUR_MS, - }); - - const verdict = await verifyCapabilityToken(token, { - identity: issuer, - clock: fixedClock(now), - revocation: neverRevoked, - }); - - expect(verdict.ok).toBe(true); - }); - - it("rejects a token whose signature doesn't match its claimed issuer", async () => { - const token = await signToken(issuer, { - tokenId: nextTokenId(), - bearer: bearerDeviceId, - scope: workScope, - expires: now + HOUR_MS, - }); - const tampered: CapabilityToken = [ - token[0], - token[1], - token[2], - new Uint8Array(P256_SIGNATURE_BYTE_LENGTH), - ]; - - const verdict = await verifyCapabilityToken(tampered, { - identity: issuer, - clock: fixedClock(now), - revocation: neverRevoked, - }); - - expect(verdict).toEqual({ ok: false, reason: "bad_signature" }); - }); - - it("rejects an expired token", async () => { - const token = await signToken(issuer, { - tokenId: nextTokenId(), - bearer: bearerDeviceId, - scope: workScope, - expires: now - 1, - }); - - const verdict = await verifyCapabilityToken(token, { - identity: issuer, - clock: fixedClock(now), - revocation: neverRevoked, - }); - - expect(verdict).toEqual({ ok: false, reason: "expired" }); - }); - - it("rejects a token revoked by its own issuer's entry", async () => { - const tokenId = nextTokenId(); - const token = await signToken(issuer, { - tokenId, - bearer: bearerDeviceId, - scope: workScope, - expires: now + HOUR_MS, - }); - const ownIssuerEntry: RevocationClaims = { - "token-id": tokenId, - issuer: issuer.deviceId, - "issuer-key": issuer.identityKey, - "revoked-at": now - REVOKED_SHORTLY_BEFORE_NOW_MS, - }; - - const verdict = await verifyCapabilityToken(token, { - identity: issuer, - clock: fixedClock(now), - revocation: revocationView([ownIssuerEntry]), - }); - - expect(verdict).toEqual({ ok: false, reason: "revoked" }); - }); - - it("accepts a token whose revocation entry was signed by a third party, not its own issuer", async () => { - const tokenId = nextTokenId(); - const token = await signToken(issuer, { - tokenId, - bearer: bearerDeviceId, - scope: workScope, - expires: now + HOUR_MS, - }); - // Same token-id, but revoked-at attributed to a different issuer -- only a token's own issuer may revoke it, so this entry must not count. - const thirdParty = await generateEs256Identity(); - const thirdPartyEntry: RevocationClaims = { - "token-id": tokenId, - issuer: thirdParty.deviceId, - "issuer-key": thirdParty.identityKey, - "revoked-at": now - REVOKED_SHORTLY_BEFORE_NOW_MS, - }; - - const verdict = await verifyCapabilityToken(token, { - identity: issuer, - clock: fixedClock(now), - revocation: revocationView([thirdPartyEntry]), - }); - - expect(verdict.ok).toBe(true); - }); - - it("rejects a token presented by a device other than its bearer", async () => { - const token = await signToken(issuer, { - tokenId: nextTokenId(), - bearer: bearerDeviceId, - scope: workScope, - expires: now + HOUR_MS, - }); - const someoneElse = (await generateEs256Identity()).deviceId; - - const verdict = await verifyCapabilityToken(token, { - identity: issuer, - clock: fixedClock(now), - revocation: neverRevoked, - expectedBearer: someoneElse, - }); - - expect(verdict).toEqual({ ok: false, reason: "bearer_mismatch" }); - }); - - it("accepts a delegated token whose expiry narrows the parent's", async () => { - const rootExpiry = now + 2 * HOUR_MS; - const root = await signToken(issuer, { - tokenId: nextTokenId(), - bearer: bearerDeviceId, - scope: workScope, - expires: rootExpiry, - }); - - const delegate = await generateEs256Identity(); - // Signed by bearerIdentity, not issuer: the root's bearer is the one delegating, so it must be the actual signer of the child token -- a self-certifying token's issuer-key has to belong to whoever actually signed it (checked independently by "wrong_issuer"), and delegation requires the child's issuer to equal the parent's bearer. - const claims: TokenClaims = { - "token-id": nextTokenId(), - issuer: bearerDeviceId, - "issuer-key": bearerIdentity.identityKey, - bearer: delegate.deviceId, - capability: "exec:pty", - scope: { kind: "folder", path: "/work/subdir" }, - expires: now + HOUR_MS, - parent: encodeBuf(root), - }; - const payload = encodeBuf(claims); - const protectedHeader = encodeBuf({}); - const toBeSigned = encodeBuf([ - "Signature1", - protectedHeader, - new Uint8Array(0), - payload, - ]); - const signature = await bearerIdentity.sign(toBeSigned); - const delegated: CapabilityToken = [ - protectedHeader, - {}, - payload, - signature, - ]; - - const verdict = await verifyCapabilityToken(delegated, { - identity: issuer, - clock: fixedClock(now), - revocation: neverRevoked, - }); - - expect(verdict.ok).toBe(true); - }); - - it("rejects a delegated token whose expiry exceeds its parent's", async () => { - const rootExpiry = now + HOUR_MS; - const root = await signToken(issuer, { - tokenId: nextTokenId(), - bearer: bearerDeviceId, - scope: workScope, - expires: rootExpiry, - }); - - const delegate = await generateEs256Identity(); - const claims: TokenClaims = { - "token-id": nextTokenId(), - issuer: bearerDeviceId, - "issuer-key": bearerIdentity.identityKey, - bearer: delegate.deviceId, - capability: "exec:pty", - scope: { kind: "folder", path: "/work/subdir" }, - expires: rootExpiry + HOUR_MS, // wider than the parent -- must be rejected - parent: encodeBuf(root), - }; - const payload = encodeBuf(claims); - const protectedHeader = encodeBuf({}); - const toBeSigned = encodeBuf([ - "Signature1", - protectedHeader, - new Uint8Array(0), - payload, - ]); - const signature = await bearerIdentity.sign(toBeSigned); - const widened: CapabilityToken = [protectedHeader, {}, payload, signature]; - - const verdict = await verifyCapabilityToken(widened, { - identity: issuer, - clock: fixedClock(now), - revocation: neverRevoked, - }); - - expect(verdict).toEqual({ ok: false, reason: "delegation_exceeds_parent" }); - }); - - it("rejects a delegated token whose ancestor is revoked, even though the leaf itself is not", async () => { - const rootTokenId = nextTokenId(); - const root = await signToken(issuer, { - tokenId: rootTokenId, - bearer: bearerDeviceId, - scope: workScope, - expires: now + 2 * HOUR_MS, - }); - - const delegate = await generateEs256Identity(); - const claims: TokenClaims = { - "token-id": nextTokenId(), - issuer: bearerDeviceId, - "issuer-key": bearerIdentity.identityKey, - bearer: delegate.deviceId, - capability: "exec:pty", - scope: { kind: "folder", path: "/work/subdir" }, - expires: now + HOUR_MS, - parent: encodeBuf(root), - }; - const payload = encodeBuf(claims); - const protectedHeader = encodeBuf({}); - const toBeSigned = encodeBuf([ - "Signature1", - protectedHeader, - new Uint8Array(0), - payload, - ]); - const signature = await bearerIdentity.sign(toBeSigned); - const delegated: CapabilityToken = [ - protectedHeader, - {}, - payload, - signature, - ]; - - // Only the ROOT is revoked, by the root's own issuer -- the sweep must reach it through the delegation chain, not stop at the leaf. - const rootRevokedByIssuer: RevocationClaims = { - "token-id": rootTokenId, - issuer: issuer.deviceId, - "issuer-key": issuer.identityKey, - "revoked-at": now - REVOKED_SHORTLY_BEFORE_NOW_MS, - }; - - const verdict = await verifyCapabilityToken(delegated, { - identity: issuer, - clock: fixedClock(now), - revocation: revocationView([rootRevokedByIssuer]), - }); - - expect(verdict).toEqual({ ok: false, reason: "parent_invalid" }); - }); - - // -- Delegation must narrow scope and capability, not just expiry -- - - interface DelegatedSeed { - tokenId: Uint8Array; - bearer: DeviceId; - scope: CapabilityScope; - capability?: string; - expires: number; - parent: CapabilityToken; - delegationsRemaining?: number; - } - - /** Signs a child token as `identity` with an explicit parent token embedded -- the parent's own claims are recoverable by the verifier's own recursion, so they are not restated here. Deliberately does none of mintCapabilityToken's own narrowing checks -- tests exercising verifyCapabilityToken's own enforcement need to construct chains mint would refuse to produce. */ - async function signDelegated( - identity: IdentityPort, - seed: DelegatedSeed, - ): Promise { - const claims: TokenClaims = { - "token-id": seed.tokenId, - issuer: identity.deviceId, - "issuer-key": identity.identityKey, - bearer: seed.bearer, - capability: seed.capability ?? "exec:pty", - scope: seed.scope, - expires: seed.expires, - parent: encodeBuf(seed.parent), - ...(seed.delegationsRemaining !== undefined - ? { "delegations-remaining": seed.delegationsRemaining } - : {}), - }; - const payload = encodeBuf(claims); - const protectedHeader = encodeBuf({}); - const toBeSigned = encodeBuf([ - "Signature1", - protectedHeader, - new Uint8Array(0), - payload, - ]); - const signature = await identity.sign(toBeSigned); - return [protectedHeader, {}, payload, signature]; - } - - /** A root /work-folder token plus a helper to delegate under it, signed by the root's bearer (bearerIdentity), keeping the common expiry/scope consistent across the narrowing tests. */ - async function delegateUnderWorkRoot( - childScope: Readonly, - childCapability?: string, - ): Promise { - const root = await signToken(issuer, { - tokenId: nextTokenId(), - bearer: bearerDeviceId, - scope: { kind: "folder", path: "/work" }, - expires: now + 2 * HOUR_MS, - }); - const delegate = await generateEs256Identity(); - return signDelegated(bearerIdentity, { - tokenId: nextTokenId(), - bearer: delegate.deviceId, - scope: childScope, - ...(childCapability !== undefined ? { capability: childCapability } : {}), - expires: now + HOUR_MS, - parent: root, - }); - } - - it("accepts a delegated token with the same scope as its parent", async () => { - const delegated = await delegateUnderWorkRoot({ - kind: "folder", - path: "/work", - }); - - const verdict = await verifyCapabilityToken(delegated, { - identity: issuer, - clock: fixedClock(now), - revocation: neverRevoked, - }); - - expect(verdict.ok).toBe(true); - }); - - it("accepts a delegated token whose path descends from the parent's", async () => { - const delegated = await delegateUnderWorkRoot({ - kind: "folder", - path: "/work/subdir/deeper", - }); - - const verdict = await verifyCapabilityToken(delegated, { - identity: issuer, - clock: fixedClock(now), - revocation: neverRevoked, - }); - - expect(verdict.ok).toBe(true); - }); - - it("rejects a delegated token whose scope kind differs from the parent's", async () => { - // kind:"org" is a different kind of authority, not a narrower one -- even though its path textually starts with /work - const delegated = await delegateUnderWorkRoot({ - kind: "org", - path: "/work", - }); - - const verdict = await verifyCapabilityToken(delegated, { - identity: issuer, - clock: fixedClock(now), - revocation: neverRevoked, - }); - - expect(verdict).toEqual({ - ok: false, - reason: "delegation_exceeds_parent", - }); - }); - - it("rejects a delegated token whose path is a sibling, not a descendant", async () => { - const delegated = await delegateUnderWorkRoot({ - kind: "folder", - path: "/home/private", - }); - - const verdict = await verifyCapabilityToken(delegated, { - identity: issuer, - clock: fixedClock(now), - revocation: neverRevoked, - }); - - expect(verdict).toEqual({ - ok: false, - reason: "delegation_exceeds_parent", - }); - }); - - it("rejects a delegated token whose path merely prefixes the parent's without a segment boundary", async () => { - // "/workbook" starts with "/work" as a string but is a different path, not a descendant - const delegated = await delegateUnderWorkRoot({ - kind: "folder", - path: "/workbook", - }); - - const verdict = await verifyCapabilityToken(delegated, { - identity: issuer, - clock: fixedClock(now), - revocation: neverRevoked, - }); - - expect(verdict).toEqual({ - ok: false, - reason: "delegation_exceeds_parent", - }); - }); - - it("rejects a delegated token whose path uses a .. segment to escape the parent's", async () => { - // Purely lexical prefix comparison would accept "/work/../org" under "/work"; a path that normalises outside the parent is a widening, so any "." or ".." segment fails the narrowing comparison - const delegated = await delegateUnderWorkRoot({ - kind: "folder", - path: "/work/../org", - }); - - const verdict = await verifyCapabilityToken(delegated, { - identity: issuer, - clock: fixedClock(now), - revocation: neverRevoked, - }); - - expect(verdict).toEqual({ - ok: false, - reason: "delegation_exceeds_parent", - }); - }); - - it("rejects a delegated token with a nested .. segment even when it stays inside the parent", async () => { - // "/work/a/../b" normalises to "/work/b" which would narrow, but relative segments are rejected wholesale: fail-closed rather than reimplementing path normalisation - const delegated = await delegateUnderWorkRoot({ - kind: "folder", - path: "/work/a/../b", - }); - - const verdict = await verifyCapabilityToken(delegated, { - identity: issuer, - clock: fixedClock(now), - revocation: neverRevoked, - }); - - expect(verdict).toEqual({ - ok: false, - reason: "delegation_exceeds_parent", - }); - }); - - it("accepts a delegated token whose child path carries a trailing slash under the parent", async () => { - const delegated = await delegateUnderWorkRoot({ - kind: "folder", - path: "/work/", - }); - - const verdict = await verifyCapabilityToken(delegated, { - identity: issuer, - clock: fixedClock(now), - revocation: neverRevoked, - }); - - expect(verdict.ok).toBe(true); - }); - - it("rejects a delegated token whose child path differs only in case", async () => { - const delegated = await delegateUnderWorkRoot({ - kind: "folder", - path: "/Work", - }); - - const verdict = await verifyCapabilityToken(delegated, { - identity: issuer, - clock: fixedClock(now), - revocation: neverRevoked, - }); - - expect(verdict).toEqual({ - ok: false, - reason: "delegation_exceeds_parent", - }); - }); - - it("rejects a delegated token with no path under a path-narrowed parent", async () => { - // Absent path means the kind's whole-scope root, which is wider than the parent's /work - const delegated = await delegateUnderWorkRoot({ kind: "folder" }); - - const verdict = await verifyCapabilityToken(delegated, { - identity: issuer, - clock: fixedClock(now), - revocation: neverRevoked, - }); - - expect(verdict).toEqual({ - ok: false, - reason: "delegation_exceeds_parent", - }); - }); - - it("rejects a delegated token whose capability verb differs from the parent's", async () => { - const delegated = await delegateUnderWorkRoot( - { kind: "folder", path: "/work" }, - "exec:proc", - ); - - const verdict = await verifyCapabilityToken(delegated, { - identity: issuer, - clock: fixedClock(now), - revocation: neverRevoked, - }); - - expect(verdict).toEqual({ - ok: false, - reason: "delegation_exceeds_parent", - }); - }); - - it("rejects a delegated token whose delegations-remaining is not strictly less than its parent's", async () => { - const root = await signToken(issuer, { - tokenId: nextTokenId(), - bearer: bearerDeviceId, - scope: { kind: "folder", path: "/work" }, - expires: now + 2 * HOUR_MS, - delegationsRemaining: 1, - }); - const delegate = await generateEs256Identity(); - // Equal to the parent's own delegations-remaining (1), not strictly less -- this is the exact unbounded-admission gap the claim exists to close: without this check, any bearer of a bounded grant could mint an equally-unbounded child. - const delegated = await signDelegated(bearerIdentity, { - tokenId: nextTokenId(), - bearer: delegate.deviceId, - scope: { kind: "folder", path: "/work" }, - expires: now + HOUR_MS, - parent: root, - delegationsRemaining: 1, - }); - - const verdict = await verifyCapabilityToken(delegated, { - identity: issuer, - clock: fixedClock(now), - revocation: neverRevoked, - }); - - expect(verdict).toEqual({ - ok: false, - reason: "delegation_exceeds_parent", - }); - }); - - it("rejects an unbounded delegated token under a parent that itself bounds re-delegation", async () => { - const root = await signToken(issuer, { - tokenId: nextTokenId(), - bearer: bearerDeviceId, - scope: { kind: "folder", path: "/work" }, - expires: now + 2 * HOUR_MS, - delegationsRemaining: 1, - }); - const delegate = await generateEs256Identity(); - // No delegations-remaining at all -- unbounded, which is wider than the parent's bounded 1. - const delegated = await signDelegated(bearerIdentity, { - tokenId: nextTokenId(), - bearer: delegate.deviceId, - scope: { kind: "folder", path: "/work" }, - expires: now + HOUR_MS, - parent: root, - }); - - const verdict = await verifyCapabilityToken(delegated, { - identity: issuer, - clock: fixedClock(now), - revocation: neverRevoked, - }); - - expect(verdict).toEqual({ - ok: false, - reason: "delegation_exceeds_parent", - }); - }); - - it("accepts a delegated token whose delegations-remaining strictly narrows its parent's, and reports the chain's root and depth", async () => { - const root = await signToken(issuer, { - tokenId: nextTokenId(), - bearer: bearerDeviceId, - scope: { kind: "folder", path: "/work" }, - expires: now + 2 * HOUR_MS, - delegationsRemaining: 1, - }); - const delegate = await generateEs256Identity(); - const delegated = await signDelegated(bearerIdentity, { - tokenId: nextTokenId(), - bearer: delegate.deviceId, - scope: { kind: "folder", path: "/work" }, - expires: now + HOUR_MS, - parent: root, - delegationsRemaining: 0, - }); - - const verdict = await verifyCapabilityToken(delegated, { - identity: issuer, - clock: fixedClock(now), - revocation: neverRevoked, - }); - - expect(verdict.ok).toBe(true); - if (!verdict.ok) return; - expect(verdict.rootIssuer).toEqual(issuer.deviceId); - expect(verdict.depth).toBe(1); - }); - - it("reports depth 0 and itself as the root for a root grant with no parent", async () => { - const token = await signToken(issuer, { - tokenId: nextTokenId(), - bearer: bearerDeviceId, - scope: workScope, - expires: now + HOUR_MS, - }); - - const verdict = await verifyCapabilityToken(token, { - identity: issuer, - clock: fixedClock(now), - revocation: neverRevoked, - }); - - expect(verdict.ok).toBe(true); - if (!verdict.ok) return; - expect(verdict.rootIssuer).toEqual(issuer.deviceId); - expect(verdict.depth).toBe(0); - }); - - async function delegateUnderRootRoot( - childPath: string, - ): Promise { - const root = await signToken(issuer, { - tokenId: nextTokenId(), - bearer: bearerDeviceId, - scope: { kind: "folder", path: "/" }, - expires: now + 2 * HOUR_MS, - }); - const delegate = await generateEs256Identity(); - return signDelegated(bearerIdentity, { - tokenId: nextTokenId(), - bearer: delegate.deviceId, - scope: { kind: "folder", path: childPath }, - expires: now + HOUR_MS, - parent: root, - }); - } - - it("accepts any well-formed child path under a whole-root parent path", async () => { - const delegated = await delegateUnderRootRoot("/anything/at/all"); - - const verdict = await verifyCapabilityToken(delegated, { - identity: issuer, - clock: fixedClock(now), - revocation: neverRevoked, - }); - - expect(verdict.ok).toBe(true); - }); - - it("rejects an empty child path", async () => { - const delegated = await delegateUnderWorkRoot({ kind: "folder", path: "" }); - - const verdict = await verifyCapabilityToken(delegated, { - identity: issuer, - clock: fixedClock(now), - revocation: neverRevoked, - }); - - expect(verdict).toEqual({ - ok: false, - reason: "delegation_exceeds_parent", - }); - }); - - it("applies expectedBearer to the leaf only, never to ancestors in the chain", async () => { - // The parent's bearer is the child's issuer (bearerIdentity), NOT the leaf's presenter: a leaf presented by its own delegate must verify even though the ancestor's bearer differs. - const root = await signToken(issuer, { - tokenId: nextTokenId(), - bearer: bearerDeviceId, - scope: { kind: "folder", path: "/work" }, - expires: now + 2 * HOUR_MS, - }); - const delegate = await generateEs256Identity(); - const delegated = await signDelegated(bearerIdentity, { - tokenId: nextTokenId(), - bearer: delegate.deviceId, - scope: { kind: "folder", path: "/work" }, - expires: now + HOUR_MS, - parent: root, - }); - - const verdict = await verifyCapabilityToken(delegated, { - identity: issuer, - clock: fixedClock(now), - revocation: neverRevoked, - expectedBearer: delegate.deviceId, - }); - - expect(verdict.ok).toBe(true); - }); - - // -- Hostile input must produce verdicts, not throws -- - - it("returns malformed for a payload whose bytes are not CBOR at all", async () => { - // A wrapped CBOR break byte: decodes to a bstr rather than token-claims, so this exercises schema rejection of a decodable-but-wrong payload - const garbage = encodeBuf(buf(Buffer.from("ff", "hex"))); - const hostile: CapabilityToken = [ - encodeBuf({}), - {}, - garbage, - new Uint8Array(P256_SIGNATURE_BYTE_LENGTH), - ]; - - const verdict = await verifyCapabilityToken(hostile, { - identity: issuer, - clock: fixedClock(now), - revocation: neverRevoked, - }); - - expect(verdict).toEqual({ ok: false, reason: "malformed" }); - }); - - it("returns malformed for a payload whose CBOR map keys are not canonically ordered", async () => { - // CDE requires canonical (length-first) map-key ordering; cbor2's cdeDecodeOptions rejects this encoding - const reversedKeys = buf(Buffer.from("a2627a7a01616102", "hex")); - const hostile: CapabilityToken = [ - encodeBuf({}), - {}, - reversedKeys, - new Uint8Array(P256_SIGNATURE_BYTE_LENGTH), - ]; - - const verdict = await verifyCapabilityToken(hostile, { - identity: issuer, - clock: fixedClock(now), - revocation: neverRevoked, - }); - - expect(verdict).toEqual({ ok: false, reason: "malformed" }); - }); - - it("returns parent_invalid for a parent whose bytes are not CBOR", async () => { - // Raw, unwrapped 0xff (a top-level CBOR BREAK -- decode throws): wrapped via encode() it would become a well-formed bstr and exercise schema rejection instead of the decode-throw path this test exists for - const garbageParent = buf(new Uint8Array([LOW_BYTE_MASK])); - // Signed with real claims but a garbage parent field, so the token itself is otherwise well-formed and the failure is isolated to parent decoding - const claims: TokenClaims = { - "token-id": nextTokenId(), - issuer: bearerDeviceId, - "issuer-key": bearerIdentity.identityKey, - bearer: (await generateEs256Identity()).deviceId, - capability: "exec:pty", - scope: { kind: "folder", path: "/work" }, - expires: now + HOUR_MS, - parent: garbageParent, - }; - const payload = encodeBuf(claims); - const protectedHeader = encodeBuf({}); - const toBeSigned = encodeBuf([ - "Signature1", - protectedHeader, - new Uint8Array(0), - payload, - ]); - const signature = await bearerIdentity.sign(toBeSigned); - const hostile: CapabilityToken = [protectedHeader, {}, payload, signature]; - - const verdict = await verifyCapabilityToken(hostile, { - identity: issuer, - clock: fixedClock(now), - revocation: neverRevoked, - }); - - expect(verdict).toEqual({ ok: false, reason: "parent_invalid" }); - }); -}); - -describe("verifyRevocationEntry", () => { - let issuer: IdentityPort; - - beforeAll(async () => { - issuer = await generateEs256Identity(); - }); - - it("accepts a validly signed, self-certifying revocation entry and returns its claims", async () => { - const tokenId = nextTokenId(); - const entry = await signRevocationEntry(issuer, tokenId); - - const verdict = await verifyRevocationEntry(entry, { identity: issuer }); - - expect(verdict.ok).toBe(true); - if (verdict.ok) { - expect(equalBytes(verdict.claims["token-id"], tokenId)).toBe(true); - expect(equalBytes(verdict.claims.issuer, issuer.deviceId)).toBe(true); - } - }); - - it("rejects an entry whose signature doesn't verify against its embedded issuer-key", async () => { - const entry = await signRevocationEntry(issuer, nextTokenId()); - // Corrupt the signature bytes themselves: the identity port only supplies crypto primitives and checks against the entry's own embedded key, so swapping port instances proves nothing (the neighbouring test covers exactly that) -- a genuinely bad signature must be forged in the bytes. - const [protectedHeader, unprotected, payload, signature] = entry; - const tampered = Uint8Array.from(signature); - const firstByte = tampered.at(0); - if (firstByte === undefined) { - throw new Error("test setup: signature has no bytes to corrupt"); - } - tampered[0] = firstByte ^ LOW_BYTE_MASK; - const forged: RevocationEntry = [ - protectedHeader, - unprotected, - payload, - tampered, - ]; - - const verdict = await verifyRevocationEntry(forged, { identity: issuer }); - - expect(verdict).toEqual({ ok: false, reason: "bad_signature" }); - }); - - it("rejects an entry whose issuer-key does not derive its claimed issuer device-id", async () => { - // Sign as one identity but claim a different issuer: the signature verifies (it was really signed by the embedded key) but sha256(public-key) != the claimed issuer, so the entry is not self-certifying. - const actualSigner = await generateEs256Identity(); - const claimedIssuer = await generateEs256Identity(); - const claims: RevocationClaims = { - "token-id": nextTokenId(), - issuer: claimedIssuer.deviceId, - "issuer-key": actualSigner.identityKey, - "revoked-at": 0, - }; - const payload = encodeBuf(claims); - const protectedHeader = encodeBuf({}); - const toBeSigned = encodeBuf([ - "Signature1", - protectedHeader, - new Uint8Array(0), - payload, - ]); - const signature = await actualSigner.sign(toBeSigned); - const forged: RevocationEntry = [protectedHeader, {}, payload, signature]; - - const verdict = await verifyRevocationEntry(forged, { - identity: actualSigner, - }); - - expect(verdict).toEqual({ ok: false, reason: "wrong_issuer" }); - }); - - it("rejects an entry whose payload doesn't parse as revocation-claims", async () => { - const payload = encodeBuf({ nonsense: true }); - const protectedHeader = encodeBuf({}); - const toBeSigned = encodeBuf([ - "Signature1", - protectedHeader, - new Uint8Array(0), - payload, - ]); - const signature = await issuer.sign(toBeSigned); - const malformed: RevocationEntry = [ - protectedHeader, - {}, - payload, - signature, - ]; - - const verdict = await verifyRevocationEntry(malformed, { - identity: issuer, - }); - - expect(verdict).toEqual({ ok: false, reason: "malformed" }); - }); - - it("ignores bearer identity: an entry is about the issuer's token, not who presented the frame", async () => { - const entry = await signRevocationEntry(issuer, nextTokenId()); - const anyOtherViewer = await generateEs256Identity(); - - const verdict = await verifyRevocationEntry(entry, { - identity: anyOtherViewer, - }); - - // The identity port supplies only crypto primitives (verify/derive); verification succeeds regardless of which port instance performs it. - expect(verdict.ok).toBe(true); - }); - - it("returns malformed for an entry whose payload bytes are not CBOR, instead of throwing", async () => { - // This function's whole purpose is ingesting hostile gossiped entries: a lone top-level CBOR BREAK byte must produce the malformed verdict, never a throw - const garbage = buf(Buffer.from("ff", "hex")); - const hostile: RevocationEntry = [ - encodeBuf({}), - {}, - garbage, - new Uint8Array(P256_SIGNATURE_BYTE_LENGTH), - ]; - - const verdict = await verifyRevocationEntry(hostile, { identity: issuer }); - - expect(verdict).toEqual({ ok: false, reason: "malformed" }); - }); - - it("returns malformed for an entry whose payload map keys are not canonically ordered, instead of throwing", async () => { - const nonCanonical = buf(Buffer.from("a2627a7a01616102", "hex")); - const hostile: RevocationEntry = [ - encodeBuf({}), - {}, - nonCanonical, - new Uint8Array(P256_SIGNATURE_BYTE_LENGTH), - ]; - - const verdict = await verifyRevocationEntry(hostile, { identity: issuer }); - - expect(verdict).toEqual({ ok: false, reason: "malformed" }); - }); -}); - -describe("createMemoryStorage / createSystemClock", () => { - it("round-trips a value through memory storage", async () => { - const storage = createMemoryStorage(); - const sampleBytes = new TextEncoder().encode("sample"); - await storage.set("k", sampleBytes); - expect(await storage.get("k")).toEqual(sampleBytes); - await storage.delete("k"); - expect(await storage.get("k")).toBeUndefined(); - }); - - it("lists keys by prefix", async () => { - const storage = createMemoryStorage(); - await storage.set("a/1", new Uint8Array()); - await storage.set("a/2", new Uint8Array()); - await storage.set("b/1", new Uint8Array()); - expect(new Set(await storage.keys("a/"))).toEqual(new Set(["a/1", "a/2"])); - }); - - it("reports the current time", () => { - const clock = createSystemClock(); - const before = Date.now(); - const reported = clock.now(); - const after = Date.now(); - expect(reported).toBeGreaterThanOrEqual(before); - expect(reported).toBeLessThanOrEqual(after); - }); -}); - -describe("mintCapabilityToken", () => { - let issuer: IdentityPort; - let bearerIdentity: IdentityPort; - - beforeAll(async () => { - issuer = await generateEs256Identity(); - bearerIdentity = await generateEs256Identity(); - }); - - const now = 1_893_456_000_000; - const workScope: CapabilityScope = { kind: "folder", path: "/work" }; - - it("mints a root token that verifyCapabilityToken accepts", async () => { - const verdict = await mintCapabilityToken({ - identity: issuer, - clock: fixedClock(now), - tokenId: nextTokenId(), - bearer: bearerIdentity.deviceId, - capability: "exec:pty", - scope: workScope, - expires: now + HOUR_MS, - }); - expect(verdict.ok).toBe(true); - if (!verdict.ok) return; - - const verified = await verifyCapabilityToken(verdict.token, { - identity: issuer, - clock: fixedClock(now), - revocation: neverRevoked, - }); - expect(verified.ok).toBe(true); - if (!verified.ok) return; - expect(verified.claims.bearer).toEqual(bearerIdentity.deviceId); - expect(verified.claims.capability).toBe("exec:pty"); - }); - - it("mints a delegated token, signed as the parent's own bearer, that verifyCapabilityToken accepts", async () => { - const rootVerdict = await mintCapabilityToken({ - identity: issuer, - clock: fixedClock(now), - tokenId: nextTokenId(), - bearer: bearerIdentity.deviceId, - capability: "room:member", - scope: { kind: "room", path: ROOM_MEMBER_ROOM_PATH }, - expires: now + HOUR_MS, - delegationsRemaining: 1, - }); - expect(rootVerdict.ok).toBe(true); - if (!rootVerdict.ok) return; - - const delegate = await generateEs256Identity(); - const delegatedVerdict = await mintCapabilityToken({ - identity: bearerIdentity, - clock: fixedClock(now), - tokenId: nextTokenId(), - bearer: delegate.deviceId, - capability: "room:member", - scope: { kind: "room", path: ROOM_MEMBER_ROOM_PATH }, - expires: now + HOUR_MS, - delegationsRemaining: 0, - parent: rootVerdict.token, - }); - expect(delegatedVerdict.ok).toBe(true); - if (!delegatedVerdict.ok) return; - - const verified = await verifyCapabilityToken(delegatedVerdict.token, { - identity: issuer, - clock: fixedClock(now), - revocation: neverRevoked, - }); - expect(verified.ok).toBe(true); - }); - - it("refuses to mint an already-expired token", async () => { - const verdict = await mintCapabilityToken({ - identity: issuer, - clock: fixedClock(now), - tokenId: nextTokenId(), - bearer: bearerIdentity.deviceId, - capability: "exec:pty", - scope: workScope, - expires: now - 1, - }); - expect(verdict).toEqual({ ok: false, reason: "already_expired" }); - }); - - it("refuses to mint against a malformed parent", async () => { - const malformedParent: CapabilityToken = [ - new Uint8Array(), - {}, - null, - new Uint8Array(P256_SIGNATURE_BYTE_LENGTH), - ]; - const verdict = await mintCapabilityToken({ - identity: bearerIdentity, - clock: fixedClock(now), - tokenId: nextTokenId(), - bearer: (await generateEs256Identity()).deviceId, - capability: "exec:pty", - scope: workScope, - expires: now + HOUR_MS, - parent: malformedParent, - }); - expect(verdict).toEqual({ ok: false, reason: "parent_malformed" }); - }); - - it("refuses to mint a delegation the issuer's own device does not hold the parent's bearer for", async () => { - const rootVerdict = await mintCapabilityToken({ - identity: issuer, - clock: fixedClock(now), - tokenId: nextTokenId(), - bearer: bearerIdentity.deviceId, - capability: "exec:pty", - scope: workScope, - expires: now + HOUR_MS, - }); - expect(rootVerdict.ok).toBe(true); - if (!rootVerdict.ok) return; - - // Minting as `issuer` itself, not `bearerIdentity` -- the parent's bearer is bearerIdentity, not issuer, so issuer cannot delegate from it. - const verdict = await mintCapabilityToken({ - identity: issuer, - clock: fixedClock(now), - tokenId: nextTokenId(), - bearer: (await generateEs256Identity()).deviceId, - capability: "exec:pty", - scope: workScope, - expires: now + HOUR_MS, - parent: rootVerdict.token, - }); - expect(verdict).toEqual({ ok: false, reason: "parent_bearer_mismatch" }); - }); - - it("refuses to mint a delegation whose expiry exceeds its parent's", async () => { - const rootVerdict = await mintCapabilityToken({ - identity: issuer, - clock: fixedClock(now), - tokenId: nextTokenId(), - bearer: bearerIdentity.deviceId, - capability: "exec:pty", - scope: workScope, - expires: now + HOUR_MS, - }); - expect(rootVerdict.ok).toBe(true); - if (!rootVerdict.ok) return; - - const verdict = await mintCapabilityToken({ - identity: bearerIdentity, - clock: fixedClock(now), - tokenId: nextTokenId(), - bearer: (await generateEs256Identity()).deviceId, - capability: "exec:pty", - scope: workScope, - expires: now + 2 * HOUR_MS, - parent: rootVerdict.token, - }); - expect(verdict).toEqual({ ok: false, reason: "expires_exceeds_parent" }); - }); - - it("refuses to mint a delegation whose scope does not narrow its parent's", async () => { - const rootVerdict = await mintCapabilityToken({ - identity: issuer, - clock: fixedClock(now), - tokenId: nextTokenId(), - bearer: bearerIdentity.deviceId, - capability: "exec:pty", - scope: workScope, - expires: now + HOUR_MS, - }); - expect(rootVerdict.ok).toBe(true); - if (!rootVerdict.ok) return; - - const verdict = await mintCapabilityToken({ - identity: bearerIdentity, - clock: fixedClock(now), - tokenId: nextTokenId(), - bearer: (await generateEs256Identity()).deviceId, - capability: "exec:pty", - scope: { kind: "folder", path: "/elsewhere" }, - expires: now + HOUR_MS, - parent: rootVerdict.token, - }); - expect(verdict).toEqual({ ok: false, reason: "scope_does_not_narrow" }); - }); - - it("refuses to mint a delegation with a different capability than its parent's", async () => { - const rootVerdict = await mintCapabilityToken({ - identity: issuer, - clock: fixedClock(now), - tokenId: nextTokenId(), - bearer: bearerIdentity.deviceId, - capability: "exec:pty", - scope: workScope, - expires: now + HOUR_MS, - }); - expect(rootVerdict.ok).toBe(true); - if (!rootVerdict.ok) return; - - const verdict = await mintCapabilityToken({ - identity: bearerIdentity, - clock: fixedClock(now), - tokenId: nextTokenId(), - bearer: (await generateEs256Identity()).deviceId, - capability: "exec:proc", - scope: workScope, - expires: now + HOUR_MS, - parent: rootVerdict.token, - }); - expect(verdict).toEqual({ ok: false, reason: "capability_mismatch" }); - }); - - it("refuses to mint a delegation whose delegations-remaining is not strictly less than its parent's", async () => { - const rootVerdict = await mintCapabilityToken({ - identity: issuer, - clock: fixedClock(now), - tokenId: nextTokenId(), - bearer: bearerIdentity.deviceId, - capability: "room:member", - scope: { kind: "room", path: ROOM_MEMBER_ROOM_PATH }, - expires: now + HOUR_MS, - delegationsRemaining: 1, - }); - expect(rootVerdict.ok).toBe(true); - if (!rootVerdict.ok) return; - - // Equal to the parent's own delegations-remaining (1), not strictly less. - const verdict = await mintCapabilityToken({ - identity: bearerIdentity, - clock: fixedClock(now), - tokenId: nextTokenId(), - bearer: (await generateEs256Identity()).deviceId, - capability: "room:member", - scope: { kind: "room", path: ROOM_MEMBER_ROOM_PATH }, - expires: now + HOUR_MS, - delegationsRemaining: 1, - parent: rootVerdict.token, - }); - expect(verdict).toEqual({ ok: false, reason: "delegation_exceeds_parent" }); - }); - - it("refuses to mint an unbounded delegation under a parent that itself bounds re-delegation", async () => { - const rootVerdict = await mintCapabilityToken({ - identity: issuer, - clock: fixedClock(now), - tokenId: nextTokenId(), - bearer: bearerIdentity.deviceId, - capability: "room:member", - scope: { kind: "room", path: ROOM_MEMBER_ROOM_PATH }, - expires: now + HOUR_MS, - delegationsRemaining: 1, - }); - expect(rootVerdict.ok).toBe(true); - if (!rootVerdict.ok) return; - - // No delegationsRemaining at all -- unbounded, which is wider than the parent's bounded 1. - const verdict = await mintCapabilityToken({ - identity: bearerIdentity, - clock: fixedClock(now), - tokenId: nextTokenId(), - bearer: (await generateEs256Identity()).deviceId, - capability: "room:member", - scope: { kind: "room", path: ROOM_MEMBER_ROOM_PATH }, - expires: now + HOUR_MS, - parent: rootVerdict.token, - }); - expect(verdict).toEqual({ ok: false, reason: "delegation_exceeds_parent" }); - }); - - it("mints a second, independent root-level grant for a different bearer even though an earlier root grant this issuer minted has delegationsRemaining: 0", async () => { - const firstRoot = await mintCapabilityToken({ - identity: issuer, - clock: fixedClock(now), - tokenId: nextTokenId(), - bearer: bearerIdentity.deviceId, - capability: "room:member", - scope: { kind: "room", path: ROOM_MEMBER_ROOM_PATH }, - expires: now + HOUR_MS, - delegationsRemaining: 0, - }); - expect(firstRoot.ok).toBe(true); - - // No `parent` at all -- a second, independent root-level grant for a different bearer, never checked against firstRoot's own (unrelated) delegationsRemaining. - const secondBearer = await generateEs256Identity(); - const secondRoot = await mintCapabilityToken({ - identity: issuer, - clock: fixedClock(now), - tokenId: nextTokenId(), - bearer: secondBearer.deviceId, - capability: "room:member", - scope: { kind: "room", path: ROOM_MEMBER_ROOM_PATH }, - expires: now + HOUR_MS, - delegationsRemaining: 0, - }); - expect(secondRoot.ok).toBe(true); - if (!secondRoot.ok) return; - - const verified = await verifyCapabilityToken(secondRoot.token, { - identity: issuer, - clock: fixedClock(now), - revocation: neverRevoked, - }); - expect(verified.ok).toBe(true); - }); -}); - -describe("canGrant", () => { - let issuer: IdentityPort; - let bearerIdentity: IdentityPort; - - beforeAll(async () => { - issuer = await generateEs256Identity(); - bearerIdentity = await generateEs256Identity(); - }); - - const now = 1_893_456_000_000; - const workScope: CapabilityScope = { kind: "folder", path: "/work" }; - - it("agrees with a real mint's own verdict: true when narrowing succeeds", async () => { - const rootVerdict = await mintCapabilityToken({ - identity: issuer, - clock: fixedClock(now), - tokenId: nextTokenId(), - bearer: bearerIdentity.deviceId, - capability: "room:member", - scope: { kind: "room", path: ROOM_MEMBER_ROOM_PATH }, - expires: now + HOUR_MS, - delegationsRemaining: 1, - }); - expect(rootVerdict.ok).toBe(true); - if (!rootVerdict.ok) return; - - const candidate = { - capability: "room:member", - scope: { kind: "room", path: ROOM_MEMBER_ROOM_PATH } as CapabilityScope, - expires: now + HOUR_MS, - delegationsRemaining: 0, - }; - expect( - canGrant(rootVerdict.token, bearerIdentity.deviceId, candidate, now), - ).toBe(true); - - const delegatedVerdict = await mintCapabilityToken({ - identity: bearerIdentity, - clock: fixedClock(now), - tokenId: nextTokenId(), - bearer: (await generateEs256Identity()).deviceId, - ...candidate, - parent: rootVerdict.token, - }); - expect(delegatedVerdict.ok).toBe(true); - }); - - it("agrees with a real mint's own verdict: false when the querying device does not hold the token's bearer", async () => { - const rootVerdict = await mintCapabilityToken({ - identity: issuer, - clock: fixedClock(now), - tokenId: nextTokenId(), - bearer: bearerIdentity.deviceId, - capability: "exec:pty", - scope: workScope, - expires: now + HOUR_MS, - }); - expect(rootVerdict.ok).toBe(true); - if (!rootVerdict.ok) return; - - expect( - canGrant( - rootVerdict.token, - issuer.deviceId, - { - capability: "exec:pty", - scope: workScope, - expires: now + HOUR_MS, - }, - now, - ), - ).toBe(false); - }); - - it("agrees with a real mint's own verdict: false when the candidate's expiry exceeds the held token's", async () => { - const rootVerdict = await mintCapabilityToken({ - identity: issuer, - clock: fixedClock(now), - tokenId: nextTokenId(), - bearer: bearerIdentity.deviceId, - capability: "exec:pty", - scope: workScope, - expires: now + HOUR_MS, - }); - expect(rootVerdict.ok).toBe(true); - if (!rootVerdict.ok) return; - - expect( - canGrant( - rootVerdict.token, - bearerIdentity.deviceId, - { - capability: "exec:pty", - scope: workScope, - expires: now + HOUR_MS + 1, - }, - now, - ), - ).toBe(false); - }); - - it("agrees with a real mint's own verdict: false when the candidate's scope does not narrow the held token's", async () => { - const rootVerdict = await mintCapabilityToken({ - identity: issuer, - clock: fixedClock(now), - tokenId: nextTokenId(), - bearer: bearerIdentity.deviceId, - capability: "exec:pty", - scope: workScope, - expires: now + HOUR_MS, - }); - expect(rootVerdict.ok).toBe(true); - if (!rootVerdict.ok) return; - - expect( - canGrant( - rootVerdict.token, - bearerIdentity.deviceId, - { - capability: "exec:pty", - scope: { kind: "folder", path: "/elsewhere" }, - expires: now + HOUR_MS, - }, - now, - ), - ).toBe(false); - }); - - it("agrees with a real mint's own verdict: false when the candidate's capability differs", async () => { - const rootVerdict = await mintCapabilityToken({ - identity: issuer, - clock: fixedClock(now), - tokenId: nextTokenId(), - bearer: bearerIdentity.deviceId, - capability: "exec:pty", - scope: workScope, - expires: now + HOUR_MS, - }); - expect(rootVerdict.ok).toBe(true); - if (!rootVerdict.ok) return; - - expect( - canGrant( - rootVerdict.token, - bearerIdentity.deviceId, - { - capability: "room:member", - scope: workScope, - expires: now + HOUR_MS, - }, - now, - ), - ).toBe(false); - }); - - it("agrees with a real mint's own verdict: false when the candidate's delegations-remaining would not be strictly less than the held token's", async () => { - const rootVerdict = await mintCapabilityToken({ - identity: issuer, - clock: fixedClock(now), - tokenId: nextTokenId(), - bearer: bearerIdentity.deviceId, - capability: "room:member", - scope: { kind: "room", path: ROOM_MEMBER_ROOM_PATH }, - expires: now + HOUR_MS, - delegationsRemaining: 1, - }); - expect(rootVerdict.ok).toBe(true); - if (!rootVerdict.ok) return; - - expect( - canGrant( - rootVerdict.token, - bearerIdentity.deviceId, - { - capability: "room:member", - scope: { kind: "room", path: ROOM_MEMBER_ROOM_PATH }, - expires: now + HOUR_MS, - delegationsRemaining: 1, - }, - now, - ), - ).toBe(false); - }); - - it("returns false for a candidate that is already expired, without needing to consult the held token at all", async () => { - const rootVerdict = await mintCapabilityToken({ - identity: issuer, - clock: fixedClock(now), - tokenId: nextTokenId(), - bearer: bearerIdentity.deviceId, - capability: "exec:pty", - scope: workScope, - expires: now + HOUR_MS, - }); - expect(rootVerdict.ok).toBe(true); - if (!rootVerdict.ok) return; - - expect( - canGrant( - rootVerdict.token, - bearerIdentity.deviceId, - { - capability: "exec:pty", - scope: workScope, - expires: now - 1, - }, - now, - ), - ).toBe(false); - }); - - it("returns false for a malformed held token", () => { - const malformedToken: CapabilityToken = [ - new Uint8Array(), - {}, - null, - new Uint8Array(P256_SIGNATURE_BYTE_LENGTH), - ]; - expect( - canGrant( - malformedToken, - bearerIdentity.deviceId, - { - capability: "exec:pty", - scope: workScope, - expires: now + HOUR_MS, - }, - now, - ), - ).toBe(false); - }); -}); - -describe("mintRevocationEntry", () => { - it("mints a revocation entry that verifyRevocationEntry accepts", async () => { - const issuer = await generateEs256Identity(); - const tokenId = nextTokenId(); - - const entry = await mintRevocationEntry({ - identity: issuer, - tokenId, - revokedAt: 1_893_456_000_000, - }); - - const verdict = await verifyRevocationEntry(entry, { identity: issuer }); - expect(verdict).toEqual({ - ok: true, - claims: { - "token-id": tokenId, - issuer: issuer.deviceId, - "issuer-key": issuer.identityKey, - "revoked-at": 1_893_456_000_000, - }, - }); - }); -}); diff --git a/ts/packages/node/package.json b/ts/packages/node/package.json index aeeacc9..7eae57f 100644 --- a/ts/packages/node/package.json +++ b/ts/packages/node/package.json @@ -3,7 +3,7 @@ "version": "0.0.0", "private": true, "type": "module", - "packageManager": "pnpm@10.33.0", + "packageManager": "pnpm@12.4.1+sha512.2e81e399d73fe8390dab25e06aa788ab7a5908248d2f5a370f82b481147a6a7a367bf8048f9a6fdb6460f21a66f0542dedb8b94ca2c8723596741920b1656d4c", "bin": { "wire-mesh-node": "./dist/server.mjs" }, @@ -22,13 +22,13 @@ "_lint": "eslint . --fix --cache --max-warnings 0" }, "dependencies": { - "wire-mesh-core": "workspace:*", "cbor2": "2.3.0", + "wire-mesh-core": "workspace:*", "ws": "8.21.3" }, "devDependencies": { "@arethetypeswrong/cli": "0.18.5", - "@exadev/eslint-config": "2.10.6", + "@exadev/eslint-config": "2.12.1", "@types/node": "26.4.1", "@types/ws": "8.18.1", "eslint": "10.10.0", diff --git a/ts/packages/node/src/adapters/node-websocket-transport.ts b/ts/packages/node/src/adapters/node-websocket-transport.ts index 3448b52..2df9846 100644 --- a/ts/packages/node/src/adapters/node-websocket-transport.ts +++ b/ts/packages/node/src/adapters/node-websocket-transport.ts @@ -61,7 +61,7 @@ function decodeMessage(data: Uint8Array): Frame { return result.data; } -/** Narrows `ws`'s RawData (Buffer | ArrayBuffer | Buffer[]) to a single contiguous, genuinely plain Uint8Array. `ws` only ever delivers an array of buffers when a message arrives fragmented across a stream that itself isn't being reassembled -- doesn't happen for the default (non-streamed) receive path this adapter uses. A Node Buffer IS a Uint8Array subclass, but handing one to cbor2's decode() as-is leaks that subclass into every nested byte-string value decode() returns (it slices the same buffer type it was given); cbor2's encode() then does not recognise a Buffer as a plain byte string and serialises it as a generic object instead, corrupting any value (like a device-id) that round-trips decode -> store -> re-encode, as this hub's relay-connect -> relay-inbound path does. Always copying into a fresh Uint8Array here, the same normalisation the browser/Worker adapters get for free from `new Uint8Array(event.data)` on their own already-ArrayBuffer input, is the fix. */ +/** Narrows `ws`'s RawData (Buffer | ArrayBuffer | Buffer[]) to a single contiguous, genuinely plain Uint8Array. `ws` only ever delivers an array of buffers when a message arrives fragmented across a stream that itself isn't being reassembled -- doesn't happen for the default (non-streamed) receive path this adapter uses. A Node Buffer IS a Uint8Array subclass, but handing one to cbor2's decode() as-is leaks that subclass into every nested byte-string value decode() returns (it slices the same buffer type it was given); cbor2's encode() then does not recognise a Buffer as a plain byte string and serialises it as a generic object instead, corrupting any value (like a device-id) that round-trips decode -\> store -\> re-encode, as this hub's relay-connect -\> relay-inbound path does. Always copying into a fresh Uint8Array here, the same normalisation the browser/Worker adapters get for free from `new Uint8Array(event.data)` on their own already-ArrayBuffer input, is the fix. */ function bytesFromRawData(data: RawData): Uint8Array { if (Array.isArray(data)) { throw new Error("expected a single WebSocket message, got fragments"); diff --git a/ts/packages/web-console/package.json b/ts/packages/web-console/package.json index f8a30c1..563988d 100644 --- a/ts/packages/web-console/package.json +++ b/ts/packages/web-console/package.json @@ -3,7 +3,7 @@ "version": "0.0.0", "private": true, "type": "module", - "packageManager": "pnpm@10.33.0", + "packageManager": "pnpm@12.4.1+sha512.2e81e399d73fe8390dab25e06aa788ab7a5908248d2f5a370f82b481147a6a7a367bf8048f9a6fdb6460f21a66f0542dedb8b94ca2c8723596741920b1656d4c", "scripts": { "build": "turbo run _build", "_build": "vite build", @@ -27,7 +27,7 @@ "wire-mesh-core": "workspace:*" }, "devDependencies": { - "@exadev/eslint-config": "2.10.6", + "@exadev/eslint-config": "2.12.1", "@playwright/test": "1.63.0", "@testing-library/jest-dom": "7.0.1", "@testing-library/react": "16.3.3", diff --git a/ts/pnpm-lock.yaml b/ts/pnpm-lock.yaml index cc06da5..c8cbae4 100644 --- a/ts/pnpm-lock.yaml +++ b/ts/pnpm-lock.yaml @@ -1,3 +1,161 @@ +--- +lockfileVersion: '9.0' + +importers: + + .: + configDependencies: {} + packageManagerDependencies: + pnpm: + specifier: 12.4.1 + version: 12.4.1 + +packages: + + '@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] + + pnpm@12.4.1: + resolution: {integrity: sha512-LoHjmdc/6DkNqyXgaqeIq3pZCCSNL1o3D4K0gRR6ano2e/gEj5pv22Rg8hpm8FQt7bi5TKLIcjWWdBkgsWVtTA==} + engines: {node: '>=18.*'} + hasBin: true + +snapshots: + + '@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 + + 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 + +--- lockfileVersion: '9.0' settings: @@ -28,20 +186,20 @@ importers: specifier: 5.20260905.1 version: 5.20260905.1 '@exadev/eslint-config': - specifier: 2.10.6 - version: 2.10.6(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: 2.12.1 + version: 2.12.1(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 eslint: specifier: 10.10.0 - version: 10.10.0(jiti@2.7.0) + version: 10.10.0(jiti@2.7.0)(supports-color@10.2.2) 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@10.2.2)) 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@10.2.2)))(eslint@10.10.0(jiti@2.7.0)(supports-color@10.2.2))(prettier@3.9.6) globals: specifier: 17.12.0 version: 17.12.0 @@ -80,20 +238,20 @@ importers: specifier: 0.18.5 version: 0.18.5 '@exadev/eslint-config': - specifier: 2.10.6 - version: 2.10.6(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: 2.12.1 + version: 2.12.1(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) '@semantic-release/commit-analyzer': specifier: 13.0.1 - version: 13.0.1(semantic-release@25.0.9(typescript@6.0.3)) + version: 13.0.1(semantic-release@25.0.9(supports-color@10.2.2)(typescript@6.0.3))(supports-color@10.2.2) '@semantic-release/github': specifier: 12.0.9 - version: 12.0.9(semantic-release@25.0.9(typescript@6.0.3)) + version: 12.0.9(semantic-release@25.0.9(supports-color@10.2.2)(typescript@6.0.3))(supports-color@10.2.2) '@semantic-release/npm': specifier: 13.1.5 - version: 13.1.5(semantic-release@25.0.9(typescript@6.0.3)) + version: 13.1.5(semantic-release@25.0.9(supports-color@10.2.2)(typescript@6.0.3)) '@semantic-release/release-notes-generator': specifier: 14.1.1 - version: 14.1.1(semantic-release@25.0.9(typescript@6.0.3)) + version: 14.1.1(semantic-release@25.0.9(supports-color@10.2.2)(typescript@6.0.3))(supports-color@10.2.2) '@stryker-mutator/api': specifier: 10.0.0 version: 10.0.0 @@ -111,13 +269,13 @@ importers: version: 10.4.0 eslint: specifier: 10.10.0 - version: 10.10.0(jiti@2.7.0) + version: 10.10.0(jiti@2.7.0)(supports-color@10.2.2) 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@10.2.2)) 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@10.2.2)))(eslint@10.10.0(jiti@2.7.0)(supports-color@10.2.2))(prettier@3.9.6) globals: specifier: 17.12.0 version: 17.12.0 @@ -129,7 +287,7 @@ importers: version: 3.9.6 semantic-release: specifier: 25.0.9 - version: 25.0.9(typescript@6.0.3) + version: 25.0.9(supports-color@10.2.2)(typescript@6.0.3) tsdown: specifier: 0.23.0 version: 0.23.0(@arethetypeswrong/core@0.18.5)(typescript@6.0.3) @@ -159,8 +317,8 @@ importers: specifier: 0.18.5 version: 0.18.5 '@exadev/eslint-config': - specifier: 2.10.6 - version: 2.10.6(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: 2.12.1 + version: 2.12.1(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 @@ -169,13 +327,13 @@ importers: version: 8.18.1 eslint: specifier: 10.10.0 - version: 10.10.0(jiti@2.7.0) + version: 10.10.0(jiti@2.7.0)(supports-color@10.2.2) 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@10.2.2)) 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@10.2.2)))(eslint@10.10.0(jiti@2.7.0)(supports-color@10.2.2))(prettier@3.9.6) globals: specifier: 17.12.0 version: 17.12.0 @@ -223,8 +381,8 @@ importers: version: link:../core devDependencies: '@exadev/eslint-config': - specifier: 2.10.6 - version: 2.10.6(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: 2.12.1 + version: 2.12.1(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 @@ -251,19 +409,19 @@ importers: version: 1.21.2 '@vanilla-extract/vite-plugin': specifier: 5.2.6 - version: 5.2.6(@types/node@26.4.1)(jiti@2.7.0)(sugarss@5.0.1(postcss@8.5.28))(vite@8.2.2(@types/node@26.4.1)(jiti@2.7.0)(sugarss@5.0.1(postcss@8.5.28))) + version: 5.2.6(@types/node@26.4.1)(jiti@2.7.0)(sugarss@5.0.1(postcss@8.5.28))(supports-color@10.2.2)(vite@8.2.2(@types/node@26.4.1)(jiti@2.7.0)(sugarss@5.0.1(postcss@8.5.28))) '@vitejs/plugin-react': specifier: 6.1.1 version: 6.1.1(vite@8.2.2(@types/node@26.4.1)(jiti@2.7.0)(sugarss@5.0.1(postcss@8.5.28))) eslint: specifier: 10.10.0 - version: 10.10.0(jiti@2.7.0) + version: 10.10.0(jiti@2.7.0)(supports-color@10.2.2) 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@10.2.2)) 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@10.2.2)))(eslint@10.10.0(jiti@2.7.0)(supports-color@10.2.2))(prettier@3.9.6) fake-indexeddb: specifier: 6.2.5 version: 6.2.5 @@ -724,6 +882,14 @@ packages: '@emotion/hash@0.9.2': resolution: {integrity: sha512-MyqliTZGuOm3+5ZRSaaBGP3USLw6+EGykkwZns2EPC5g8jJ4z9OrdZY9apkl3+UP9+sdz76YYkwCKP5gh8iY3g==} + '@es-joy/jsdoccomment@0.97.0': + resolution: {integrity: sha512-EP8uoFfh6+GsdGCduYtmWAW0h7AO+Ayik9Vh5YbA2r/3N6lmJKkCNZX+q3QBXC1K6ixjQ/9igF2b7WVvLm063g==} + engines: {node: ^22.22.2 || >=24.15.0} + + '@es-joy/resolve.exports@1.2.0': + resolution: {integrity: sha512-Q9hjxWI5xBM+qW2enxfe8wDKdFWMfd0Z29k5ZJnuBqD/CasY5Zryj09aCA6owbGATWz+39p5uIdaHXpopOcG8g==} + engines: {node: '>=10'} + '@esbuild/aix-ppc64@0.25.4': resolution: {integrity: sha512-1VCICWypeQKhVbE9oW/sJaAmjLxhVqacdkvPLEjwlttjfwENRSClS8EjBz0KzRyFSCPDIkuXW34Je/vk7zdB7Q==} engines: {node: '>=18'} @@ -913,8 +1079,8 @@ packages: resolution: {integrity: sha512-IkO+/KEUvwbVpiURZg+P7zF74z5Jxe0UgJxVni+RtoHQ6IZieXaO02kmadomap/q+l6bc/jdPGGqTjhuZnuz1Q==} engines: {node: ^20.19.0 || ^22.13.0 || >=24} - '@exadev/eslint-config@2.10.6': - resolution: {integrity: sha512-iU/0uHNqo9GLLpLzlBA9KN3233aKHOj7bQYsc4in1HCZaUHqjNmAdX8bPs99SxhNjsP7wOpBl7Inrh6w3JDlqw==} + '@exadev/eslint-config@2.12.1': + resolution: {integrity: sha512-b5JKCX7l5onZy0i0kHdIeuiocxDoiuEwnrG6+j+EV8ZWaLpi18BDzNi1iT3tc647DFRUYJtHWDiJrkWT6E2JjQ==} engines: {node: '>=20'} peerDependencies: '@next/eslint-plugin-next': ^16.3.2 @@ -1278,6 +1444,12 @@ packages: peerDependencies: react: ^19.2.0 + '@microsoft/tsdoc-config@0.18.1': + resolution: {integrity: sha512-9brPoVdfN9k9g0dcWkFeA7IH9bbcttzDJlXvkf8b2OBzd5MueR1V2wkKBL0abn0otvmkHJC6aapBOTJDDeMCZg==} + + '@microsoft/tsdoc@0.16.0': + resolution: {integrity: sha512-xgAyonlVVS+q7Vc7qLW0UrJU7rSFcETRWsqdXZtjzRU8dF+6CkozTK4V4y1LwOX7j8r/vHphjDeMeGI4tNGeGA==} + '@octokit/auth-token@6.0.0': resolution: {integrity: sha512-P4YJBPdPSpWTQ1NU4XYdvHvXJJDxM6YwpS0FZHRgP7YFkdVxsWcpWGy/NVqlAA7PcPCnMacXlRm1y2PFZRWL/w==} engines: {node: '>= 20'} @@ -1512,6 +1684,10 @@ packages: resolution: {integrity: sha512-fCTuZK4QBa+39Oz9l4OGfJfz+GpwCp3AqO7Zch3to99xHPgstVsRFpeQ8LNd2o1Gv8raL2mCFwiaHh7bFSp5DQ==} engines: {node: '>=22'} + '@sindresorhus/base62@1.0.0': + resolution: {integrity: sha512-TeheYy0ILzBEI/CO55CP6zJCSdSWeRtGnHy8U8dWSUH4I68iqTsy7HkMktR4xakThc9jotkPQUXT4ITdbV7cHA==} + engines: {node: '>=18'} + '@sindresorhus/is@4.6.0': resolution: {integrity: sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw==} engines: {node: '>=10'} @@ -1671,16 +1847,32 @@ packages: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: '>=4.8.4 <6.1.0' + '@typescript-eslint/project-service@8.56.1': + resolution: {integrity: sha512-TAdqQTzHNNvlVFfR+hu2PDJrURiwKsUvxFn1M0h95BB8ah5jejas08jUWG4dBA68jDMI988IvtfdAI53JzEHOQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.0.0' + '@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/scope-manager@8.56.1': + resolution: {integrity: sha512-YAi4VDKcIZp0O4tz/haYKhmIDZFEUPOreKbfdAN3SzUDMcPhJ8QI99xQXqX+HoUVq8cs85eRKnD+rne2UAnj2w==} + 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/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.69.0': resolution: {integrity: sha512-xNqK7YTDZsLniQMV/4rpFR8Z5JlqeRvVjuG1YgF/mdPVH84HSD19L8CczMA0qg2RfwEV231GHH3VnToJDo4MfQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -1694,16 +1886,33 @@ packages: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: '>=4.8.4 <6.1.0' + '@typescript-eslint/types@8.56.1': + resolution: {integrity: sha512-dbMkdIUkIkchgGDIv7KLUpa0Mda4IYjo4IAMJUZ+3xNoUXxMsk9YtKpTHSChRS85o+H9ftm51gsK1dZReY9CVw==} + 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/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.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/utils@8.56.1': + resolution: {integrity: sha512-HPAVNIME3tABJ61siYlHzSWCGtOoeP2RTIaHXFMPqjrQKCGB9OgUVdiNgH7TJS2JNIQ5qQ4RsAUDuGaGme/KOA==} + 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.0.0' + '@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} @@ -1711,6 +1920,10 @@ packages: 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} + '@typescript-eslint/visitor-keys@8.69.0': resolution: {integrity: sha512-+rmdgPA+EXkNgKYvHvFfhrs35utXbwaC5PGpDquSXcoXQDKUA5UjV0LmTucG/4JXkM31BTu4TilHtrN8IVBe8w==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -1930,6 +2143,9 @@ packages: ajv@6.15.0: resolution: {integrity: sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==} + ajv@8.18.0: + resolution: {integrity: sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==} + ajv@8.20.0: resolution: {integrity: sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==} @@ -1968,6 +2184,10 @@ packages: any-promise@1.3.0: resolution: {integrity: sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==} + are-docs-informative@0.1.1: + resolution: {integrity: sha512-sqRsNQBwbKLRX0jV5Cu5uzmtflf892n4Vukz7T659ebL4pz3mpOqCMU7lxMoBTFwnp10E3YB5ZcyHM41W5bcDA==} + engines: {node: '>=18'} + argparse@2.0.1: resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} @@ -2145,6 +2365,10 @@ packages: resolution: {integrity: sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==} engines: {node: '>=20'} + comment-parser@1.4.8: + resolution: {integrity: sha512-rKZTGo4fzKYna8UcL0isTg5wkBNla7bxTypLwZQXjIdi++IdP1OJ41rI5Mti3/jltkPujbu4i9LIARYA+zpotQ==} + engines: {node: '>= 12.0.0'} + compare-func@2.0.0: resolution: {integrity: sha512-zHig5N+tPWARooBnb0Zx1MFcdfpyJrfTJ3Y5L+IFvUm8rM74hHz66z0gw0x4tijh5CorKkKUCnW82R2vmpeCRA==} @@ -2402,6 +2626,12 @@ packages: peerDependencies: eslint: '>=7.0.0' + eslint-plugin-jsdoc@64.3.6: + resolution: {integrity: sha512-lo7IXmgUUNy88SxW7KnJmmD2iPQBIRMomfCFPHidW1M3zNNVuzxlB2uoNrNyE1g4v2/L+RtYmiROPwQhSNzL8Q==} + engines: {node: ^22.22.2 || >=24.15.0} + peerDependencies: + eslint: ^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0 + eslint-plugin-prettier@5.5.6: resolution: {integrity: sha512-ifetmTcxWfz+4qRW3pH/ujdTq2jQIj59AxJMIN26K5avYgU8dxycUETQonWiW+wPrYXA0j3Try0l1CnwVQtDqQ==} engines: {node: ^14.18.0 || >=16.0.0} @@ -2416,6 +2646,9 @@ packages: eslint-config-prettier: optional: true + eslint-plugin-tsdoc@0.5.2: + resolution: {integrity: sha512-BlvqjWZdBJDIPO/YU3zcPCF23CvjYT3gyu63yo6b609NNV3D1b6zceAREy2xnweuBoDpZcLNuPyAUq9cvx6bbQ==} + eslint-scope@9.1.2: resolution: {integrity: sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==} engines: {node: ^20.19.0 || ^22.13.0 || >=24} @@ -2689,6 +2922,9 @@ packages: resolution: {integrity: sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==} engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + html-entities@2.6.0: + resolution: {integrity: sha512-kig+rMn/QOVRvr7c86gQ8lWXq+Hkv6CbAH1hLu+RG338StTpE8Z0b44SDVaqVu7HGKf27frdmUYEs9hTUX/cLQ==} + http-proxy-agent@9.1.0: resolution: {integrity: sha512-2NxoveTT58mjYT4n3RPTEfCZGLMbidoO8XEieXfpSYxu+PQJ1qpx4ypwH6N+uF9twBPIvRRgvkvW5HUTYWENig==} engines: {node: '>= 20'} @@ -2760,6 +2996,10 @@ packages: is-arrayish@0.3.4: resolution: {integrity: sha512-m6UrgzFVUYawGBh1dUsWR5M2Clqic9RVXC/9f8ceNlv2IcO9j9J/z8UoCLPqtsPBFNzEpfR3xftohbfqDx8EQA==} + is-core-module@2.16.2: + resolution: {integrity: sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==} + engines: {node: '>= 0.4'} + is-extglob@2.1.1: resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} engines: {node: '>=0.10.0'} @@ -2820,6 +3060,9 @@ packages: resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==} hasBin: true + jju@1.4.0: + resolution: {integrity: sha512-8wb9Yw966OSxApiCt0K3yNJL8pnNeIv+OEq2YMidz4FKP6nonSRoOXc80iXY4JaN2FC11B9qsNmDsm+ZOfMROA==} + js-md4@0.3.2: resolution: {integrity: sha512-/GDnfQYsltsjRswQhN9fhv3EMw2sCpUdrdxyWDOUK7eyD++r3gRhzgiQgc/x4MAv2i1iuQ4lxO5mvqM3vj4bwA==} @@ -2833,6 +3076,10 @@ packages: resolution: {integrity: sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==} hasBin: true + jsdoc-type-pratt-parser@9.2.1: + resolution: {integrity: sha512-V4Ww4EHnTcTLSOMoB0FsF72JhQvcAsriCm/LWnxJeGWoxIjEL2l9na11abQok5SYShq8m0Gl02el/xAbTCulvQ==} + engines: {node: ^22.22.2 || >=24.15.0} + jsdom@30.0.1: resolution: {integrity: sha512-52v7mUVUfNQVYYqE1lcdaymWL0njO7lTLUog6ZvW2U5KsbiLk/GnZlVJ+qx0xfNJZ6Gn+KSpPNE52vurbxZwrA==} engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0} @@ -3231,6 +3478,9 @@ packages: resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} engines: {node: '>=0.10.0'} + object-deep-merge@2.0.1: + resolution: {integrity: sha512-aKttDKcU3pyZqKcCkDhsMn70WmZFG2JGDQLP9EcLyTSIFQRCPWLAmBZRLJnrVUrhPG1jETEEbfdgbNtJf1LyMg==} + object-inspect@1.13.4: resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} engines: {node: '>= 0.4'} @@ -3298,6 +3548,9 @@ packages: resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} engines: {node: '>=6'} + parse-imports-exports@0.2.4: + resolution: {integrity: sha512-4s6vd6dx1AotCx/RCI2m7t7GCh5bDRUtGNvRfHSP2wbBQdMi67pPe7mtzmgwcaQ8VKK/6IB7Glfyu3qdZJPybQ==} + parse-json@4.0.0: resolution: {integrity: sha512-aOIos8bujGN93/8Ox/jPLh7RwVnPEysynVFE+fQZyg6jKELEHwzgKdLRFHUgXJL6kylijVSBC4BvN9OmsB48Rw==} engines: {node: '>=4'} @@ -3314,6 +3567,9 @@ packages: resolution: {integrity: sha512-TXfryirbmq34y8QBwgqCVLi+8oA3oWx2eAnSn62ITyEhEYaWRlVZ2DvMM9eZbMs/RfxPu/PK/aBLyGj4IrqMHw==} engines: {node: '>=18'} + parse-statements@1.0.11: + resolution: {integrity: sha512-HlsyYdMBnbPQ9Jr/VgJ1YF4scnldvJpJxCVx6KgqPL4dxppsWrJHCIIxQXMJrqGnsRkNPATbeMJ8Yxu7JMsYcA==} + parse5-htmlparser2-tree-adapter@6.0.1: resolution: {integrity: sha512-qPuWvbLgvDGilKc5BoicRovlT4MtYT6JfJyBOMDsKoiT+GiuP5qyrPCnR9HcPECIJJmZh5jRndyNThnhhb/vlA==} @@ -3342,6 +3598,9 @@ packages: resolution: {integrity: sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==} engines: {node: '>=12'} + path-parse@1.0.7: + resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} + path-to-regexp@6.3.0: resolution: {integrity: sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==} @@ -3566,6 +3825,10 @@ packages: require-like@0.1.2: resolution: {integrity: sha512-oyrU88skkMtDdauHDuKVrgR+zuItqr6/c//FXzvmxRGMexSDc6hNvJInGW3LL46n+8b50RykrvwSUIIQH2LQ5A==} + reserved-identifiers@1.2.0: + resolution: {integrity: sha512-yE7KUfFvaBFzGPs5H3Ops1RevfUEsDc5Iz65rOwWg4lE8HJSYtle77uul3+573457oHvBKuHYDl/xqUkKpEEdw==} + engines: {node: '>=18'} + resolve-from@4.0.0: resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} engines: {node: '>=4'} @@ -3577,6 +3840,11 @@ packages: resolve-pkg-maps@1.0.0: resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} + resolve@1.22.12: + resolution: {integrity: sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==} + engines: {node: '>= 0.4'} + hasBin: true + rolldown-plugin-dts@0.28.5: resolution: {integrity: sha512-yYd3C9CeJwqjOc9X23m0Tyxcqic491uLZlfg51szT287S8zCCqLR2uoySoElgqy2CLn7PdXcEo1dlkBs4n1WHg==} engines: {node: ^22.18.0 || ^24.11.0 || >=26.0.0} @@ -3701,6 +3969,9 @@ packages: spdx-expression-parse@3.0.1: resolution: {integrity: sha512-cbqHunsQWnJNE6KhVSMsMeH5H/L9EpymbzqTQ3uLwNCLZ1Q481oWaofqH7nO6V07xlXwY6PhQdQ2IedWx/ZK4Q==} + spdx-expression-parse@5.0.0: + resolution: {integrity: sha512-vngmw3Rgn+o2arXNbnZaj5UtOEBuWBfvaI+Wc8GFfykIhA5/vdK9/Sp/XkLv63dykz2rxKDvKEHupF5P0FORcQ==} + spdx-license-ids@3.0.23: resolution: {integrity: sha512-CWLcCCH7VLu13TgOH+r8p1O/Znwhqv/dbb6lqWy67G+pT1kHmeD/+V36AVb/vq8QMIQwVShJ6Ssl5FPh0fuSdw==} @@ -3789,6 +4060,10 @@ packages: resolution: {integrity: sha512-zFObLMyZeEwzAoKCyu1B91U79K2t7ApXuQfo8OuxwXLDgcKxuwM+YvcbIhm6QWqz7mHUH1TVytR1PwVVjEuMig==} engines: {node: '>=14.18'} + supports-preserve-symlinks-flag@1.0.0: + resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} + engines: {node: '>= 0.4'} + symbol-tree@3.2.4: resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==} @@ -3852,6 +4127,10 @@ packages: resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} engines: {node: '>=8.0'} + to-valid-identifier@1.0.0: + resolution: {integrity: sha512-41wJyvKep3yT2tyPqX/4blcfybknGB4D+oETKLs7Q76UiPqRpUJK3hr1nxelyYO0PHKVzJwlu0aCeEAsGI6rpw==} + engines: {node: '>=20'} + tough-cookie@6.0.2: resolution: {integrity: sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==} engines: {node: '>=16'} @@ -4377,20 +4656,20 @@ snapshots: '@babel/compat-data@8.0.0': {} - '@babel/core@7.29.7': + '@babel/core@7.29.7(supports-color@10.2.2)': dependencies: '@babel/code-frame': 7.29.7 '@babel/generator': 7.29.8 '@babel/helper-compilation-targets': 7.29.7 - '@babel/helper-module-transforms': 7.29.7(@babel/core@7.29.7) + '@babel/helper-module-transforms': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2) '@babel/helpers': 7.29.7 '@babel/parser': 7.29.8 '@babel/template': 7.29.7 - '@babel/traverse': 7.29.8 + '@babel/traverse': 7.29.8(supports-color@10.2.2) '@babel/types': 7.29.8 '@jridgewell/remapping': 2.3.5 convert-source-map: 2.0.0 - debug: 4.4.3 + debug: 4.4.3(supports-color@10.2.2) gensync: 1.0.0-beta.2 json5: 2.2.3 semver: 6.3.1 @@ -4473,9 +4752,9 @@ snapshots: '@babel/traverse': 8.0.4 '@babel/types': 8.0.4 - '@babel/helper-module-imports@7.29.7': + '@babel/helper-module-imports@7.29.7(supports-color@10.2.2)': dependencies: - '@babel/traverse': 7.29.8 + '@babel/traverse': 7.29.8(supports-color@10.2.2) '@babel/types': 7.29.8 transitivePeerDependencies: - supports-color @@ -4485,12 +4764,12 @@ snapshots: '@babel/traverse': 8.0.4 '@babel/types': 8.0.4 - '@babel/helper-module-transforms@7.29.7(@babel/core@7.29.7)': + '@babel/helper-module-transforms@7.29.7(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2)': dependencies: - '@babel/core': 7.29.7 - '@babel/helper-module-imports': 7.29.7 + '@babel/core': 7.29.7(supports-color@10.2.2) + '@babel/helper-module-imports': 7.29.7(supports-color@10.2.2) '@babel/helper-validator-identifier': 7.29.7 - '@babel/traverse': 7.29.8 + '@babel/traverse': 7.29.8(supports-color@10.2.2) transitivePeerDependencies: - supports-color @@ -4570,9 +4849,9 @@ snapshots: '@babel/core': 8.0.1 '@babel/helper-plugin-utils': 8.0.1(@babel/core@8.0.1) - '@babel/plugin-syntax-typescript@7.29.7(@babel/core@7.29.7)': + '@babel/plugin-syntax-typescript@7.29.7(@babel/core@7.29.7(supports-color@10.2.2))': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@10.2.2) '@babel/helper-plugin-utils': 7.29.7 '@babel/plugin-syntax-typescript@8.0.3(@babel/core@8.0.1)': @@ -4663,7 +4942,7 @@ snapshots: '@babel/parser': 8.0.4 '@babel/types': 8.0.4 - '@babel/traverse@7.29.8': + '@babel/traverse@7.29.8(supports-color@10.2.2)': dependencies: '@babel/code-frame': 7.29.7 '@babel/generator': 7.29.8 @@ -4671,7 +4950,7 @@ snapshots: '@babel/parser': 7.29.8 '@babel/template': 7.29.7 '@babel/types': 7.29.8 - debug: 4.4.3 + debug: 4.4.3(supports-color@10.2.2) transitivePeerDependencies: - supports-color @@ -4782,6 +5061,16 @@ snapshots: '@emotion/hash@0.9.2': {} + '@es-joy/jsdoccomment@0.97.0': + dependencies: + '@types/estree': 1.0.9 + '@typescript-eslint/types': 8.69.0 + comment-parser: 1.4.8 + esquery: 1.7.0 + jsdoc-type-pratt-parser: 9.2.1 + + '@es-joy/resolve.exports@1.2.0': {} + '@esbuild/aix-ppc64@0.25.4': optional: true @@ -4857,17 +5146,17 @@ snapshots: '@esbuild/win32-x64@0.25.4': 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@10.2.2))': dependencies: - eslint: 10.10.0(jiti@2.7.0) + eslint: 10.10.0(jiti@2.7.0)(supports-color@10.2.2) 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@10.2.2)': dependencies: '@eslint/object-schema': 3.0.5 - debug: 4.4.3 + debug: 4.4.3(supports-color@10.2.2) minimatch: 10.2.6 transitivePeerDependencies: - supports-color @@ -4880,9 +5169,9 @@ 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@10.2.2))': optionalDependencies: - eslint: 10.10.0(jiti@2.7.0) + eslint: 10.10.0(jiti@2.7.0)(supports-color@10.2.2) '@eslint/object-schema@3.0.5': {} @@ -4891,14 +5180,16 @@ snapshots: '@eslint/core': 1.2.1 levn: 0.4.1 - '@exadev/eslint-config@2.10.6(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)': + '@exadev/eslint-config@2.12.1(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)) - '@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/js': 10.0.1(eslint@10.10.0(jiti@2.7.0)(supports-color@10.2.2)) + '@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) + 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-tsdoc: 0.5.2(eslint@10.10.0(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3) 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@10.2.2))(supports-color@10.2.2)(typescript@6.0.3) transitivePeerDependencies: - supports-color @@ -5192,6 +5483,15 @@ snapshots: dependencies: react: 19.2.8 + '@microsoft/tsdoc-config@0.18.1': + dependencies: + '@microsoft/tsdoc': 0.16.0 + ajv: 8.18.0 + jju: 1.4.0 + resolve: 1.22.12 + + '@microsoft/tsdoc@0.16.0': {} + '@octokit/auth-token@6.0.0': {} '@octokit/core@7.0.8': @@ -5349,23 +5649,23 @@ snapshots: '@sec-ant/readable-stream@0.4.1': {} - '@semantic-release/commit-analyzer@13.0.1(semantic-release@25.0.9(typescript@6.0.3))': + '@semantic-release/commit-analyzer@13.0.1(semantic-release@25.0.9(supports-color@10.2.2)(typescript@6.0.3))(supports-color@10.2.2)': dependencies: conventional-changelog-angular: 8.3.1 conventional-changelog-writer: 9.2.1 conventional-commits-filter: 5.0.0 conventional-commits-parser: 6.4.0 - debug: 4.4.3 - import-from-esm: 2.0.0 + debug: 4.4.3(supports-color@10.2.2) + import-from-esm: 2.0.0(supports-color@10.2.2) lodash-es: 4.18.1 micromatch: 4.0.8 - semantic-release: 25.0.9(typescript@6.0.3) + semantic-release: 25.0.9(supports-color@10.2.2)(typescript@6.0.3) transitivePeerDependencies: - supports-color '@semantic-release/error@4.0.0': {} - '@semantic-release/github@12.0.9(semantic-release@25.0.9(typescript@6.0.3))': + '@semantic-release/github@12.0.9(semantic-release@25.0.9(supports-color@10.2.2)(typescript@6.0.3))(supports-color@10.2.2)': dependencies: '@octokit/core': 7.0.8 '@octokit/plugin-paginate-rest': 14.0.0(@octokit/core@7.0.8) @@ -5373,15 +5673,15 @@ snapshots: '@octokit/plugin-throttling': 11.0.5(@octokit/core@7.0.8) '@semantic-release/error': 4.0.0 aggregate-error: 5.0.0 - debug: 4.4.3 + debug: 4.4.3(supports-color@10.2.2) dir-glob: 3.0.1 - http-proxy-agent: 9.1.0 - https-proxy-agent: 9.1.0 + http-proxy-agent: 9.1.0(supports-color@10.2.2) + https-proxy-agent: 9.1.0(supports-color@10.2.2) issue-parser: 7.0.2 lodash-es: 4.18.1 mime: 4.1.0 p-filter: 4.1.0 - semantic-release: 25.0.9(typescript@6.0.3) + semantic-release: 25.0.9(supports-color@10.2.2)(typescript@6.0.3) tinyglobby: 0.2.17 undici: 7.14.0 url-join: 5.0.0 @@ -5389,7 +5689,7 @@ snapshots: - kerberos - supports-color - '@semantic-release/npm@13.1.5(semantic-release@25.0.9(typescript@6.0.3))': + '@semantic-release/npm@13.1.5(semantic-release@25.0.9(supports-color@10.2.2)(typescript@6.0.3))': dependencies: '@actions/core': 3.0.1 '@semantic-release/error': 4.0.0 @@ -5404,21 +5704,21 @@ snapshots: rc: 1.2.8 read-pkg: 10.1.0 registry-auth-token: 5.1.1 - semantic-release: 25.0.9(typescript@6.0.3) + semantic-release: 25.0.9(supports-color@10.2.2)(typescript@6.0.3) semver: 7.8.5 tempy: 3.2.0 - '@semantic-release/release-notes-generator@14.1.1(semantic-release@25.0.9(typescript@6.0.3))': + '@semantic-release/release-notes-generator@14.1.1(semantic-release@25.0.9(supports-color@10.2.2)(typescript@6.0.3))(supports-color@10.2.2)': dependencies: conventional-changelog-angular: 8.3.1 conventional-changelog-writer: 9.2.1 conventional-commits-filter: 5.0.0 conventional-commits-parser: 6.4.0 - debug: 4.4.3 - import-from-esm: 2.0.0 + debug: 4.4.3(supports-color@10.2.2) + import-from-esm: 2.0.0(supports-color@10.2.2) lodash-es: 4.18.1 read-package-up: 11.0.0 - semantic-release: 25.0.9(typescript@6.0.3) + semantic-release: 25.0.9(supports-color@10.2.2)(typescript@6.0.3) transitivePeerDependencies: - supports-color @@ -5426,6 +5726,8 @@ snapshots: '@simple-libs/stream-utils@2.0.0': {} + '@sindresorhus/base62@1.0.0': {} + '@sindresorhus/is@4.6.0': {} '@sindresorhus/is@7.2.0': {} @@ -5591,15 +5893,15 @@ snapshots: dependencies: '@types/node': 26.4.1 - '@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@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))(supports-color@10.2.2)(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@10.2.2))(supports-color@10.2.2)(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@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) '@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@10.2.2) ignore: 7.0.8 natural-compare: 1.4.0 ts-api-utils: 2.5.0(typescript@6.0.3) @@ -5607,57 +5909,92 @@ 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@10.2.2))(supports-color@10.2.2)(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@10.2.2)(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@10.2.2) + eslint: 10.10.0(jiti@2.7.0)(supports-color@10.2.2) typescript: 6.0.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/project-service@8.69.0(typescript@6.0.3)': + '@typescript-eslint/project-service@8.56.1(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 - debug: 4.4.3 + 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@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 + debug: 4.4.3(supports-color@10.2.2) + typescript: 6.0.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/scope-manager@8.56.1': + dependencies: + '@typescript-eslint/types': 8.56.1 + '@typescript-eslint/visitor-keys': 8.56.1 + '@typescript-eslint/scope-manager@8.69.0': dependencies: '@typescript-eslint/types': 8.69.0 '@typescript-eslint/visitor-keys': 8.69.0 + '@typescript-eslint/tsconfig-utils@8.56.1(typescript@6.0.3)': + dependencies: + typescript: 6.0.3 + '@typescript-eslint/tsconfig-utils@8.69.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))(typescript@6.0.3)': + '@typescript-eslint/type-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: '@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@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) + debug: 4.4.3(supports-color@10.2.2) + eslint: 10.10.0(jiti@2.7.0)(supports-color@10.2.2) ts-api-utils: 2.5.0(typescript@6.0.3) typescript: 6.0.3 transitivePeerDependencies: - supports-color + '@typescript-eslint/types@8.56.1': {} + '@typescript-eslint/types@8.69.0': {} - '@typescript-eslint/typescript-estree@8.69.0(typescript@6.0.3)': + '@typescript-eslint/typescript-estree@8.56.1(supports-color@10.2.2)(typescript@6.0.3)': dependencies: - '@typescript-eslint/project-service': 8.69.0(typescript@6.0.3) + '@typescript-eslint/project-service': 8.56.1(supports-color@10.2.2)(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(supports-color@10.2.2) + 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/typescript-estree@8.69.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 - debug: 4.4.3 + debug: 4.4.3(supports-color@10.2.2) minimatch: 10.2.6 semver: 7.8.5 tinyglobby: 0.2.17 @@ -5666,32 +6003,48 @@ snapshots: 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.56.1(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)) + '@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.56.1 + '@typescript-eslint/types': 8.56.1 + '@typescript-eslint/typescript-estree': 8.56.1(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@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(typescript@6.0.3) - eslint: 10.10.0(jiti@2.7.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/visitor-keys@8.56.1': + dependencies: + '@typescript-eslint/types': 8.56.1 + eslint-visitor-keys: 5.0.1 + '@typescript-eslint/visitor-keys@8.69.0': dependencies: '@typescript-eslint/types': 8.69.0 eslint-visitor-keys: 5.0.1 - '@vanilla-extract/babel-plugin-debug-ids@1.2.2': + '@vanilla-extract/babel-plugin-debug-ids@1.2.2(supports-color@10.2.2)': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@10.2.2) transitivePeerDependencies: - supports-color - '@vanilla-extract/compiler@0.7.2(@types/node@26.4.1)(jiti@2.7.0)(sugarss@5.0.1(postcss@8.5.28))': + '@vanilla-extract/compiler@0.7.2(@types/node@26.4.1)(jiti@2.7.0)(sugarss@5.0.1(postcss@8.5.28))(supports-color@10.2.2)': dependencies: '@vanilla-extract/css': 1.21.2 - '@vanilla-extract/integration': 8.0.10 + '@vanilla-extract/integration': 8.0.10(supports-color@10.2.2) vite: 8.2.2(@types/node@26.4.1)(jiti@2.7.0)(sugarss@5.0.1(postcss@8.5.28)) vite-node: 6.0.0(@types/node@26.4.1)(jiti@2.7.0)(sugarss@5.0.1(postcss@8.5.28)) transitivePeerDependencies: @@ -5726,11 +6079,11 @@ snapshots: transitivePeerDependencies: - babel-plugin-macros - '@vanilla-extract/integration@8.0.10': + '@vanilla-extract/integration@8.0.10(supports-color@10.2.2)': dependencies: - '@babel/core': 7.29.7 - '@babel/plugin-syntax-typescript': 7.29.7(@babel/core@7.29.7) - '@vanilla-extract/babel-plugin-debug-ids': 1.2.2 + '@babel/core': 7.29.7(supports-color@10.2.2) + '@babel/plugin-syntax-typescript': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2)) + '@vanilla-extract/babel-plugin-debug-ids': 1.2.2(supports-color@10.2.2) '@vanilla-extract/css': 1.21.2 dedent: 1.7.2 esbuild: 0.25.4 @@ -5744,10 +6097,10 @@ snapshots: '@vanilla-extract/private@1.0.9': {} - '@vanilla-extract/vite-plugin@5.2.6(@types/node@26.4.1)(jiti@2.7.0)(sugarss@5.0.1(postcss@8.5.28))(vite@8.2.2(@types/node@26.4.1)(jiti@2.7.0)(sugarss@5.0.1(postcss@8.5.28)))': + '@vanilla-extract/vite-plugin@5.2.6(@types/node@26.4.1)(jiti@2.7.0)(sugarss@5.0.1(postcss@8.5.28))(supports-color@10.2.2)(vite@8.2.2(@types/node@26.4.1)(jiti@2.7.0)(sugarss@5.0.1(postcss@8.5.28)))': dependencies: - '@vanilla-extract/compiler': 0.7.2(@types/node@26.4.1)(jiti@2.7.0)(sugarss@5.0.1(postcss@8.5.28)) - '@vanilla-extract/integration': 8.0.10 + '@vanilla-extract/compiler': 0.7.2(@types/node@26.4.1)(jiti@2.7.0)(sugarss@5.0.1(postcss@8.5.28))(supports-color@10.2.2) + '@vanilla-extract/integration': 8.0.10(supports-color@10.2.2) vite: 8.2.2(@types/node@26.4.1)(jiti@2.7.0)(sugarss@5.0.1(postcss@8.5.28)) transitivePeerDependencies: - '@types/node' @@ -5879,6 +6232,13 @@ snapshots: json-schema-traverse: 0.4.1 uri-js: 4.4.1 + ajv@8.18.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-uri: 3.1.7 + json-schema-traverse: 1.0.0 + require-from-string: 2.0.2 + ajv@8.20.0: dependencies: fast-deep-equal: 3.1.3 @@ -5910,6 +6270,8 @@ snapshots: any-promise@1.3.0: {} + are-docs-informative@0.1.1: {} + argparse@2.0.1: {} argue-cli@3.2.0: {} @@ -6076,6 +6438,8 @@ snapshots: commander@14.0.3: {} + comment-parser@1.4.8: {} + compare-func@2.0.0: dependencies: array-ify: 1.0.0 @@ -6162,9 +6526,11 @@ snapshots: transitivePeerDependencies: - '@noble/hashes' - debug@4.4.3: + debug@4.4.3(supports-color@10.2.2): dependencies: ms: 2.1.3 + optionalDependencies: + supports-color: 10.2.2 decimal.js@10.6.0: {} @@ -6290,18 +6656,50 @@ 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@10.2.2)): dependencies: - eslint: 10.10.0(jiti@2.7.0) + eslint: 10.10.0(jiti@2.7.0)(supports-color@10.2.2) - 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-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): dependencies: - eslint: 10.10.0(jiti@2.7.0) + '@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)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3) + are-docs-informative: 0.1.1 + comment-parser: 1.4.8 + debug: 4.4.3(supports-color@10.2.2) + escape-string-regexp: 5.0.0 + eslint: 10.10.0(jiti@2.7.0)(supports-color@10.2.2) + espree: 11.2.0 + esquery: 1.7.0 + html-entities: 2.6.0 + object-deep-merge: 2.0.1 + parse-imports-exports: 0.2.4 + semver: 7.8.5 + spdx-expression-parse: 5.0.0 + to-valid-identifier: 1.0.0 + transitivePeerDependencies: + - supports-color + - typescript + + eslint-plugin-prettier@5.5.6(eslint-config-prettier@10.1.8(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))(prettier@3.9.6): + dependencies: + eslint: 10.10.0(jiti@2.7.0)(supports-color@10.2.2) 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@10.2.2)) + + eslint-plugin-tsdoc@0.5.2(eslint@10.10.0(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(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)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3) + transitivePeerDependencies: + - eslint + - supports-color + - typescript eslint-scope@9.1.2: dependencies: @@ -6314,11 +6712,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@10.2.2): 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@10.2.2)) '@eslint-community/regexpp': 4.12.2 - '@eslint/config-array': 0.23.5 + '@eslint/config-array': 0.23.5(supports-color@10.2.2) '@eslint/config-helpers': 0.7.0 '@eslint/core': 1.2.1 '@eslint/plugin-kit': 0.7.3 @@ -6328,7 +6726,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@10.2.2) escape-string-regexp: 4.0.0 eslint-scope: 9.1.2 eslint-visitor-keys: 5.0.1 @@ -6592,19 +6990,21 @@ snapshots: transitivePeerDependencies: - '@noble/hashes' - http-proxy-agent@9.1.0: + html-entities@2.6.0: {} + + http-proxy-agent@9.1.0(supports-color@10.2.2): dependencies: agent-base: 9.0.0 - debug: 4.4.3 + debug: 4.4.3(supports-color@10.2.2) proxy-agent-negotiate: 1.1.0 transitivePeerDependencies: - kerberos - supports-color - https-proxy-agent@9.1.0: + https-proxy-agent@9.1.0(supports-color@10.2.2): dependencies: agent-base: 9.0.0 - debug: 4.4.3 + debug: 4.4.3(supports-color@10.2.2) proxy-agent-negotiate: 1.1.0 transitivePeerDependencies: - kerberos @@ -6627,9 +7027,9 @@ snapshots: parent-module: 1.0.1 resolve-from: 4.0.0 - import-from-esm@2.0.0: + import-from-esm@2.0.0(supports-color@10.2.2): dependencies: - debug: 4.4.3 + debug: 4.4.3(supports-color@10.2.2) import-meta-resolve: 4.2.0 transitivePeerDependencies: - supports-color @@ -6654,6 +7054,10 @@ snapshots: is-arrayish@0.3.4: {} + is-core-module@2.16.2: + dependencies: + hasown: 2.0.4 + is-extglob@2.1.1: {} is-fullwidth-code-point@3.0.0: {} @@ -6694,6 +7098,8 @@ snapshots: jiti@2.7.0: {} + jju@1.4.0: {} + js-md4@0.3.2: {} js-tokens@10.0.0: {} @@ -6704,6 +7110,11 @@ snapshots: dependencies: argparse: 2.0.1 + jsdoc-type-pratt-parser@9.2.1: + dependencies: + '@types/estree': 1.0.9 + '@types/node': 26.4.1 + jsdom@30.0.1: dependencies: '@asamuzakjp/css-color': 6.0.7 @@ -7016,6 +7427,8 @@ snapshots: object-assign@4.1.1: {} + object-deep-merge@2.0.1: {} + object-inspect@1.13.4: {} obug@2.1.4: {} @@ -7073,6 +7486,10 @@ snapshots: dependencies: callsites: 3.1.0 + parse-imports-exports@0.2.4: + dependencies: + parse-statements: 1.0.11 + parse-json@4.0.0: dependencies: error-ex: 1.3.4 @@ -7093,6 +7510,8 @@ snapshots: parse-ms@4.0.0: {} + parse-statements@1.0.11: {} + parse5-htmlparser2-tree-adapter@6.0.1: dependencies: parse5: 6.0.1 @@ -7113,6 +7532,8 @@ snapshots: path-key@4.0.0: {} + path-parse@1.0.7: {} + path-to-regexp@6.3.0: {} path-type@4.0.0: {} @@ -7322,12 +7743,21 @@ snapshots: require-like@0.1.2: {} + reserved-identifiers@1.2.0: {} + resolve-from@4.0.0: {} resolve-from@5.0.0: {} resolve-pkg-maps@1.0.0: {} + resolve@1.22.12: + dependencies: + es-errors: 1.3.0 + is-core-module: 2.16.2 + path-parse: 1.0.7 + supports-preserve-symlinks-flag: 1.0.0 + rolldown-plugin-dts@0.28.5(rolldown@1.2.7)(typescript@6.0.3): dependencies: dts-resolver: 3.0.0 @@ -7377,16 +7807,16 @@ snapshots: scheduler@0.27.0: {} - semantic-release@25.0.9(typescript@6.0.3): + semantic-release@25.0.9(supports-color@10.2.2)(typescript@6.0.3): dependencies: - '@semantic-release/commit-analyzer': 13.0.1(semantic-release@25.0.9(typescript@6.0.3)) + '@semantic-release/commit-analyzer': 13.0.1(semantic-release@25.0.9(supports-color@10.2.2)(typescript@6.0.3))(supports-color@10.2.2) '@semantic-release/error': 4.0.0 - '@semantic-release/github': 12.0.9(semantic-release@25.0.9(typescript@6.0.3)) - '@semantic-release/npm': 13.1.5(semantic-release@25.0.9(typescript@6.0.3)) - '@semantic-release/release-notes-generator': 14.1.1(semantic-release@25.0.9(typescript@6.0.3)) + '@semantic-release/github': 12.0.9(semantic-release@25.0.9(supports-color@10.2.2)(typescript@6.0.3))(supports-color@10.2.2) + '@semantic-release/npm': 13.1.5(semantic-release@25.0.9(supports-color@10.2.2)(typescript@6.0.3)) + '@semantic-release/release-notes-generator': 14.1.1(semantic-release@25.0.9(supports-color@10.2.2)(typescript@6.0.3))(supports-color@10.2.2) aggregate-error: 5.0.0 cosmiconfig: 9.0.2(typescript@6.0.3) - debug: 4.4.3 + debug: 4.4.3(supports-color@10.2.2) env-ci: 11.2.0 execa: 9.6.1 figures: 6.1.0 @@ -7395,7 +7825,7 @@ snapshots: git-log-parser: 1.2.1 hook-std: 4.0.0 hosted-git-info: 9.0.3 - import-from-esm: 2.0.0 + import-from-esm: 2.0.0(supports-color@10.2.2) lodash-es: 4.18.1 marked: 15.0.12 marked-terminal: 7.3.0(marked@15.0.12) @@ -7514,6 +7944,11 @@ snapshots: spdx-exceptions: 2.5.0 spdx-license-ids: 3.0.23 + spdx-expression-parse@5.0.0: + dependencies: + spdx-exceptions: 2.5.0 + spdx-license-ids: 3.0.23 + spdx-license-ids@3.0.23: {} split2@1.0.0: @@ -7597,6 +8032,8 @@ snapshots: has-flag: 4.0.0 supports-color: 7.2.0 + supports-preserve-symlinks-flag@1.0.0: {} + symbol-tree@3.2.4: {} synckit@0.11.13: @@ -7654,6 +8091,11 @@ snapshots: dependencies: is-number: 7.0.0 + to-valid-identifier@1.0.0: + dependencies: + '@sindresorhus/base62': 1.0.0 + reserved-identifiers: 1.2.0 + tough-cookie@6.0.2: dependencies: tldts: 7.4.12 @@ -7732,13 +8174,13 @@ snapshots: tunnel: 0.0.6 underscore: 1.13.8 - 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@10.2.2))(supports-color@10.2.2)(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@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))(supports-color@10.2.2)(typescript@6.0.3) + '@typescript-eslint/parser': 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/typescript-estree': 8.69.0(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) + eslint: 10.10.0(jiti@2.7.0)(supports-color@10.2.2) typescript: 6.0.3 transitivePeerDependencies: - supports-color diff --git a/ts/pnpm-workspace.yaml b/ts/pnpm-workspace.yaml index 2c900d2..11befb8 100644 --- a/ts/pnpm-workspace.yaml +++ b/ts/pnpm-workspace.yaml @@ -1,6 +1,16 @@ packages: - "packages/*" +overrides: + conventional-changelog-writer: "^9.2.0" +# Left disallowed, matching this workspace's existing behaviour under pnpm 10 (which only ever warned "Ignored build scripts" for these three and installed successfully without running them). +allowBuilds: + esbuild: false + sharp: false + workerd: false +minimumReleaseAgeExclude: + - '@exadev/eslint-config' # cddl.js is a git dependency (not published to npm) with a "prepare" build script -- an ExaDev-owned package this workspace already trusts, not a third-party one, so allowing its build script is the documented exception to pnpm's default script-blocking. onlyBuiltDependencies: - cddl.js +saveExact: true