diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 2075cd9..c571614 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -24,6 +24,7 @@ "@vue/tsconfig": "^0.9.1", "typescript": "~6.0.2", "vite": "^8.0.4", + "vitest": "^3.2.7", "vue-tsc": "^3.2.6" } }, @@ -79,6 +80,448 @@ "node": ">=6.9.0" } }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz", + "integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz", + "integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz", + "integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz", + "integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz", + "integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz", + "integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz", + "integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz", + "integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz", + "integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz", + "integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz", + "integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz", + "integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz", + "integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz", + "integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz", + "integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz", + "integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz", + "integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz", + "integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz", + "integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz", + "integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz", + "integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz", + "integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz", + "integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz", + "integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz", + "integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz", + "integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", @@ -124,6 +567,23 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@napi-rs/lzma-linux-x64-gnu": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz", + "integrity": "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^22.20 || ^24.12 || >=25" + } + }, "node_modules/@oxc-project/types": { "version": "0.151.0", "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.151.0.tgz", @@ -379,6 +839,356 @@ "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", "license": "MIT" }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.64.10", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.64.10.tgz", + "integrity": "sha512-oJ9ZfVIZjSjts0ZIYA48+5T0vurE5fQfX/Liad2U52Y6EN99x/OCdjGGuKMDP6jSk2i2lY6jwwA3X0drEc4PBg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.64.10", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.64.10.tgz", + "integrity": "sha512-LylBINgYYwgHNz8OMCy5+Y0w/LEgRYE0PcAIZfEuTuLNMf0S40GSLX6SFwqZsBLplS7RIHHy4zOAbl8Y5cQEvg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.64.10", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.64.10.tgz", + "integrity": "sha512-vbuT3VT1GBI2fAOoUJUrsHVoOg9nbrFXTM7e3DSnb9PYf4n6jZCN8v4Vo8O6x4u5B67+/c803b8BtCZe2nIeqA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.64.10", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.64.10.tgz", + "integrity": "sha512-qYUp/qYvM3T4cF+VWalPl+przBUESILxi+qZ1m1MVRHpg06vS+jHaxXubjCaRpXjLJPDFdXzOZ0FABDghHOhNQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.64.10", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.64.10.tgz", + "integrity": "sha512-v9NRQG+JztbP4DP/0yuhSyDcEJk047lYbqeNnqIgbXITcVDjGDS7OpkW5nadSY3tJ7MY8W1qIMMqP4Q3Q8BDjQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.64.10", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.64.10.tgz", + "integrity": "sha512-CHc9LBHdxhn6/NXfb9IegXCWoz5VlvcDOx9QrzZ9q+XYhFlfzwymSWJjOlZcEMMtC1zgMV7qxDe6U18AI1SG0A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.64.10", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.64.10.tgz", + "integrity": "sha512-ble2WR7kWfzZFJUd3ODMGCJn1XDszptfRC0QvWc0h9/EA6MxnrQ1mvbjPZifEdgHyfHhDrwWeKeJONDITcrHSg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.64.10", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.64.10.tgz", + "integrity": "sha512-DMCYegihxBMKvLpODdk4OsvIgxtHTi+Oddf1Pm3d6wMVNjcBiG2pqyXjMPy55HgG1gCdEFr9VfhB1CmvuKn1lw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.64.10", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.64.10.tgz", + "integrity": "sha512-HQ6HuVH9TmhvPWIas7MA/KeAROgdwqH3REpUU6hZq47NW7m1b1QCrHgM+M3+N/B8DfMt88x1/z79ppRc9RflCQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.64.10", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.64.10.tgz", + "integrity": "sha512-avBhxba1DdctX0kxKkN3JWdTSg72QsxSn6FdfhZ9MkL3TTYDOXqxY1GqbC7VNttlgviK7tCe2No5btfRkFgeIg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.64.10", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.64.10.tgz", + "integrity": "sha512-UtQMex7fiXq0W/GqcWRpy6XKqUbXgQeGKB+Bd5rJngmHzpI+3kjwpWFLAhKg02onFsIMSh+Zx0zp9JIMQ7/05A==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.64.10", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.64.10.tgz", + "integrity": "sha512-0Aum2pUsCSuOopi6oka+nR1DTj/nfvc1xjL979lvh6kXUjNa30TCX7sKFjyMsrX7bK3bNVs0FsJwQcE2HCWX1Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.64.10", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.64.10.tgz", + "integrity": "sha512-4dgsouKal3qfMFORgtNigLPt7K3HXiO6jTr7+nFJv1GWu1OkZx0kvQIXbK4sjrIGnaLBk0s80eQCQ9hPIelLrw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.64.10", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.64.10.tgz", + "integrity": "sha512-d7NE89try3jisYzBD7J9M5iwfgcrXYC4dwHEjQ3HIWBftvwch1U/cy3iZ26SthtUOyClKiu7iqYiFjQEwiOlqQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.64.10", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.64.10.tgz", + "integrity": "sha512-dqB5ChSBp91JWXxpCKAS5ZVAE38esKQvdfyqANm0lF5JPiQrXnYwgG3oVBQmuNRcQ1WHkA3j5qYLsrl2CtfhCA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.64.10", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.64.10.tgz", + "integrity": "sha512-7GMf3vbQQslorWLmfsbi5fdt9RVnglk205XpOMFvW6UY5jE5oJc6JLieQJPHtsYlZ2EO0PtC06nY9Hz/IG8guw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.64.10", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.64.10.tgz", + "integrity": "sha512-tuKbkB23DKc5LG+irPSUnZZztRHqC3CNmyxeWloueBteHB+i+1HkRCMzAE41lseLDlarybeLhqfibFitKOxKMg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.64.10", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.64.10.tgz", + "integrity": "sha512-ntHtQOMw7oIshHRF1swV3vyBw+beQt9PeZ04O3D32EeDZUMoHMJuZKp4/fDllH/+xogeK0GUCONVfjjKusqKLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.64.10", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.64.10.tgz", + "integrity": "sha512-Dlh8q8r145aYgh+N3gNIG1dz35Fz/FCxe5hs/43orDsffjP8S1P4Xz5I4FpSJsDS4LpQw1QmswZkQlUbkCldjQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.64.10", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.64.10.tgz", + "integrity": "sha512-VBJhHWz/55u45CWFv9OmfSRDgJDVENESgFxSAn13gIKJpTuFJbP4/7ymMZhDfeKFx1zGn1XBsLnNB7Ryy71qww==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.64.10", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.64.10.tgz", + "integrity": "sha512-Vjcazg+EEzUfIz+LcaxS6YtbQHo+sv0sCD+5VbwhY504bCKNW4OOreVgKq61pHT2esRQ/+AXYVPStDwxSvAOdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.64.10", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.64.10.tgz", + "integrity": "sha512-jJAaP4El+HWxYkM0jeJghASaFgfnluY0FRHhyHXxU6STs/nBhKMIxIMDrJEFg2ftKz6orX9WAo4diVan5NugNA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.64.10", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.64.10.tgz", + "integrity": "sha512-bXXdRUfRx9PaHCToKt8R/7wjDV6Tbk/hmSHK0uQBKmD3rj4jkuYndct8uGX77ZuZsePrVkQbWUgPR2jJhCTMpQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.64.10", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.64.10.tgz", + "integrity": "sha512-57Idx+VkEOQeG6Ys6sZOBFT7FfbeyxGv9A3k1m+Lf/z2SwqT40rNWbAINzjPHSMoCCBvY7Fnb9RGZWQin/HHAw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.64.10", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.64.10.tgz", + "integrity": "sha512-vrvuasIpm/xf+BpZbzgirku0WnrQZMAc+H2Ai26jzqBxYEmQQE4DdJb7h8erkJMfRRipx0ma2Cty8jJwUm9ocQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, "node_modules/@tailwindcss/node": { "version": "4.3.3", "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.3.tgz", @@ -885,6 +1695,31 @@ "vite": "^5.2.0 || ^6 || ^7 || ^8" } }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/node": { "version": "22.20.4", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.4.tgz", @@ -921,6 +1756,94 @@ "vue": "^3.2.25" } }, + "node_modules/@vitest/expect": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.7.tgz", + "integrity": "sha512-E8eBXaKibuvH2pSZErOjdVb5vF4PbKYcrnluBTYxEk1l/VhhwZg1kZQsdtjq+CsF5CFydf2Rdkz7jDHKSisi3w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/spy": "3.2.7", + "@vitest/utils": "3.2.7", + "chai": "^5.2.0", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/pretty-format": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.7.tgz", + "integrity": "sha512-KUHlwqVu0sRlhCdyPdQ/wBoTfRahjUky1MubOmYw9fWfIZy1gNoHpuaaQBPAaMaVYdQYHJLurzj8ECCj5OwTqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.7.tgz", + "integrity": "sha512-sB9y4ovltoQP+WaUPwmSxO9WIg9Ig694Di5PalVPsYHklAdE027mehpWF2SQSVq+k6sFgaivbTjTJwZLSHbedA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "3.2.7", + "pathe": "^2.0.3", + "strip-literal": "^3.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.7.tgz", + "integrity": "sha512-7C+MwShwtBSI5Buwoyg3s/iY1eHL9PKAf+O1wVh/TdnjXUtkoL/9YQtre90i4MtNXM6edP1wJ2zOBpfCyhIS7g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.7", + "magic-string": "^0.30.17", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.7.tgz", + "integrity": "sha512-Q2eQGI6d2L/hBtZ0qNuKcAGid68XK6cv1xsoaIma6PaJhHPoqcEJhYpXZ/5myCMqkNgtP6UKuBhbc0nHKnrkuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^4.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.7.tgz", + "integrity": "sha512-x6BDOd7dyo3PFLY3I9/HJ25X/6OurhGXk2/B9gOZNPF7XDVjeBK4k01lQE5uvDpbuheErh91qYuE1E2OEjK3Rw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.7", + "loupe": "^3.1.4", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/@volar/language-core": { "version": "2.4.28", "resolved": "https://registry.npmjs.org/@volar/language-core/-/language-core-2.4.28.tgz", @@ -1132,6 +2055,16 @@ "dev": true, "license": "MIT" }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/asynckit": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", @@ -1160,6 +2093,16 @@ "url": "https://github.com/sponsors/antfu" } }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/call-bind-apply-helpers": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", @@ -1173,6 +2116,33 @@ "node": ">= 0.4" } }, + "node_modules/chai": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", + "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/check-error": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", + "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + } + }, "node_modules/combined-stream": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", @@ -1208,6 +2178,16 @@ } } }, + "node_modules/deep-eql": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/delayed-stream": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", @@ -1283,6 +2263,13 @@ "node": ">= 0.4" } }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, "node_modules/es-object-atoms": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", @@ -1310,12 +2297,64 @@ "node": ">= 0.4" } }, + "node_modules/esbuild": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz", + "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==", + "devOptional": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.2", + "@esbuild/android-arm": "0.28.2", + "@esbuild/android-arm64": "0.28.2", + "@esbuild/android-x64": "0.28.2", + "@esbuild/darwin-arm64": "0.28.2", + "@esbuild/darwin-x64": "0.28.2", + "@esbuild/freebsd-arm64": "0.28.2", + "@esbuild/freebsd-x64": "0.28.2", + "@esbuild/linux-arm": "0.28.2", + "@esbuild/linux-arm64": "0.28.2", + "@esbuild/linux-ia32": "0.28.2", + "@esbuild/linux-loong64": "0.28.2", + "@esbuild/linux-mips64el": "0.28.2", + "@esbuild/linux-ppc64": "0.28.2", + "@esbuild/linux-riscv64": "0.28.2", + "@esbuild/linux-s390x": "0.28.2", + "@esbuild/linux-x64": "0.28.2", + "@esbuild/netbsd-arm64": "0.28.2", + "@esbuild/netbsd-x64": "0.28.2", + "@esbuild/openbsd-arm64": "0.28.2", + "@esbuild/openbsd-x64": "0.28.2", + "@esbuild/openharmony-arm64": "0.28.2", + "@esbuild/sunos-x64": "0.28.2", + "@esbuild/win32-arm64": "0.28.2", + "@esbuild/win32-ia32": "0.28.2", + "@esbuild/win32-x64": "0.28.2" + } + }, "node_modules/estree-walker": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", "license": "MIT" }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/fdir": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", @@ -1515,6 +2554,13 @@ "jiti": "lib/jiti-cli.mjs" } }, + "node_modules/js-tokens": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", + "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==", + "dev": true, + "license": "MIT" + }, "node_modules/lightningcss": { "version": "1.33.0", "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", @@ -1764,6 +2810,13 @@ "url": "https://opencollective.com/parcel" } }, + "node_modules/loupe": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", + "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", + "dev": true, + "license": "MIT" + }, "node_modules/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", @@ -1847,6 +2900,23 @@ "dev": true, "license": "MIT" }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathval": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", + "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.16" + } + }, "node_modules/perfect-debounce": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-2.1.0.tgz", @@ -1979,6 +3049,59 @@ "@rolldown/binding-win32-x64-msvc": "1.2.11" } }, + "node_modules/rollup": { + "version": "4.64.10", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.64.10.tgz", + "integrity": "sha512-8N52YH1B5JzBP+B1rkcBERG7rfRTGAM0RmUpyd76yyRfTljuG452GCNbVm6MPzs1+gXBha6Rw5/oHRFohOpfDg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@napi-rs/lzma-linux-x64-gnu": "1.5.1", + "@rollup/rollup-android-arm-eabi": "4.64.10", + "@rollup/rollup-android-arm64": "4.64.10", + "@rollup/rollup-darwin-arm64": "4.64.10", + "@rollup/rollup-darwin-x64": "4.64.10", + "@rollup/rollup-freebsd-arm64": "4.64.10", + "@rollup/rollup-freebsd-x64": "4.64.10", + "@rollup/rollup-linux-arm-gnueabihf": "4.64.10", + "@rollup/rollup-linux-arm-musleabihf": "4.64.10", + "@rollup/rollup-linux-arm64-gnu": "4.64.10", + "@rollup/rollup-linux-arm64-musl": "4.64.10", + "@rollup/rollup-linux-loong64-gnu": "4.64.10", + "@rollup/rollup-linux-loong64-musl": "4.64.10", + "@rollup/rollup-linux-ppc64-gnu": "4.64.10", + "@rollup/rollup-linux-ppc64-musl": "4.64.10", + "@rollup/rollup-linux-riscv64-gnu": "4.64.10", + "@rollup/rollup-linux-riscv64-musl": "4.64.10", + "@rollup/rollup-linux-s390x-gnu": "4.64.10", + "@rollup/rollup-linux-x64-gnu": "4.64.10", + "@rollup/rollup-linux-x64-musl": "4.64.10", + "@rollup/rollup-openbsd-x64": "4.64.10", + "@rollup/rollup-openharmony-arm64": "4.64.10", + "@rollup/rollup-win32-arm64-msvc": "4.64.10", + "@rollup/rollup-win32-ia32-msvc": "4.64.10", + "@rollup/rollup-win32-x64-gnu": "4.64.10", + "@rollup/rollup-win32-x64-msvc": "4.64.10", + "fsevents": "~2.3.2" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -1988,6 +3111,33 @@ "node": ">=0.10.0" } }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, + "node_modules/strip-literal": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.1.0.tgz", + "integrity": "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "js-tokens": "^9.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, "node_modules/tailwindcss": { "version": "4.3.3", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.3.tgz", @@ -2007,6 +3157,20 @@ "url": "https://opencollective.com/webpack" } }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true, + "license": "MIT" + }, "node_modules/tinyglobby": { "version": "0.2.17", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", @@ -2023,6 +3187,36 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/tinypool": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", + "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + } + }, + "node_modules/tinyrainbow": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz", + "integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-4.0.6.tgz", + "integrity": "sha512-u8KszXvGfU68hVcZpRHKG28T0krMuv2G5nDhiHaMLen/gIuFEgIJhaJuO69qjnXg5paSrbPMFfx3brNuN8eVSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/typescript": { "version": "6.0.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", @@ -2121,6 +3315,289 @@ } } }, + "node_modules/vite-node": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.2.4.tgz", + "integrity": "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.4.1", + "es-module-lexer": "^1.7.0", + "pathe": "^2.0.3", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vite-node/node_modules/vite": { + "version": "7.3.7", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.7.tgz", + "integrity": "sha512-AHEzTORMbi7/7aXp7w7KZ5+DCUdSeLsJz0grY8xHpc9DHdYb0vQu6DqJfm2tOw3gDExNgPgnJTXxM6syYFHaCA==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.27.0 || ^0.28.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vitest": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.7.tgz", + "integrity": "sha512-KrxIJ62Fd89gfysR4WotlgZABiz2dqFPgqGzX7s+CwsqLFomRH7777ZcrOD6+WVAh7khPQP41A+BKbpcJFrdEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/expect": "3.2.7", + "@vitest/mocker": "3.2.7", + "@vitest/pretty-format": "^3.2.7", + "@vitest/runner": "3.2.7", + "@vitest/snapshot": "3.2.7", + "@vitest/spy": "3.2.7", + "@vitest/utils": "3.2.7", + "chai": "^5.2.0", + "debug": "^4.4.1", + "expect-type": "^1.2.1", + "magic-string": "^0.30.17", + "pathe": "^2.0.3", + "picomatch": "^4.0.2", + "std-env": "^3.9.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.2", + "tinyglobby": "^0.2.14", + "tinypool": "^1.1.1", + "tinyrainbow": "^2.0.0", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0", + "vite-node": "3.2.4", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/debug": "^4.1.12", + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "@vitest/browser": "3.2.7", + "@vitest/ui": "3.2.7", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/debug": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/vitest/node_modules/@vitest/mocker": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.7.tgz", + "integrity": "sha512-Trr0hYO9CM3Wj6ksWHRhK9IZpIY6wTMO5u/MqXurMxT57sWBaOPEtP3Oq60ihZuh5JsiagKfz95OcxdEP6dBrA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "3.2.7", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.17" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/vitest/node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/vitest/node_modules/vite": { + "version": "7.3.7", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.7.tgz", + "integrity": "sha512-AHEzTORMbi7/7aXp7w7KZ5+DCUdSeLsJz0grY8xHpc9DHdYb0vQu6DqJfm2tOw3gDExNgPgnJTXxM6syYFHaCA==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.27.0 || ^0.28.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, "node_modules/vscode-uri": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/vscode-uri/-/vscode-uri-3.2.0.tgz", @@ -2187,6 +3664,23 @@ "typescript": ">=5.0.0" } }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/yaml": { "version": "2.9.1", "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.1.tgz", diff --git a/frontend/package.json b/frontend/package.json index 246539e..86bbf3e 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -7,6 +7,7 @@ "dev": "vite --host 0.0.0.0", "build": "vue-tsc -b && vite build", "preview": "vite preview", + "test": "vitest run", "postinstall": "node patches/patch-exiftool-stderr.mjs && node patches/patch-zeroperl-worker-fetch.mjs" }, "dependencies": { @@ -25,6 +26,7 @@ "@vue/tsconfig": "^0.9.1", "typescript": "~6.0.2", "vite": "^8.0.4", + "vitest": "^3.2.7", "vue-tsc": "^3.2.6" } } diff --git a/frontend/src/consts/crypto/aesGcmAuthTagByteLength.ts b/frontend/src/consts/crypto/aesGcmAuthTagByteLength.ts new file mode 100644 index 0000000..d80fc31 --- /dev/null +++ b/frontend/src/consts/crypto/aesGcmAuthTagByteLength.ts @@ -0,0 +1 @@ +export const AES_GCM_AUTH_TAG_BYTE_LENGTH = 16; diff --git a/frontend/src/consts/crypto/aesGcmIvByteLength.ts b/frontend/src/consts/crypto/aesGcmIvByteLength.ts new file mode 100644 index 0000000..4c332a1 --- /dev/null +++ b/frontend/src/consts/crypto/aesGcmIvByteLength.ts @@ -0,0 +1 @@ +export const AES_GCM_IV_BYTE_LENGTH = 12; diff --git a/frontend/src/consts/crypto/aesGcmKeyByteLength.ts b/frontend/src/consts/crypto/aesGcmKeyByteLength.ts new file mode 100644 index 0000000..96b9c15 --- /dev/null +++ b/frontend/src/consts/crypto/aesGcmKeyByteLength.ts @@ -0,0 +1 @@ +export const AES_GCM_KEY_BYTE_LENGTH = 32; diff --git a/frontend/src/consts/exiftoolWorkerInitJpeg.ts b/frontend/src/consts/exiftoolWorkerInitJpeg.ts index b2d0d60..b1940cc 100644 --- a/frontend/src/consts/exiftoolWorkerInitJpeg.ts +++ b/frontend/src/consts/exiftoolWorkerInitJpeg.ts @@ -1,4 +1,4 @@ -import { base64ToBytes } from '@/utils/base64ToBytes'; +import { base64ToBytes } from '@/utils/encoding/base64ToBytes'; const EXIFTOOL_WORKER_INIT_JPEG_BASE64 = '/9j/4AAQSkZJRgABAQEASABIAAD/2wBDAP//////////////////////////////////////////////////////////////////////////////////////2wBDAf//////////////////////////////////////////////////////////////////////////////////////wAARCAABAAEDASIAAhEBAxEB/8QAFQABAQAAAAAAAAAAAAAAAAAAAAb/xAAUEAEAAAAAAAAAAAAAAAAAAAAA/8QAFAEBAAAAAAAAAAAAAAAAAAAAAP/EABQRAQAAAAAAAAAAAAAAAAAAAAD/2gAMAwEAAhEDEQA/AL+AD//Z'; diff --git a/frontend/src/utils/crypto/decryptAesGcm.spec.ts b/frontend/src/utils/crypto/decryptAesGcm.spec.ts new file mode 100644 index 0000000..5896663 --- /dev/null +++ b/frontend/src/utils/crypto/decryptAesGcm.spec.ts @@ -0,0 +1,66 @@ +import { describe, expect, it } from 'vitest'; +import { AES_GCM_AUTH_TAG_BYTE_LENGTH } from '@/consts/crypto/aesGcmAuthTagByteLength'; +import { AES_GCM_IV_BYTE_LENGTH } from '@/consts/crypto/aesGcmIvByteLength'; +import { decryptAesGcm } from '@/utils/crypto/decryptAesGcm'; +import { encryptAesGcm } from '@/utils/crypto/encryptAesGcm'; +import { generateDek } from '@/utils/crypto/generateDek'; +import { generateIv } from '@/utils/crypto/generateIv'; +import { importAesGcmKey } from '@/utils/crypto/importAesGcmKey'; + +describe('decryptAesGcm', () => { + it('rejects wrong-length IVs before calling Web Crypto', async () => { + const dek = generateDek(); + const key = await importAesGcmKey(dek); + const ciphertext = new Uint8Array(AES_GCM_AUTH_TAG_BYTE_LENGTH); + + await expect(decryptAesGcm(ciphertext, key, new Uint8Array(AES_GCM_IV_BYTE_LENGTH - 1))).rejects.toThrow( + `AES-GCM IV must be ${AES_GCM_IV_BYTE_LENGTH} bytes` + ); + await expect(decryptAesGcm(ciphertext, key, new Uint8Array(AES_GCM_IV_BYTE_LENGTH + 1))).rejects.toThrow( + `AES-GCM IV must be ${AES_GCM_IV_BYTE_LENGTH} bytes` + ); + }); + + it('rejects tampered ciphertext (auth tag failure)', async () => { + const plaintext = new TextEncoder().encode('integrity-checked'); + const dek = generateDek(); + const iv = generateIv(); + const key = await importAesGcmKey(dek); + const ciphertext = await encryptAesGcm(plaintext, key, iv); + + const tampered = new Uint8Array(ciphertext); + tampered[tampered.length - 1] ^= 0x01; + + await expect(decryptAesGcm(tampered, key, iv)).rejects.toThrow(); + }); + + it('rejects decryption with the wrong key', async () => { + const plaintext = new TextEncoder().encode('wrong-key'); + const iv = generateIv(); + const encryptKey = await importAesGcmKey(generateDek()); + const decryptKey = await importAesGcmKey(generateDek()); + const ciphertext = await encryptAesGcm(plaintext, encryptKey, iv); + + await expect(decryptAesGcm(ciphertext, decryptKey, iv)).rejects.toThrow(); + }); + + it('rejects decryption with the wrong IV', async () => { + const plaintext = new TextEncoder().encode('wrong-iv'); + const dek = generateDek(); + const key = await importAesGcmKey(dek); + const ciphertext = await encryptAesGcm(plaintext, key, generateIv()); + + await expect(decryptAesGcm(ciphertext, key, generateIv())).rejects.toThrow(); + }); + + it('rejects truncated ciphertext', async () => { + const plaintext = new TextEncoder().encode('truncated'); + const dek = generateDek(); + const iv = generateIv(); + const key = await importAesGcmKey(dek); + const ciphertext = await encryptAesGcm(plaintext, key, iv); + const truncated = ciphertext.slice(0, ciphertext.length - 1); + + await expect(decryptAesGcm(truncated, key, iv)).rejects.toThrow(); + }); +}); diff --git a/frontend/src/utils/crypto/decryptAesGcm.ts b/frontend/src/utils/crypto/decryptAesGcm.ts new file mode 100644 index 0000000..5836930 --- /dev/null +++ b/frontend/src/utils/crypto/decryptAesGcm.ts @@ -0,0 +1,19 @@ +import { AES_GCM_IV_BYTE_LENGTH } from '@/consts/crypto/aesGcmIvByteLength'; +import { toArrayBuffer } from '@/utils/toArrayBuffer'; + +export const decryptAesGcm = async (ciphertext: Uint8Array, key: CryptoKey, iv: Uint8Array): Promise => { + if (iv.byteLength !== AES_GCM_IV_BYTE_LENGTH) { + throw new Error(`AES-GCM IV must be ${AES_GCM_IV_BYTE_LENGTH} bytes`); + } + + const plaintext = await crypto.subtle.decrypt( + { + name: 'AES-GCM', + iv: toArrayBuffer(iv) + }, + key, + toArrayBuffer(ciphertext) + ); + + return new Uint8Array(plaintext); +}; diff --git a/frontend/src/utils/crypto/encryptAesGcm.spec.ts b/frontend/src/utils/crypto/encryptAesGcm.spec.ts new file mode 100644 index 0000000..6ef9f8a --- /dev/null +++ b/frontend/src/utils/crypto/encryptAesGcm.spec.ts @@ -0,0 +1,109 @@ +import { describe, expect, it } from 'vitest'; +import { AES_GCM_AUTH_TAG_BYTE_LENGTH } from '@/consts/crypto/aesGcmAuthTagByteLength'; +import { AES_GCM_IV_BYTE_LENGTH } from '@/consts/crypto/aesGcmIvByteLength'; +import { bytesToBase64 } from '@/utils/encoding/bytesToBase64'; +import { decryptAesGcm } from '@/utils/crypto/decryptAesGcm'; +import { encryptAesGcm } from '@/utils/crypto/encryptAesGcm'; +import { generateDek } from '@/utils/crypto/generateDek'; +import { generateIv } from '@/utils/crypto/generateIv'; +import { importAesGcmKey } from '@/utils/crypto/importAesGcmKey'; + +const UPLOAD_API_BASE64_PATTERN = /^[A-Za-z0-9+/]+={0,2}$/; + +describe('encryptAesGcm', () => { + it('roundtrips plaintext', async () => { + const plaintext = new TextEncoder().encode('stripped-image-bytes'); + const dek = generateDek(); + const iv = generateIv(); + const key = await importAesGcmKey(dek); + + const ciphertext = await encryptAesGcm(plaintext, key, iv); + const decrypted = await decryptAesGcm(ciphertext, key, iv); + + expect(decrypted).toEqual(plaintext); + }); + + it('roundtrips empty plaintext and large payloads', async () => { + const dek = generateDek(); + const key = await importAesGcmKey(dek); + + const empty = new Uint8Array(0); + const large = new Uint8Array(65_536); + + for (let i = 0; i < large.length; i++) { + large[i] = i % 256; + } + + for (const plaintext of [empty, large]) { + const iv = generateIv(); + const ciphertext = await encryptAesGcm(plaintext, key, iv); + const decrypted = await decryptAesGcm(ciphertext, key, iv); + + expect(decrypted).toEqual(plaintext); + expect(ciphertext.byteLength).toBe(plaintext.byteLength + AES_GCM_AUTH_TAG_BYTE_LENGTH); + } + }); + + it('accepts ArrayBuffer plaintext', async () => { + const plaintext = new TextEncoder().encode('array-buffer-plaintext').buffer; + const dek = generateDek(); + const iv = generateIv(); + const key = await importAesGcmKey(dek); + + const ciphertext = await encryptAesGcm(plaintext, key, iv); + const decrypted = await decryptAesGcm(ciphertext, key, iv); + + expect(decrypted).toEqual(new Uint8Array(plaintext)); + }); + + it('rejects wrong-length IVs before calling Web Crypto', async () => { + const dek = generateDek(); + const key = await importAesGcmKey(dek); + const plaintext = new Uint8Array([1, 2, 3]); + + await expect(encryptAesGcm(plaintext, key, new Uint8Array(AES_GCM_IV_BYTE_LENGTH - 1))).rejects.toThrow( + `AES-GCM IV must be ${AES_GCM_IV_BYTE_LENGTH} bytes` + ); + await expect(encryptAesGcm(plaintext, key, new Uint8Array(AES_GCM_IV_BYTE_LENGTH + 1))).rejects.toThrow( + `AES-GCM IV must be ${AES_GCM_IV_BYTE_LENGTH} bytes` + ); + }); + + it('is deterministic for a fixed key, IV, and plaintext', async () => { + const plaintext = new TextEncoder().encode('deterministic-check'); + const dek = generateDek(); + const iv = generateIv(); + const key = await importAesGcmKey(dek); + + const first = await encryptAesGcm(plaintext, key, iv); + const second = await encryptAesGcm(plaintext, key, iv); + + expect(first).toEqual(second); + }); + + it('changes ciphertext when the IV changes (nonce separation)', async () => { + const plaintext = new TextEncoder().encode('unique-nonce'); + const dek = generateDek(); + const key = await importAesGcmKey(dek); + + const ciphertextA = await encryptAesGcm(plaintext, key, generateIv()); + const ciphertextB = await encryptAesGcm(plaintext, key, generateIv()); + + expect(ciphertextA).not.toEqual(ciphertextB); + }); + + it('produces standard base64 iv and ciphertext suitable for the upload API', async () => { + const plaintext = new Uint8Array(64); + const dek = generateDek(); + const iv = generateIv(); + const key = await importAesGcmKey(dek); + + const ciphertext = await encryptAesGcm(plaintext, key, iv); + const ivBase64 = bytesToBase64(iv); + const ciphertextBase64 = bytesToBase64(ciphertext); + + expect(UPLOAD_API_BASE64_PATTERN.test(ivBase64)).toBe(true); + expect(UPLOAD_API_BASE64_PATTERN.test(ciphertextBase64)).toBe(true); + expect(ciphertext.byteLength).toBe(plaintext.byteLength + AES_GCM_AUTH_TAG_BYTE_LENGTH); + }); +}); diff --git a/frontend/src/utils/crypto/encryptAesGcm.ts b/frontend/src/utils/crypto/encryptAesGcm.ts new file mode 100644 index 0000000..dea9551 --- /dev/null +++ b/frontend/src/utils/crypto/encryptAesGcm.ts @@ -0,0 +1,23 @@ +import { AES_GCM_IV_BYTE_LENGTH } from '@/consts/crypto/aesGcmIvByteLength'; +import { toArrayBuffer } from '@/utils/toArrayBuffer'; + +export const encryptAesGcm = async ( + plaintext: ArrayBuffer | Uint8Array, + key: CryptoKey, + iv: Uint8Array +): Promise => { + if (iv.byteLength !== AES_GCM_IV_BYTE_LENGTH) { + throw new Error(`AES-GCM IV must be ${AES_GCM_IV_BYTE_LENGTH} bytes`); + } + + const ciphertext = await crypto.subtle.encrypt( + { + name: 'AES-GCM', + iv: toArrayBuffer(iv) + }, + key, + toArrayBuffer(plaintext) + ); + + return new Uint8Array(ciphertext); +}; diff --git a/frontend/src/utils/crypto/generateDek.spec.ts b/frontend/src/utils/crypto/generateDek.spec.ts new file mode 100644 index 0000000..22dca80 --- /dev/null +++ b/frontend/src/utils/crypto/generateDek.spec.ts @@ -0,0 +1,19 @@ +import { describe, expect, it } from 'vitest'; +import { AES_GCM_KEY_BYTE_LENGTH } from '@/consts/crypto/aesGcmKeyByteLength'; +import { generateDek } from '@/utils/crypto/generateDek'; + +describe('generateDek', () => { + it('returns a 32-byte raw AES-256 key', () => { + const dek = generateDek(); + + expect(dek).toBeInstanceOf(Uint8Array); + expect(dek.byteLength).toBe(AES_GCM_KEY_BYTE_LENGTH); + }); + + it('generates distinct DEKs across calls', () => { + const dekA = generateDek(); + const dekB = generateDek(); + + expect(dekA).not.toEqual(dekB); + }); +}); diff --git a/frontend/src/utils/crypto/generateDek.ts b/frontend/src/utils/crypto/generateDek.ts new file mode 100644 index 0000000..25dab54 --- /dev/null +++ b/frontend/src/utils/crypto/generateDek.ts @@ -0,0 +1,4 @@ +import { AES_GCM_KEY_BYTE_LENGTH } from '@/consts/crypto/aesGcmKeyByteLength'; +import { generateRandomBytes } from '@/utils/crypto/generateRandomBytes'; + +export const generateDek = (): Uint8Array => generateRandomBytes(AES_GCM_KEY_BYTE_LENGTH); diff --git a/frontend/src/utils/crypto/generateIv.spec.ts b/frontend/src/utils/crypto/generateIv.spec.ts new file mode 100644 index 0000000..c3e5979 --- /dev/null +++ b/frontend/src/utils/crypto/generateIv.spec.ts @@ -0,0 +1,19 @@ +import { describe, expect, it } from 'vitest'; +import { AES_GCM_IV_BYTE_LENGTH } from '@/consts/crypto/aesGcmIvByteLength'; +import { generateIv } from '@/utils/crypto/generateIv'; + +describe('generateIv', () => { + it('returns a 12-byte IV for AES-GCM', () => { + const iv = generateIv(); + + expect(iv).toBeInstanceOf(Uint8Array); + expect(iv.byteLength).toBe(AES_GCM_IV_BYTE_LENGTH); + }); + + it('generates distinct IVs across calls', () => { + const ivA = generateIv(); + const ivB = generateIv(); + + expect(ivA).not.toEqual(ivB); + }); +}); diff --git a/frontend/src/utils/crypto/generateIv.ts b/frontend/src/utils/crypto/generateIv.ts new file mode 100644 index 0000000..6dcff36 --- /dev/null +++ b/frontend/src/utils/crypto/generateIv.ts @@ -0,0 +1,4 @@ +import { AES_GCM_IV_BYTE_LENGTH } from '@/consts/crypto/aesGcmIvByteLength'; +import { generateRandomBytes } from '@/utils/crypto/generateRandomBytes'; + +export const generateIv = (): Uint8Array => generateRandomBytes(AES_GCM_IV_BYTE_LENGTH); diff --git a/frontend/src/utils/crypto/generateRandomBytes.spec.ts b/frontend/src/utils/crypto/generateRandomBytes.spec.ts new file mode 100644 index 0000000..0711ecf --- /dev/null +++ b/frontend/src/utils/crypto/generateRandomBytes.spec.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from 'vitest'; +import { generateRandomBytes } from '@/utils/crypto/generateRandomBytes'; + +describe('generateRandomBytes', () => { + it('returns a Uint8Array of the requested length', () => { + expect(generateRandomBytes(0)).toEqual(new Uint8Array(0)); + expect(generateRandomBytes(1).byteLength).toBe(1); + expect(generateRandomBytes(4096).byteLength).toBe(4096); + }); + + it('does not return the same buffer for consecutive calls', () => { + const first = generateRandomBytes(32); + const second = generateRandomBytes(32); + + expect(first).not.toEqual(second); + }); + + it('is not trivially constant for non-zero length', () => { + const bytes = generateRandomBytes(64); + const allSame = bytes.every(byte => byte === bytes[0]); + + expect(allSame).toBe(false); + }); +}); diff --git a/frontend/src/utils/crypto/generateRandomBytes.ts b/frontend/src/utils/crypto/generateRandomBytes.ts new file mode 100644 index 0000000..61e55a2 --- /dev/null +++ b/frontend/src/utils/crypto/generateRandomBytes.ts @@ -0,0 +1,5 @@ +export const generateRandomBytes = (length: number): Uint8Array => { + const bytes = new Uint8Array(length); + + return crypto.getRandomValues(bytes); +}; diff --git a/frontend/src/utils/crypto/importAesGcmKey.spec.ts b/frontend/src/utils/crypto/importAesGcmKey.spec.ts new file mode 100644 index 0000000..9158bbe --- /dev/null +++ b/frontend/src/utils/crypto/importAesGcmKey.spec.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from 'vitest'; +import { AES_GCM_KEY_BYTE_LENGTH } from '@/consts/crypto/aesGcmKeyByteLength'; +import { encryptAesGcm } from '@/utils/crypto/encryptAesGcm'; +import { generateDek } from '@/utils/crypto/generateDek'; +import { generateIv } from '@/utils/crypto/generateIv'; +import { importAesGcmKey } from '@/utils/crypto/importAesGcmKey'; + +describe('importAesGcmKey', () => { + it('imports a raw 32-byte key for AES-GCM encrypt and decrypt', async () => { + const dek = generateDek(); + const key = await importAesGcmKey(dek); + + expect(key.algorithm.name).toBe('AES-GCM'); + expect(key.type).toBe('secret'); + expect(key.extractable).toBe(false); + expect(key.usages).toEqual(['encrypt', 'decrypt']); + + const iv = generateIv(); + const plaintext = new TextEncoder().encode('key-import-smoke'); + + await expect(encryptAesGcm(plaintext, key, iv)).resolves.toBeInstanceOf(Uint8Array); + }); + + it('rejects wrong-length raw keys', async () => { + const shortKey = new Uint8Array(AES_GCM_KEY_BYTE_LENGTH - 1); + const longKey = new Uint8Array(AES_GCM_KEY_BYTE_LENGTH + 1); + + await expect(importAesGcmKey(shortKey)).rejects.toThrow( + `AES-GCM key must be ${AES_GCM_KEY_BYTE_LENGTH} bytes` + ); + await expect(importAesGcmKey(longKey)).rejects.toThrow( + `AES-GCM key must be ${AES_GCM_KEY_BYTE_LENGTH} bytes` + ); + }); +}); diff --git a/frontend/src/utils/crypto/importAesGcmKey.ts b/frontend/src/utils/crypto/importAesGcmKey.ts new file mode 100644 index 0000000..77eda21 --- /dev/null +++ b/frontend/src/utils/crypto/importAesGcmKey.ts @@ -0,0 +1,10 @@ +import { AES_GCM_KEY_BYTE_LENGTH } from '@/consts/crypto/aesGcmKeyByteLength'; +import { toArrayBuffer } from '@/utils/toArrayBuffer'; + +export const importAesGcmKey = async (rawKey: Uint8Array): Promise => { + if (rawKey.byteLength !== AES_GCM_KEY_BYTE_LENGTH) { + throw new Error(`AES-GCM key must be ${AES_GCM_KEY_BYTE_LENGTH} bytes`); + } + + return crypto.subtle.importKey('raw', toArrayBuffer(rawKey), { name: 'AES-GCM' }, false, ['encrypt', 'decrypt']); +}; diff --git a/frontend/src/utils/encoding/base64ToBytes.spec.ts b/frontend/src/utils/encoding/base64ToBytes.spec.ts new file mode 100644 index 0000000..e8e9d07 --- /dev/null +++ b/frontend/src/utils/encoding/base64ToBytes.spec.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from 'vitest'; +import { base64ToBytes } from '@/utils/encoding/base64ToBytes'; + +describe('base64ToBytes', () => { + it('decodes RFC 4648 examples', () => { + expect(base64ToBytes('Zg==')).toEqual(new Uint8Array([102])); + expect(base64ToBytes('Zm8=')).toEqual(new Uint8Array([102, 111])); + expect(base64ToBytes('Zm9v')).toEqual(new Uint8Array([102, 111, 111])); + expect(base64ToBytes('Zm9vYg==')).toEqual(new Uint8Array([102, 111, 111, 98])); + }); + + it('decodes empty input', () => { + expect(base64ToBytes('')).toEqual(new Uint8Array([])); + }); + + it('decodes every byte value via roundtrip with btoa', () => { + const bytes = new Uint8Array(256); + + for (let i = 0; i < 256; i++) { + bytes[i] = i; + } + + const binary = Array.from(bytes, byte => String.fromCharCode(byte)).join(''); + const base64 = btoa(binary); + + expect(base64ToBytes(base64)).toEqual(bytes); + }); + + it('rejects invalid base64', () => { + expect(() => base64ToBytes('not!!!valid')).toThrow(); + }); +}); diff --git a/frontend/src/utils/base64ToBytes.ts b/frontend/src/utils/encoding/base64ToBytes.ts similarity index 100% rename from frontend/src/utils/base64ToBytes.ts rename to frontend/src/utils/encoding/base64ToBytes.ts diff --git a/frontend/src/utils/encoding/base64UrlToBytes.spec.ts b/frontend/src/utils/encoding/base64UrlToBytes.spec.ts new file mode 100644 index 0000000..27f2c34 --- /dev/null +++ b/frontend/src/utils/encoding/base64UrlToBytes.spec.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from 'vitest'; +import { base64ToBytes } from '@/utils/encoding/base64ToBytes'; +import { bytesToBase64 } from '@/utils/encoding/bytesToBase64'; +import { bytesToBase64Url } from '@/utils/encoding/bytesToBase64Url'; +import { base64UrlToBytes } from '@/utils/encoding/base64UrlToBytes'; + +describe('base64UrlToBytes', () => { + it('decodes with and without restored padding', () => { + const bytes = new Uint8Array([1, 2, 3]); + const standard = bytesToBase64(bytes); + const url = bytesToBase64Url(bytes); + + expect(base64UrlToBytes(url)).toEqual(bytes); + expect(base64UrlToBytes(standard.replaceAll('+', '-').replaceAll('/', '_').replaceAll('=', ''))).toEqual(bytes); + }); + + it('matches standard base64 decode for padded input', () => { + const standard = 'AQIDBA=='; + const url = 'AQIDBA'; + + expect(base64UrlToBytes(url)).toEqual(base64ToBytes(standard)); + }); + + it('rejects invalid input', () => { + expect(() => base64UrlToBytes('%%%')).toThrow(); + }); +}); diff --git a/frontend/src/utils/encoding/base64UrlToBytes.ts b/frontend/src/utils/encoding/base64UrlToBytes.ts new file mode 100644 index 0000000..463f66e --- /dev/null +++ b/frontend/src/utils/encoding/base64UrlToBytes.ts @@ -0,0 +1,13 @@ +import { base64ToBytes } from '@/utils/encoding/base64ToBytes'; + +export const base64UrlToBytes = (base64Url: string): Uint8Array => { + let base64 = base64Url.split('-').join('+').split('_').join('/'); + + const padding = base64.length % 4; + + if (padding > 0) { + base64 += '='.repeat(4 - padding); + } + + return base64ToBytes(base64); +}; diff --git a/frontend/src/utils/encoding/bytesToBase64.spec.ts b/frontend/src/utils/encoding/bytesToBase64.spec.ts new file mode 100644 index 0000000..9edac6c --- /dev/null +++ b/frontend/src/utils/encoding/bytesToBase64.spec.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from 'vitest'; +import { base64ToBytes } from '@/utils/encoding/base64ToBytes'; +import { bytesToBase64 } from '@/utils/encoding/bytesToBase64'; + +const UPLOAD_API_BASE64_PATTERN = /^[A-Za-z0-9+/]+={0,2}$/; + +describe('bytesToBase64', () => { + it('encodes RFC 4648 examples', () => { + expect(bytesToBase64(new Uint8Array([102]))).toBe('Zg=='); + expect(bytesToBase64(new Uint8Array([102, 111]))).toBe('Zm8='); + expect(bytesToBase64(new Uint8Array([102, 111, 111]))).toBe('Zm9v'); + }); + + it('encodes empty input', () => { + expect(bytesToBase64(new Uint8Array([]))).toBe(''); + }); + + it('roundtrips with base64ToBytes for random lengths', () => { + for (const length of [1, 2, 3, 4, 5, 12, 16, 32, 64, 1000]) { + const bytes = new Uint8Array(length); + + for (let i = 0; i < length; i++) { + bytes[i] = (i * 17 + length) % 256; + } + + expect(base64ToBytes(bytesToBase64(bytes))).toEqual(bytes); + } + }); + + it('produces strings accepted by the upload API base64 validator', () => { + const ivLike = new Uint8Array(12); + + crypto.getRandomValues(ivLike); + + const encoded = bytesToBase64(ivLike); + + expect(UPLOAD_API_BASE64_PATTERN.test(encoded)).toBe(true); + expect(base64ToBytes(encoded)).toEqual(ivLike); + }); +}); diff --git a/frontend/src/utils/encoding/bytesToBase64.ts b/frontend/src/utils/encoding/bytesToBase64.ts new file mode 100644 index 0000000..a594d9f --- /dev/null +++ b/frontend/src/utils/encoding/bytesToBase64.ts @@ -0,0 +1,5 @@ +export const bytesToBase64 = (bytes: Uint8Array): string => { + const binary = Array.from(bytes, byte => String.fromCharCode(byte)).join(''); + + return btoa(binary); +}; diff --git a/frontend/src/utils/encoding/bytesToBase64Url.spec.ts b/frontend/src/utils/encoding/bytesToBase64Url.spec.ts new file mode 100644 index 0000000..fa0f0a0 --- /dev/null +++ b/frontend/src/utils/encoding/bytesToBase64Url.spec.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from 'vitest'; +import { base64UrlToBytes } from '@/utils/encoding/base64UrlToBytes'; +import { bytesToBase64Url } from '@/utils/encoding/bytesToBase64Url'; + +const BASE64URL_PATTERN = /^[A-Za-z0-9_-]*$/; + +describe('bytesToBase64Url', () => { + it('uses only URL-safe characters and omits padding', () => { + const bytes = new Uint8Array([255, 254, 253, 252, 251, 250]); + + const encoded = bytesToBase64Url(bytes); + + expect(BASE64URL_PATTERN.test(encoded)).toBe(true); + expect(encoded.includes('+')).toBe(false); + expect(encoded.includes('/')).toBe(false); + expect(encoded.includes('=')).toBe(false); + }); + + it('roundtrips with base64UrlToBytes', () => { + for (const length of [0, 1, 2, 3, 4, 12, 16, 32, 64]) { + const bytes = new Uint8Array(length); + + crypto.getRandomValues(bytes); + + expect(base64UrlToBytes(bytesToBase64Url(bytes))).toEqual(bytes); + } + }); + + it('differs from standard base64 when URL encoding is required', () => { + const bytes = new Uint8Array([251, 255, 255]); + + expect(bytesToBase64Url(bytes)).not.toBe( + btoa(Array.from(bytes, b => String.fromCharCode(b)).join('')) + ); + }); +}); diff --git a/frontend/src/utils/encoding/bytesToBase64Url.ts b/frontend/src/utils/encoding/bytesToBase64Url.ts new file mode 100644 index 0000000..9ded57b --- /dev/null +++ b/frontend/src/utils/encoding/bytesToBase64Url.ts @@ -0,0 +1,11 @@ +import { bytesToBase64 } from '@/utils/encoding/bytesToBase64'; + +export const bytesToBase64Url = (bytes: Uint8Array): string => { + let base64 = bytesToBase64(bytes).split('+').join('-').split('/').join('_'); + + while (base64.endsWith('=')) { + base64 = base64.slice(0, -1); + } + + return base64; +}; diff --git a/frontend/src/utils/toArrayBuffer.ts b/frontend/src/utils/toArrayBuffer.ts index 981f6d2..83033c5 100644 --- a/frontend/src/utils/toArrayBuffer.ts +++ b/frontend/src/utils/toArrayBuffer.ts @@ -1,3 +1,5 @@ +export function toArrayBuffer(data: ArrayBuffer | Uint8Array): ArrayBuffer; +export function toArrayBuffer(data: unknown): ArrayBuffer | null; export function toArrayBuffer(data: unknown): ArrayBuffer | null { if (data instanceof ArrayBuffer) { return data; diff --git a/frontend/tsconfig.node.json b/frontend/tsconfig.node.json index c4ea6d9..f6f8420 100644 --- a/frontend/tsconfig.node.json +++ b/frontend/tsconfig.node.json @@ -16,5 +16,5 @@ "erasableSyntaxOnly": true, "noFallthroughCasesInSwitch": true }, - "include": ["vite.config.ts"] + "include": ["vite.config.ts", "vitest.config.ts"] } diff --git a/frontend/vitest.config.ts b/frontend/vitest.config.ts new file mode 100644 index 0000000..1716273 --- /dev/null +++ b/frontend/vitest.config.ts @@ -0,0 +1,17 @@ +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { defineConfig } from 'vitest/config'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + +export default defineConfig({ + resolve: { + alias: { + '@': path.resolve(__dirname, 'src') + } + }, + test: { + environment: 'node', + include: ['src/**/*.spec.ts'] + } +});