diff --git a/bun.lock b/bun.lock index 231b5ea..50968ac 100644 --- a/bun.lock +++ b/bun.lock @@ -62,6 +62,7 @@ "typescript": "^5.7.2", "vite": "^7.1.7", "vite-tsconfig-paths": "^5.1.4", + "vitest": "^4.1.11", "workbox-build": "^7.4.1", }, }, @@ -499,6 +500,8 @@ "@solid-primitives/utils": ["@solid-primitives/utils@6.3.2", "", { "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-hZ/M/qr25QOCcwDPOHtGjxTD8w2mNyVAYvcfgwzBHq2RwNqHNdDNsMZYap20+ruRwW4A3Cdkczyoz0TSxLCAPQ=="], + "@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], + "@tanstack/devtools": ["@tanstack/devtools@0.7.0", "", { "dependencies": { "@solid-primitives/event-listener": "^2.4.3", "@solid-primitives/keyboard": "^1.3.3", "@solid-primitives/resize-observer": "^2.1.3", "@tanstack/devtools-client": "0.0.3", "@tanstack/devtools-event-bus": "0.3.3", "@tanstack/devtools-ui": "0.4.4", "clsx": "^2.1.1", "goober": "^2.1.16", "solid-js": "^1.9.9" } }, "sha512-AlAoCqJhWLg9GBEaoV1g/j+X/WA1aJSWOsekxeuZpYeS2hdVuKAjj04KQLUMJhtLfNl2s2E+TCj7ZRtWyY3U4w=="], "@tanstack/devtools-client": ["@tanstack/devtools-client@0.0.3", "", { "dependencies": { "@tanstack/devtools-event-client": "^0.3.3" } }, "sha512-kl0r6N5iIL3t9gGDRAv55VRM3UIyMKVH83esRGq7xBjYsRLe/BeCIN2HqrlJkObUXQMKhy7i8ejuGOn+bDqDBw=="], @@ -631,6 +634,10 @@ "@types/bun": ["@types/bun@1.3.8", "", { "dependencies": { "bun-types": "1.3.8" } }, "sha512-3LvWJ2q5GerAXYxO2mffLTqOzEu5qnhEAlh48Vnu8WQfnmSwbgagjGZV6BoHKJztENYEDn6QmVd949W4uESRJA=="], + "@types/chai": ["@types/chai@5.2.3", "", { "dependencies": { "@types/deep-eql": "*", "assertion-error": "^2.0.1" } }, "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA=="], + + "@types/deep-eql": ["@types/deep-eql@4.0.2", "", {}, "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw=="], + "@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="], "@types/istanbul-lib-coverage": ["@types/istanbul-lib-coverage@2.0.6", "", {}, "sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w=="], @@ -665,6 +672,20 @@ "@vitejs/plugin-react": ["@vitejs/plugin-react@5.1.3", "", { "dependencies": { "@babel/core": "^7.29.0", "@babel/plugin-transform-react-jsx-self": "^7.27.1", "@babel/plugin-transform-react-jsx-source": "^7.27.1", "@rolldown/pluginutils": "1.0.0-rc.2", "@types/babel__core": "^7.20.5", "react-refresh": "^0.18.0" }, "peerDependencies": { "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, "sha512-NVUnA6gQCl8jfoYqKqQU5Clv0aPw14KkZYCsX6T9Lfu9slI0LOU10OTwFHS/WmptsMMpshNd/1tuWsHQ2Uk+cg=="], + "@vitest/expect": ["@vitest/expect@4.1.11", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", "@vitest/spy": "4.1.11", "@vitest/utils": "4.1.11", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" } }, "sha512-VX2x5vNJXET47KAFzwERI+KRMtTTCSWTfSMKsW7JsUsXV4psq++e3DvZpuTDOpHcxytiDs6p2nhVb2tVDiiUYw=="], + + "@vitest/mocker": ["@vitest/mocker@4.1.11", "", { "dependencies": { "@vitest/spy": "4.1.11", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["msw", "vite"] }, "sha512-2XJVD55d1o5AZous5CCGKS74g/riOj9odEt2bQpCVZeblHyHdnMeFl4jl0XjU21stf4mbjUkew2eXQZt65g5CQ=="], + + "@vitest/pretty-format": ["@vitest/pretty-format@4.1.11", "", { "dependencies": { "tinyrainbow": "^3.1.0" } }, "sha512-yiZzPbGTS9Sr/JpFl8zHrcIkAofNbFV6k21vIgQN/cY/oxZeXhJv5sc/MBJ5jFKWmWs+oJHw0UXLZjmf931+Vw=="], + + "@vitest/runner": ["@vitest/runner@4.1.11", "", { "dependencies": { "@vitest/utils": "4.1.11", "pathe": "^2.0.3" } }, "sha512-LztvUgdwMNJMIkj3hQnnxiC2Xy1zNxq928W/xhjCLaNCzqTZOudjwbQf6v9IntZGPw132i2Lq2rgTRZHD3JHNw=="], + + "@vitest/snapshot": ["@vitest/snapshot@4.1.11", "", { "dependencies": { "@vitest/pretty-format": "4.1.11", "@vitest/utils": "4.1.11", "magic-string": "^0.30.21", "pathe": "^2.0.3" } }, "sha512-pN7ikn1ON7h8ee4gIAp4AzyK+zBtJPzVbqOgu5LCEh4VaJVbPQcgYQYJIMGQPXVeJJq1fnfazis7a5pFNPahog=="], + + "@vitest/spy": ["@vitest/spy@4.1.11", "", {}, "sha512-apNa/prQy2qCeywhnixOHPRCgGNhvg7T4Dapfl1GahLp/R+uhBm5cPyFoNVyqsNd2h1nJxL6BqqdIjiABL60YA=="], + + "@vitest/utils": ["@vitest/utils@4.1.11", "", { "dependencies": { "@vitest/pretty-format": "4.1.11", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" } }, "sha512-zTCVGpyFsGWBhllOyKlTw/vnr6D9qxsfSDyfbyZmTyjHw5N/VuvzHpHoQjm2ZJzn4RJgx5w4r7V0er69CmLgPQ=="], + "acorn": ["acorn@8.15.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg=="], "agent-base": ["agent-base@6.0.2", "", { "dependencies": { "debug": "4" } }, "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ=="], @@ -687,6 +708,8 @@ "asn1.js": ["asn1.js@5.4.1", "", { "dependencies": { "bn.js": "^4.0.0", "inherits": "^2.0.1", "minimalistic-assert": "^1.0.0", "safer-buffer": "^2.1.0" } }, "sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA=="], + "assertion-error": ["assertion-error@2.0.1", "", {}, "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA=="], + "async": ["async@3.2.6", "", {}, "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA=="], "async-function": ["async-function@1.0.0", "", {}, "sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA=="], @@ -749,6 +772,8 @@ "caniuse-lite": ["caniuse-lite@1.0.30001769", "", {}, "sha512-BCfFL1sHijQlBGWBMuJyhZUhzo7wer5sVj9hqekB/7xn0Ypy+pER/edCYQm4exbXj4WiySGp40P8UuTh6w1srg=="], + "chai": ["chai@6.2.2", "", {}, "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg=="], + "chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="], "chokidar": ["chokidar@5.0.0", "", { "dependencies": { "readdirp": "^5.0.0" } }, "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw=="], @@ -855,6 +880,8 @@ "es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="], + "es-module-lexer": ["es-module-lexer@2.3.2", "", {}, "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw=="], + "es-object-atoms": ["es-object-atoms@1.1.1", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA=="], "es-set-tostringtag": ["es-set-tostringtag@2.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6", "has-tostringtag": "^1.0.2", "hasown": "^2.0.2" } }, "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA=="], @@ -867,12 +894,14 @@ "escape-string-regexp": ["escape-string-regexp@4.0.0", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="], - "estree-walker": ["estree-walker@2.0.2", "", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="], + "estree-walker": ["estree-walker@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.0" } }, "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g=="], "esutils": ["esutils@2.0.3", "", {}, "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g=="], "eta": ["eta@4.6.0", "", {}, "sha512-lW6is4T1NFOYnmqGZIfvixqj7A7sSvScF+DN8EK6K58xI5MZ5UvYe0GjopxOXQtZvUn4eDdVuZ8XSoYWTMEKwA=="], + "expect-type": ["expect-type@1.4.0", "", {}, "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA=="], + "exsolve": ["exsolve@1.0.8", "", {}, "sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA=="], "facehash": ["facehash@0.0.7", "", { "peerDependencies": { "@types/react": "", "next": ">=15", "react": ">=18 <20", "react-dom": ">=18 <20" }, "optionalPeers": ["@types/react", "next"] }, "sha512-P4fw6z5DIGMbjtqEaOw7fYvYpQetSOSJOfqy3xuET7cDUI6f9CKlSX0UZIYNrtsPpCoz3LoPP5E8bNbpZBP30A=="], @@ -1189,6 +1218,8 @@ "object.assign": ["object.assign@4.1.7", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.3", "define-properties": "^1.2.1", "es-object-atoms": "^1.0.0", "has-symbols": "^1.1.0", "object-keys": "^1.1.1" } }, "sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw=="], + "obug": ["obug@2.1.4", "", {}, "sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA=="], + "onetime": ["onetime@7.0.0", "", { "dependencies": { "mimic-function": "^5.0.0" } }, "sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ=="], "ora": ["ora@9.4.1", "", { "dependencies": { "chalk": "^5.6.2", "cli-cursor": "^5.0.0", "cli-spinners": "^3.2.0", "is-interactive": "^2.0.0", "is-unicode-supported": "^2.1.0", "log-symbols": "^7.0.1", "stdin-discarder": "^0.3.2", "string-width": "^8.1.0" } }, "sha512-6VlU9MLXbjVQD04AZCMX28hVtA5bUoadvUqO76MUCVA0ilwJbMiHsITRPfyVm6p/BC0Av/BXMujx39WCe1LEqw=="], @@ -1397,6 +1428,8 @@ "side-channel-weakmap": ["side-channel-weakmap@1.0.2", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3", "side-channel-map": "^1.0.1" } }, "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A=="], + "siginfo": ["siginfo@2.0.0", "", {}, "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g=="], + "signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="], "smob": ["smob@1.6.2", "", {}, "sha512-RQsvleCbF8cVHEv+xuDGaA4pOizFqJ0GgjtMSRo6oP8pnN7WsigHgVGey6aILRBKv4W2YOMHLqbKdnB6hpB9fw=="], @@ -1413,6 +1446,10 @@ "srvx": ["srvx@0.11.22", "", { "bin": { "srvx": "bin/srvx.mjs" } }, "sha512-LqZxxBDMKuMAZzFzJnDCkFOrs9MZQZr0LvHiO/SuSZVdQaXD7xQ5UWTUxheJrQPve1qk9MG2B/yttUvJxw8egQ=="], + "stackback": ["stackback@0.0.2", "", {}, "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw=="], + + "std-env": ["std-env@4.2.0", "", {}, "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw=="], + "stdin-discarder": ["stdin-discarder@0.3.2", "", {}, "sha512-eCPu1qRxPVkl5605OTWF8Wz40b4Mf45NY5LQmVPQ599knfs5QhASUm9GbJ5BDMDOXgrnh0wyEdvzmL//YMlw0A=="], "stop-iteration-iterator": ["stop-iteration-iterator@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "internal-slot": "^1.1.0" } }, "sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ=="], @@ -1457,10 +1494,16 @@ "tiny-invariant": ["tiny-invariant@1.3.3", "", {}, "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg=="], + "tinybench": ["tinybench@2.9.0", "", {}, "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg=="], + + "tinyexec": ["tinyexec@1.3.0", "", {}, "sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ=="], + "tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="], "tinypool": ["tinypool@2.1.0", "", {}, "sha512-Pugqs6M0m7Lv1I7FtxN4aoyToKg1C4tu+/381vH35y8oENM/Ai7f7C4StcoK4/+BSw9ebcS8jRiVrORFKCALLw=="], + "tinyrainbow": ["tinyrainbow@3.1.1", "", {}, "sha512-yau8yJdTt989Mm0Bd/236QnzEiPf2xLLTqUZRUJOo/3CB078LSwzei343DgtJVmfJKJE3TMINY1u42SQsP6mXw=="], + "tldts": ["tldts@6.1.86", "", { "dependencies": { "tldts-core": "^6.1.86" }, "bin": { "tldts": "bin/cli.js" } }, "sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ=="], "tldts-core": ["tldts-core@6.1.86", "", {}, "sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA=="], @@ -1541,6 +1584,8 @@ "vitefu": ["vitefu@1.1.1", "", { "peerDependencies": { "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0-beta.0" }, "optionalPeers": ["vite"] }, "sha512-B/Fegf3i8zh0yFbpzZ21amWzHmuNlLlmJT6n7bu5e+pCHUKQIfXSYokrqOBGEMMe9UG2sostKQF9mml/vYaWJQ=="], + "vitest": ["vitest@4.1.11", "", { "dependencies": { "@vitest/expect": "4.1.11", "@vitest/mocker": "4.1.11", "@vitest/pretty-format": "4.1.11", "@vitest/runner": "4.1.11", "@vitest/snapshot": "4.1.11", "@vitest/spy": "4.1.11", "@vitest/utils": "4.1.11", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", "obug": "^2.1.1", "pathe": "^2.0.3", "picomatch": "^4.0.3", "std-env": "^4.0.0-rc.1", "tinybench": "^2.9.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", "tinyrainbow": "^3.1.0", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", "@vitest/browser-playwright": "4.1.11", "@vitest/browser-preview": "4.1.11", "@vitest/browser-webdriverio": "4.1.11", "@vitest/coverage-istanbul": "4.1.11", "@vitest/coverage-v8": "4.1.11", "@vitest/ui": "4.1.11", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@opentelemetry/api", "@types/node", "@vitest/browser-playwright", "@vitest/browser-preview", "@vitest/browser-webdriverio", "@vitest/coverage-istanbul", "@vitest/coverage-v8", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "./vitest.mjs" } }, "sha512-fhACrNXUidIbGSBr5FlbuBkO7VWC1ZyLl0DO4CU2DrQoAPxX84Ysxs+HeGQpii5lZWV1Q4gBZTTu49mF+A6Edw=="], + "w3c-keyname": ["w3c-keyname@2.2.8", "", {}, "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ=="], "web-push": ["web-push@3.6.7", "", { "dependencies": { "asn1.js": "^5.3.0", "http_ece": "1.2.0", "https-proxy-agent": "^7.0.0", "jws": "^4.0.0", "minimist": "^1.2.5" }, "bin": { "web-push": "src/cli.js" } }, "sha512-OpiIUe8cuGjrj3mMBFWY+e4MMIkW3SVT+7vEIjvD9kejGUypv8GPDf84JdPWskK8zMRIJ6xYGm+Kxr8YkPyA0A=="], @@ -1561,6 +1606,8 @@ "which-typed-array": ["which-typed-array@1.1.22", "", { "dependencies": { "available-typed-arrays": "^1.0.7", "call-bind": "^1.0.9", "call-bound": "^1.0.4", "for-each": "^0.3.5", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-tostringtag": "^1.0.2" } }, "sha512-fvO4ExWMFsqyhG3AiPAObMuY1lxaqgYcxbc49CNdWDDECOJNgQyvsOWVwbZc+qf3rzRtxojBK+CMEv0Ld5CYpw=="], + "why-is-node-running": ["why-is-node-running@2.3.0", "", { "dependencies": { "siginfo": "^2.0.0", "stackback": "0.0.2" }, "bin": { "why-is-node-running": "cli.js" } }, "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w=="], + "workbox-background-sync": ["workbox-background-sync@7.4.1", "", { "dependencies": { "idb": "^7.0.1", "workbox-core": "7.4.1" } }, "sha512-HhT7KE8tOWDm02wRNshXUnUPofMlhenF2DBdUnDPOubhizzPeItkYTmAB6td1Z2cjYPa98vzEiPLEuzn5hN66g=="], "workbox-broadcast-update": ["workbox-broadcast-update@7.4.1", "", { "dependencies": { "workbox-core": "7.4.1" } }, "sha512-uAlgslKLvbQY+suirIdnBCSYrcgBhjp81Nj4l1lj/Jmj0MJO2CJERnCJjT0GFVwmReV0N+zs78K6gqd5gr9/+A=="], @@ -1817,6 +1864,8 @@ "@jest/types/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], + "@rollup/pluginutils/estree-walker": ["estree-walker@2.0.2", "", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="], + "@rollup/pluginutils/picomatch": ["picomatch@4.0.5", "", {}, "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A=="], "@tanstack/router-generator/@babel/types": ["@babel/types@7.29.0", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A=="], @@ -1907,6 +1956,8 @@ "unplugin/picomatch": ["picomatch@4.0.5", "", {}, "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A=="], + "vitest/picomatch": ["picomatch@4.0.5", "", {}, "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A=="], + "web-push/https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="], "workbox-build/source-map": ["source-map@0.8.0-beta.0", "", { "dependencies": { "whatwg-url": "^7.0.0" } }, "sha512-2ymg6oRBpebeZi9UUNsgQ89bhx01TcTkmNTGnNO88imTmbSgy4nfujrgVEFKWpMTEGA11EDkTt7mqObTPdigIA=="], diff --git a/package.json b/package.json index 8b8ae3a..c14bbab 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,8 @@ "extract": "lingui extract --clean", "i18n:check": "lingui extract --clean && lingui compile --strict", "build": "lingui extract --clean && vite build && tsc --noEmit && bun scripts/generate-sw.mjs", - "start": "bun run server.ts" + "start": "bun run server.ts", + "test": "vitest run" }, "dependencies": { "@hello-pangea/dnd": "^18.0.1", @@ -73,6 +74,7 @@ "typescript": "^5.7.2", "vite": "^7.1.7", "vite-tsconfig-paths": "^5.1.4", + "vitest": "^4.1.11", "workbox-build": "^7.4.1" } } diff --git a/src/features/badges/server.ts b/src/features/badges/server.ts index 1b9ad54..fbaaba0 100644 --- a/src/features/badges/server.ts +++ b/src/features/badges/server.ts @@ -1,5 +1,6 @@ import { toServerResult } from "@/lib/tanstack-query/utils/to-server-result"; import { superTokensAdminFunctionMiddleware, superTokensFunctionMiddleware } from "@/utils/supertokens"; +import { serverFnLoggingMiddleware } from "@/utils/activities"; import { createServerFn } from "@tanstack/react-start"; import { pbAdmin } from "@/lib/pocketbase/client"; import { z } from "zod"; @@ -13,7 +14,7 @@ export const getPlayerBadges = createServerFn() ); export const migrateBadgeProgress = createServerFn() - .middleware([superTokensAdminFunctionMiddleware]) + .middleware([superTokensAdminFunctionMiddleware, serverFnLoggingMiddleware]) .handler(async () => toServerResult(async () => { const result = await pbAdmin.migrateBadgeProgress(); @@ -37,7 +38,7 @@ export const awardManualBadge = createServerFn() playerId: z.string(), badgeId: z.string(), })) - .middleware([superTokensAdminFunctionMiddleware]) + .middleware([superTokensAdminFunctionMiddleware, serverFnLoggingMiddleware]) .handler(async ({ data }) => toServerResult(async () => { const result = await pbAdmin.awardManualBadge(data.playerId, data.badgeId); diff --git a/src/features/players/server.ts b/src/features/players/server.ts index 6d7ea38..3893183 100644 --- a/src/features/players/server.ts +++ b/src/features/players/server.ts @@ -84,7 +84,7 @@ export const updatePlayer = createServerFn() export const createPlayer = createServerFn() .validator(playerInputSchema) - .middleware([superTokensFunctionMiddleware]) + .middleware([superTokensFunctionMiddleware, serverFnLoggingMiddleware]) .handler(async ({ context, data }) => toServerResult(async () => { const userAuthId = context.userAuthId; diff --git a/src/features/tournaments/server.ts b/src/features/tournaments/server.ts index e7f393b..72e4230 100644 --- a/src/features/tournaments/server.ts +++ b/src/features/tournaments/server.ts @@ -148,7 +148,7 @@ export const generateRandomTeams = createServerFn() tournamentId: z.string(), seed: z.number().optional() })) - .middleware([superTokensAdminFunctionMiddleware]) + .middleware([superTokensAdminFunctionMiddleware, serverFnLoggingMiddleware]) .handler(async ({ data }) => toServerResult(async () => { const freeAgents = await pbAdmin.getFreeAgents(data.tournamentId); diff --git a/src/lib/pocketbase/services/activities.ts b/src/lib/pocketbase/services/activities.ts index c2a4434..a6d9bfb 100644 --- a/src/lib/pocketbase/services/activities.ts +++ b/src/lib/pocketbase/services/activities.ts @@ -1,5 +1,7 @@ import PocketBase from "pocketbase"; import { PlayerInfo } from "@/features/players/types"; +import { pbFilter } from "../util/filter"; +import { likePattern } from "../util/like-pattern"; export interface Activity { id: string; @@ -61,15 +63,15 @@ export function createActivitiesService(pb: PocketBase) { const filters: string[] = []; if (name) { - filters.push(`name ~ "${name}"`); + filters.push(pbFilter(pb, "name ~ {:name}", { name: likePattern(name) })); } if (player) { - filters.push(`player = "${player}"`); + filters.push(pbFilter(pb, "player = {:player}", { player })); } if (success !== undefined) { - filters.push(`success = ${success}`); + filters.push(pbFilter(pb, "success = {:success}", { success })); } const filterString = filters.length > 0 ? filters.join(" && ") : ""; @@ -98,7 +100,7 @@ export function createActivitiesService(pb: PocketBase) { async getActivitiesByUser(userId: string, limit: number = 50): Promise { const result = await pb.collection("activities").getList(1, limit, { - filter: `player = "${userId}"`, + filter: pbFilter(pb, "player = {:userId}", { userId }), sort: "-created", }); return result.items; @@ -106,7 +108,7 @@ export function createActivitiesService(pb: PocketBase) { async getActivitiesByFunction(functionName: string, limit: number = 50): Promise { const result = await pb.collection("activities").getList(1, limit, { - filter: `name = "${functionName}"`, + filter: pbFilter(pb, "name = {:functionName}", { functionName }), sort: "-created", }); return result.items; diff --git a/src/lib/pocketbase/util/filter.test.ts b/src/lib/pocketbase/util/filter.test.ts new file mode 100644 index 0000000..bce9efe --- /dev/null +++ b/src/lib/pocketbase/util/filter.test.ts @@ -0,0 +1,118 @@ +import PocketBase from "pocketbase"; +import { describe, expect, it } from "vitest"; +import { pbFilter } from "./filter"; + +// Real SDK, not a stub: the escaping under test lives in PocketBase's own +// replaceAll. +const pb = new PocketBase("http://pocketbase.test"); + +const quotedLiteral = (expression: string) => { + const open = expression.indexOf("'"); + let literal = ""; + for (let i = open + 1; i < expression.length; i++) { + if (expression[i] === "\\") { + literal += expression[i + 1]; + i++; + continue; + } + if (expression[i] === "'") return literal; + literal += expression[i]; + } + throw new Error(`unterminated literal in ${expression}`); +}; + +describe("pbFilter", () => { + it.each([ + ["a match-substitution pattern", "a$&b"], + ["a preceding-input pattern", "a$`b"], + ["a following-input pattern", "a$'b"], + ["an escaped-dollar pattern", "a$$b"], + ["a bare dollar", "a$b"], + ["nothing but a dollar", "$"], + ["a dollar beside a metacharacter", "50$%"], + ])("substitutes %s as a literal", (_label, term) => { + const expression = pbFilter(pb, "first_name ~ {:query}", { query: term }); + + expect(quotedLiteral(expression)).toBe(term); + expect(expression).not.toContain("{:query}"); + }); + + it("keeps both dollars of an escaped-dollar pattern", () => { + expect(pbFilter(pb, "f ~ {:q}", { q: "a$$b" })).toBe("f ~ 'a$$b'"); + }); + + it("splices no part of the surrounding expression into the literal", () => { + const expression = pbFilter(pb, "first_name ~ {:query}", { + query: "a$`b", + }); + + expect(expression).toBe("first_name ~ 'a$`b'"); + expect(quotedLiteral(expression)).not.toContain("first_name"); + }); + + it.each([ + ["a single quote", "o'brien"], + ["a trailing backslash", "ada\\"], + ["a percent sign", "50%"], + ])("escapes %s exactly as the SDK does", (_label, term) => { + expect(pbFilter(pb, "f ~ {:q}", { q: term })).toBe( + pb.filter("f ~ {:q}", { q: term }) + ); + }); + + it.each([ + ["a number", 42], + ["a boolean", true], + ["null", null], + ["a date", new Date(Date.UTC(2024, 0, 2, 3, 4, 5, 678))], + ])("renders %s identically to pb.filter", (_label, value) => { + expect(pbFilter(pb, "f = {:v}", { v: value })).toBe( + pb.filter("f = {:v}", { v: value }) + ); + }); + + it("leaves a placeholder with no matching parameter verbatim", () => { + expect(pbFilter(pb, "a = {:missing}", {})).toBe("a = {:missing}"); + expect(pbFilter(pb, "a = {:missing}", {})).toBe( + pb.filter("a = {:missing}", {}) + ); + }); + + it.each([ + ["a hyphen", "user-id"], + ["a dot", "meta.user"], + ["a digit and underscore", "auth_id2"], + ])("resolves a key containing %s the way the SDK does", (_label, key) => { + const raw = `a = {:${key}}`; + + expect(pbFilter(pb, raw, { [key]: "v" })).toBe("a = 'v'"); + expect(pbFilter(pb, raw, { [key]: "v" })).toBe(pb.filter(raw, { [key]: "v" })); + }); + + it("does not absorb another parameter's value into a literal", () => { + expect( + pbFilter(pb, "a = {:x} && b = {:y}", { x: "%{:y}%", y: "SECRET" }) + ).toBe("a = '%{:y}%' && b = 'SECRET'"); + }); + + it("substitutes every occurrence of a repeated placeholder", () => { + expect( + pbFilter(pb, "(first_name ~ {:q} || last_name ~ {:q})", { q: "%ada%" }) + ).toBe("(first_name ~ '%ada%' || last_name ~ '%ada%')"); + }); +}); + +// Pins the SDK behaviour the doubling in `quote` compensates for. If an upgrade +// fixes the expansion upstream, these fail rather than the doubling silently +// becoming a double-escape. +describe("the pocketbase SDK substitution this works around", () => { + it("still mis-expands a dollar pattern in a parameter value", () => { + expect(pb.filter("a ~ {:q}", { q: "x$&y" })).toContain("{:q}"); + }); + + it("still splices a later parameter into an earlier literal", () => { + expect(pb.filter("a = {:x} && b = {:y}", { x: "%{:y}%", y: "SECRET" })).toBe( + "a = '%'SECRET'%' && b = 'SECRET'" + ); + }); +}); diff --git a/src/lib/pocketbase/util/filter.ts b/src/lib/pocketbase/util/filter.ts new file mode 100644 index 0000000..7cf03a3 --- /dev/null +++ b/src/lib/pocketbase/util/filter.ts @@ -0,0 +1,28 @@ +import type PocketBase from "pocketbase"; + +export type FilterParam = string | number | boolean | Date | null; + +// Any key the SDK's own `replaceAll("{:" + key + "}", …)` loop would substitute, +// so which keys resolve does not depend on the characters they are spelled with. +const PLACEHOLDER = /\{:([^}]+)\}/g; + +// `pb.filter` substitutes with String.replaceAll and a *string* replacement, so +// `$&`, `` $` ``, `$'` and `$$` inside a value are expanded as replacement +// patterns: the value's own text, or a slice of the surrounding expression, +// gets spliced into the quoted literal. Doubling every `$` first collapses back +// to the exact literal inside that same replaceAll. The FilterParam union is +// load-bearing — it keeps objects and arrays out of the SDK's JSON.stringify +// branch, which would reintroduce an undoubled `$`. +const quote = (pb: PocketBase, value: FilterParam) => + pb.filter("{:v}", { + v: typeof value === "string" ? value.replaceAll("$", () => "$$") : value, + }); + +export const pbFilter = ( + pb: PocketBase, + raw: string, + params: Record +) => + raw.replace(PLACEHOLDER, (token, key: string) => + Object.hasOwn(params, key) ? quote(pb, params[key]) : token + ); diff --git a/src/lib/pocketbase/util/like-pattern.test.ts b/src/lib/pocketbase/util/like-pattern.test.ts new file mode 100644 index 0000000..d62ce91 --- /dev/null +++ b/src/lib/pocketbase/util/like-pattern.test.ts @@ -0,0 +1,69 @@ +import PocketBase from "pocketbase"; +import { describe, expect, it } from "vitest"; +import { literalTerminates } from "@/test/pb-filter"; +import { pbFilter } from "./filter"; +import { likePattern } from "./like-pattern"; + +const compose = (term: string) => + pbFilter(new PocketBase("http://pocketbase.test"), "first_name ~ {:query}", { + query: likePattern(term), + }); + +describe("likePattern", () => { + it("wraps a plain term so ~ matches a substring", () => { + expect(likePattern("ada")).toBe("%ada%"); + }); + + it.each([ + ["a percent sign", "50%", "%50\\%%"], + ["an underscore", "_", "%\\_%"], + ["a backslash", "ada\\", "%ada\\\\%"], + ])("escapes %s so it matches literally", (_label, term, expected) => { + expect(likePattern(term)).toBe(expected); + }); + + it("always ends the operand with an unescaped wildcard", () => { + // An odd run of backslashes before the final % would mean the % is itself + // escaped, which is the shape that swallows the closing quote. + for (const term of ["ada", "ada\\", "\\", "50%", "_", "o'brien\\"]) { + const pattern = likePattern(term); + const trailingSlashes = /(\\*)%$/.exec(pattern)?.[1] ?? ""; + + expect(pattern.endsWith("%")).toBe(true); + expect(trailingSlashes.length % 2).toBe(0); + } + }); +}); + +describe("likePattern composed through the real SDK", () => { + it.each(["ada\\", "\\", "a_b\\", "o'brien\\", "ada\\\\"])( + "keeps the filter expression parseable for %j", + (term) => { + expect(literalTerminates(compose(term))).toBe(true); + } + ); + + it("detects the unterminated literal a raw term produces", () => { + const raw = new PocketBase("http://pocketbase.test").filter( + "first_name ~ {:query}", + { query: "ada\\" } + ); + + expect(raw).toBe("first_name ~ 'ada\\'"); + expect(literalTerminates(raw)).toBe(false); + }); + + it("leaves a term without metacharacters exactly as before", () => { + expect(compose("ada")).toBe("first_name ~ '%ada%'"); + }); + + it.each(["a$&b", "a$`b", "50$%", "a$&_b", "$"])( + "carries %j through both escaping layers intact", + (term) => { + const composed = compose(term); + + expect(composed).toContain(likePattern(term)); + expect(literalTerminates(composed)).toBe(true); + } + ); +}); diff --git a/src/lib/pocketbase/util/like-pattern.ts b/src/lib/pocketbase/util/like-pattern.ts new file mode 100644 index 0000000..4e21e29 --- /dev/null +++ b/src/lib/pocketbase/util/like-pattern.ts @@ -0,0 +1,10 @@ +// `pb.filter()` escapes single quotes and nothing else, so a term ending in a +// backslash escapes the closing quote of the literal it is substituted into and +// PocketBase rejects the whole expression with 400 validation_invalid_filter. +// Escaping `\ % _` and appending the wildcards here keeps the operand's last +// character a literal `%`, and makes `~` an unconditional substring match: +// PocketBase only auto-wraps (and only auto-escapes) operands that contain no +// `%` of their own, so a term carrying one would otherwise silently become a +// prefix match, and a bare `_` would match every row. +export const likePattern = (term: string) => + `%${term.replace(/[\\%_]/g, (char) => `\\${char}`)}%`; diff --git a/src/lib/redact.test.ts b/src/lib/redact.test.ts new file mode 100644 index 0000000..f4ab62e --- /dev/null +++ b/src/lib/redact.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from "vitest"; +import { redactValue, SENSITIVE_KEY } from "./redact"; + +// Shared scrubber for logs + audit rows - a regression leaks PII from both. +describe("redactValue", () => { + it("redacts sensitive keys and preserves benign ones", () => { + expect( + redactValue({ phone: "+17135550142", first_name: "Ada", token: "abc" }) + ).toEqual({ phone: "[redacted]", first_name: "Ada", token: "[redacted]" }); + }); + + it("redacts recursively through nested objects and arrays", () => { + expect( + redactValue({ + user: { first_name: "Ada", password: "hunter2" }, + items: [{ authToken: "x" }, { label: "ok" }], + }) + ).toEqual({ + user: { first_name: "Ada", password: "[redacted]" }, + items: [{ authToken: "[redacted]" }, { label: "ok" }], + }); + }); + + it("passes primitives through untouched (a bare string is not assumed secret)", () => { + expect(redactValue("hello")).toBe("hello"); + expect(redactValue(42)).toBe(42); + expect(redactValue(null)).toBeNull(); + }); + + it("covers the documented sensitive keys", () => { + for (const key of ["token", "secret", "password", "phone", "otp", "code", "auth", "key"]) { + expect(SENSITIVE_KEY.test(key)).toBe(true); + } + expect(SENSITIVE_KEY.test("first_name")).toBe(false); + }); +}); diff --git a/src/lib/redact.ts b/src/lib/redact.ts new file mode 100644 index 0000000..b97f07b --- /dev/null +++ b/src/lib/redact.ts @@ -0,0 +1,21 @@ +// Shared redaction for logs + audit rows so the two never drift. +export const SENSITIVE_KEY = /token|secret|password|phone|otp|code|auth|key/i; + +const REDACTED = "[redacted]"; + +// Deep-redact: keys matching SENSITIVE_KEY become "[redacted]"; primitives pass through. +export const redactValue = (value: unknown): unknown => { + if (Array.isArray(value)) return value.map(redactValue); + if (value && typeof value === "object") { + // Keep Error serializable. + if (value instanceof Error) { + return { name: value.name, message: value.message, stack: value.stack }; + } + const out: Record = {}; + for (const [key, v] of Object.entries(value as Record)) { + out[key] = SENSITIVE_KEY.test(key) ? REDACTED : redactValue(v); + } + return out; + } + return value; +}; diff --git a/src/lib/tanstack-query/utils/to-server-result.ts b/src/lib/tanstack-query/utils/to-server-result.ts index fc1ea9b..b4af883 100644 --- a/src/lib/tanstack-query/utils/to-server-result.ts +++ b/src/lib/tanstack-query/utils/to-server-result.ts @@ -1,6 +1,5 @@ import { logger } from "../../logger"; import { ErrorType, ServerError, ServerResult } from "../types"; -import { getRequest } from "@tanstack/react-start/server"; import { isRedirect } from "@tanstack/react-router"; export const createServerError = ( @@ -17,57 +16,20 @@ export const createServerError = ( context, }); +// Audit rows are written by serverFnLoggingMiddleware, which reads the +// returned envelope's success flag — never write them here. export const toServerResult = async ( serverFn: () => Promise ): Promise> => { - const startTime = Date.now(); - try { const data = await serverFn(); return { success: true, data }; } catch (error) { if (isRedirect(error) || error instanceof Response) throw error; - const duration = Date.now() - startTime; logger.error('Server Fn Error', error); - const mappedError = mapKnownError(error); - - let fnName = 'unknown'; - try { - const request = getRequest(); - const url = new URL(request.url); - - const functionId = url.searchParams.get('_serverFnId') || url.pathname; - - if (functionId.includes('--')) { - const match = functionId.match(/--([^_]+)_/); - fnName = match?.[1] || functionId.split('--')[1]?.split('_')[0] || 'unknown'; - } else { - fnName = serverFn.name || 'unknown'; - } - } catch { - fnName = serverFn.name || 'unknown'; - } - - import("../../pocketbase/client") - .then(async ({ pbAdmin }) => { - await pbAdmin.authPromise; - await pbAdmin.createActivity({ - name: fnName, - duration, - success: false, - error: mappedError.message, - arguments: { - errorType: mappedError.code, - statusCode: mappedError.statusCode, - userMessage: mappedError.userMessage, - }, - }); - }) - .catch(() => {}); - - return { success: false, error: mappedError }; + return { success: false, error: mapKnownError(error) }; } }; diff --git a/src/test/pb-filter.ts b/src/test/pb-filter.ts new file mode 100644 index 0000000..0ec1a87 --- /dev/null +++ b/src/test/pb-filter.ts @@ -0,0 +1,14 @@ +// Mirrors PocketBase's own scan of a quoted literal: a backslash consumes the +// character after it, so an unterminated literal is exactly what a trailing +// backslash produces. +export const literalTerminates = (expression: string) => { + const open = expression.indexOf("'"); + for (let i = open + 1; i < expression.length; i++) { + if (expression[i] === "\\") { + i++; + continue; + } + if (expression[i] === "'") return true; + } + return false; +}; diff --git a/src/utils/activities.test.ts b/src/utils/activities.test.ts new file mode 100644 index 0000000..10c9149 --- /dev/null +++ b/src/utils/activities.test.ts @@ -0,0 +1,226 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const h = vi.hoisted(() => ({ + request: undefined as { url: string; headers: Headers } | undefined, + createActivity: vi.fn(), +})); + +vi.mock("@tanstack/react-start/server", () => ({ + getRequest: () => h.request, +})); + +vi.mock("@/lib/pocketbase/client", () => ({ + pbAdmin: { authPromise: Promise.resolve(), createActivity: h.createActivity }, +})); + +vi.mock("@/lib/logger", () => ({ + Logger: class { + error() {} + info() {} + }, +})); + +import { recordDeniedServerFn, serverFnLoggingMiddleware } from "./activities"; + +type ServerHandler = (opts: { + next: () => Promise; + data: unknown; + context: unknown; + serverFnMeta?: { id: string; name?: string; filename?: string }; +}) => Promise; + +const runMiddleware = (opts: Parameters[0]) => + (serverFnLoggingMiddleware as any).options.server(opts) as ReturnType; + +const setRequest = (url: string, userAgent = "vitest") => { + h.request = { url, headers: new Headers({ "user-agent": userAgent }) }; +}; + +const successEnvelope = { result: { success: true, data: {} } }; + +beforeEach(() => { + h.createActivity.mockReset(); +}); + +describe("serverFnLoggingMiddleware", () => { + it("records the source name from the compile-time meta, not the hashed url segment", async () => { + const hashedId = "a".repeat(64); + setRequest(`http://localhost:3000/_serverFn/${hashedId}`); + + await runMiddleware({ + next: async () => successEnvelope, + data: undefined, + context: { metadata: { player_id: "p1" } }, + serverFnMeta: { + id: hashedId, + name: "updatePlayer", + filename: "src/features/players/server.ts", + }, + }); + + await vi.waitFor(() => expect(h.createActivity).toHaveBeenCalledTimes(1)); + expect(h.createActivity.mock.calls[0][0]).toMatchObject({ + name: "updatePlayer", + player: "p1", + success: true, + user_agent: "vitest", + }); + }); + + it("falls back to the last path segment when the meta carries no name", async () => { + setRequest("http://localhost:3000/_serverFn/deadbeef"); + + await runMiddleware({ + next: async () => successEnvelope, + data: undefined, + context: {}, + serverFnMeta: { id: "deadbeef" }, + }); + + await vi.waitFor(() => expect(h.createActivity).toHaveBeenCalledTimes(1)); + expect(h.createActivity.mock.calls[0][0].name).toBe("deadbeef"); + }); + + it("falls back to the last path segment when there is no meta at all", async () => { + setRequest("http://localhost:3000/_serverFn/legacySegment"); + + await runMiddleware({ + next: async () => successEnvelope, + data: undefined, + context: {}, + }); + + await vi.waitFor(() => expect(h.createActivity).toHaveBeenCalledTimes(1)); + expect(h.createActivity.mock.calls[0][0].name).toBe("legacySegment"); + }); + + it("records no player when the session has no player_id", async () => { + setRequest("http://localhost:3000/_serverFn/doThing"); + + await runMiddleware({ + next: async () => successEnvelope, + data: undefined, + context: {}, + }); + + await vi.waitFor(() => expect(h.createActivity).toHaveBeenCalledTimes(1)); + expect(h.createActivity.mock.calls[0][0].player).toBeUndefined(); + }); + + it("falls back to 'unknown' when the path has no trailing segment", async () => { + setRequest("http://localhost:3000/"); + + await runMiddleware({ + next: async () => successEnvelope, + data: undefined, + context: {}, + }); + + await vi.waitFor(() => expect(h.createActivity).toHaveBeenCalledTimes(1)); + expect(h.createActivity.mock.calls[0][0].name).toBe("unknown"); + }); + + it("records success:false by reading the returned envelope, not by throwing", async () => { + setRequest("http://localhost:3000/_serverFn/doThing"); + + await runMiddleware({ + next: async () => ({ + result: { + success: false, + error: { code: "NOT_FOUND", userMessage: "nope" }, + }, + }), + data: undefined, + context: {}, + }); + + await vi.waitFor(() => expect(h.createActivity).toHaveBeenCalledTimes(1)); + const row = h.createActivity.mock.calls[0][0]; + expect(row.success).toBe(false); + expect(row.error).toContain("NOT_FOUND"); + }); + + it("records success:false and rethrows when the handler throws", async () => { + setRequest("http://localhost:3000/_serverFn/doThing"); + + await expect( + runMiddleware({ + next: async () => { + throw new Error("boom"); + }, + data: undefined, + context: {}, + }) + ).rejects.toThrow("boom"); + + await vi.waitFor(() => expect(h.createActivity).toHaveBeenCalledTimes(1)); + expect(h.createActivity.mock.calls[0][0]).toMatchObject({ + success: false, + error: "boom", + }); + }); + + it("redacts sensitive argument keys before they reach the audit row", async () => { + setRequest("http://localhost:3000/_serverFn/login"); + + await runMiddleware({ + next: async () => successEnvelope, + data: { phone: "+17135550142", first_name: "Ada" }, + context: {}, + }); + + await vi.waitFor(() => expect(h.createActivity).toHaveBeenCalledTimes(1)); + expect(h.createActivity.mock.calls[0][0].arguments).toEqual({ + phone: "[redacted]", + first_name: "Ada", + }); + }); + + it("truncates oversized arguments", async () => { + setRequest("http://localhost:3000/_serverFn/doThing"); + + await runMiddleware({ + next: async () => successEnvelope, + data: { blob: "x".repeat(5000) }, + context: {}, + }); + + await vi.waitFor(() => expect(h.createActivity).toHaveBeenCalledTimes(1)); + const args = h.createActivity.mock.calls[0][0].arguments; + expect(args.truncated).toBeDefined(); + expect(args.truncated.length).toBeLessThanOrEqual(2048); + }); +}); + +describe("recordDeniedServerFn", () => { + const deniedRequest = (url: string) => + ({ url, headers: new Headers({ "user-agent": "vitest" }) }) as Request; + + it("names the denial row from the meta so it matches the success rows", async () => { + recordDeniedServerFn( + deniedRequest(`http://localhost:3000/_serverFn/${"b".repeat(64)}`), + { player_id: "p1" }, + { name: "createTournament" } + ); + + await vi.waitFor(() => expect(h.createActivity).toHaveBeenCalledTimes(1)); + expect(h.createActivity.mock.calls[0][0]).toMatchObject({ + name: "createTournament", + player: "p1", + success: false, + error: "FORBIDDEN: Access denied", + }); + }); + + it("still records a name when the meta is absent", async () => { + recordDeniedServerFn( + deniedRequest("http://localhost:3000/_serverFn/rawSegment") + ); + + await vi.waitFor(() => expect(h.createActivity).toHaveBeenCalledTimes(1)); + expect(h.createActivity.mock.calls[0][0]).toMatchObject({ + name: "rawSegment", + success: false, + }); + }); +}); diff --git a/src/utils/activities.ts b/src/utils/activities.ts index fc577dc..071e592 100644 --- a/src/utils/activities.ts +++ b/src/utils/activities.ts @@ -1,6 +1,7 @@ import { createMiddleware } from "@tanstack/react-start"; import { getRequest } from "@tanstack/react-start/server"; import { Logger } from "@/lib/logger"; +import { redactValue } from "@/lib/redact"; import type { ActivityInput } from "@/lib/pocketbase/services/activities"; const logger = new Logger("Activities"); @@ -16,16 +17,67 @@ const recordActivity = (activity: ActivityInput) => { }); }; +const MAX_ARGUMENTS_CHARS = 2048; + +const redactArguments = (data: unknown): unknown => { + if (data === undefined) return undefined; + + const redacted = redactValue(data); + const serialized = JSON.stringify(redacted) ?? ""; + if (serialized.length > MAX_ARGUMENTS_CHARS) { + return { truncated: serialized.slice(0, MAX_ARGUMENTS_CHARS) }; + } + return redacted; +}; + +const auditErrorMessage = ( + error?: { code?: string; userMessage?: string } +): string => { + if (!error) return "error"; + return [error.code, error.userMessage].filter(Boolean).join(": ") || "error"; +}; + +type Actor = { player_id?: string }; + +type ServerFnMeta = { name?: string }; + +// Start's default generateFunctionId hashes the entry id, so the URL segment is +// a sha256 and the compile-time meta is the only readable name. The path stays +// as the fallback for requests that carry no meta. +const serverFnName = (request: Request, meta?: ServerFnMeta): string => { + if (meta?.name) return meta.name; + const pathParts = new URL(request.url).pathname.split("/").filter(Boolean); + return pathParts[pathParts.length - 1] || "unknown"; +}; + +// The admin middleware refuses before serverFnLoggingMiddleware ever runs, so +// a denial has to write its own row — otherwise an operator reviewing the log +// during a probing attempt sees a clean history. The rejected payload is left +// out: it never reached a validator, so it is unbounded attacker input. +export const recordDeniedServerFn = ( + request: Request, + actor?: Actor, + meta?: ServerFnMeta +) => { + recordActivity({ + name: serverFnName(request, meta), + player: actor?.player_id, + duration: 0, + success: false, + error: "FORBIDDEN: Access denied", + user_agent: request.headers.get("user-agent") || undefined, + }); +}; + export const serverFnLoggingMiddleware = createMiddleware({ type: "function", -}).server(async ({ next, data, context }) => { +}).server(async ({ next, data, context, serverFnMeta }) => { const request = getRequest(); - const url = new URL(request.url); - const pathParts = url.pathname.split('/').filter(Boolean); - const serverFnName = pathParts[pathParts.length - 1] || 'unknown'; - const userId = (context as any)?.metadata?.player_id || 'unknown'; - const userAgent = request.headers.get('user-agent') || undefined; + const name = serverFnName(request, serverFnMeta); + const playerId = (context as any)?.metadata?.player_id as string | undefined; + const userAgent = request.headers.get("user-agent") || undefined; + const safeArgs = redactArguments(data); const startTime = Date.now(); @@ -33,12 +85,26 @@ export const serverFnLoggingMiddleware = createMiddleware({ const result = await next(); const duration = Date.now() - startTime; + // Single audit writer; toServerResult resolves { success:false } instead of throwing, so read the flag. + const envelope = (result as { result?: unknown })?.result; + const failed = + !!envelope && + typeof envelope === "object" && + "success" in envelope && + (envelope as { success: boolean }).success === false; + recordActivity({ - name: serverFnName, - player: userId !== 'unknown' ? userId : undefined, + name, + player: playerId, duration, - success: true, - arguments: data, + success: !failed, + error: failed + ? auditErrorMessage( + (envelope as { error?: { code?: string; userMessage?: string } }) + .error + ) + : undefined, + arguments: safeArgs, user_agent: userAgent, }); @@ -48,12 +114,12 @@ export const serverFnLoggingMiddleware = createMiddleware({ const errorMessage = error instanceof Error ? error.message : String(error); recordActivity({ - name: serverFnName, - player: userId !== 'unknown' ? userId : undefined, + name, + player: playerId, duration, success: false, error: errorMessage, - arguments: data, + arguments: safeArgs, user_agent: userAgent, }); diff --git a/src/utils/supertokens.ts b/src/utils/supertokens.ts index 1382a80..19f47bc 100644 --- a/src/utils/supertokens.ts +++ b/src/utils/supertokens.ts @@ -52,7 +52,7 @@ export const superTokensFunctionMiddleware = createMiddleware({ export const superTokensAdminFunctionMiddleware = createMiddleware({ type: "function", -}).server(async ({ next }) => { +}).server(async ({ next, serverFnMeta }) => { const request = getRequest(); try { @@ -62,7 +62,13 @@ export const superTokensAdminFunctionMiddleware = createMiddleware({ return next({ context }); } - logger.error("Unauthorized user in admin function.", context); + // Identifiers only — the full context carries phone + metadata. + logger.error("Unauthorized user in admin function.", { + userAuthId: context.userAuthId, + roles: context.roles, + }); + const { recordDeniedServerFn } = await import("./activities"); + recordDeniedServerFn(request, context.metadata, serverFnMeta); throw new Error("Unauthorized"); } catch (error: any) { if (error.message === "SESSION_REFRESH_REQUIRED") { diff --git a/vitest.config.mjs b/vitest.config.mjs new file mode 100644 index 0000000..209e4d0 --- /dev/null +++ b/vitest.config.mjs @@ -0,0 +1,12 @@ +import { defineConfig } from 'vitest/config' +import tsConfigPaths from 'vite-tsconfig-paths' + +// Excludes the TanStack Start plugin: its SSR/router codegen isn't needed for unit tests. +export default defineConfig({ + plugins: [tsConfigPaths({ projects: ['./tsconfig.json'] })], + test: { + environment: 'node', + include: ['src/**/*.test.{ts,tsx}'], + restoreMocks: true, + }, +})