Merge branch 'deep-ketch' into development
CI/CD Pipeline / Build and Push PocketBase Docker Image (push) Successful in 27s
CI/CD Pipeline / i18n Catalog Check (push) Successful in 41s
CI/CD Pipeline / Build and Push App Docker Image (push) Successful in 3m30s
CI/CD Pipeline / Deploy to Kubernetes (push) Failing after 22s

This commit is contained in:
2026-08-23 20:31:49 -07:00
29 changed files with 871 additions and 125 deletions
+52 -1
View File
@@ -62,6 +62,7 @@
"typescript": "^5.7.2", "typescript": "^5.7.2",
"vite": "^7.1.7", "vite": "^7.1.7",
"vite-tsconfig-paths": "^5.1.4", "vite-tsconfig-paths": "^5.1.4",
"vitest": "^4.1.11",
"workbox-build": "^7.4.1", "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=="], "@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": ["@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=="], "@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/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/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=="], "@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=="], "@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=="], "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=="], "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=="], "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": ["async@3.2.6", "", {}, "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA=="],
"async-function": ["async-function@1.0.0", "", {}, "sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA=="], "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=="], "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=="], "chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="],
"chokidar": ["chokidar@5.0.0", "", { "dependencies": { "readdirp": "^5.0.0" } }, "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw=="], "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-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-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=="], "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=="], "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=="], "esutils": ["esutils@2.0.3", "", {}, "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g=="],
"eta": ["eta@4.6.0", "", {}, "sha512-lW6is4T1NFOYnmqGZIfvixqj7A7sSvScF+DN8EK6K58xI5MZ5UvYe0GjopxOXQtZvUn4eDdVuZ8XSoYWTMEKwA=="], "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=="], "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=="], "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=="], "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=="], "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=="], "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=="], "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=="], "signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="],
"smob": ["smob@1.6.2", "", {}, "sha512-RQsvleCbF8cVHEv+xuDGaA4pOizFqJ0GgjtMSRo6oP8pnN7WsigHgVGey6aILRBKv4W2YOMHLqbKdnB6hpB9fw=="], "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=="], "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=="], "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=="], "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=="], "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=="], "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=="], "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": ["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=="], "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=="], "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=="], "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=="], "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=="], "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-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=="], "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=="], "@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=="], "@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=="], "@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=="], "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=="], "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=="], "workbox-build/source-map": ["source-map@0.8.0-beta.0", "", { "dependencies": { "whatwg-url": "^7.0.0" } }, "sha512-2ymg6oRBpebeZi9UUNsgQ89bhx01TcTkmNTGnNO88imTmbSgy4nfujrgVEFKWpMTEGA11EDkTt7mqObTPdigIA=="],
+3 -1
View File
@@ -12,7 +12,8 @@
"extract": "lingui extract --clean", "extract": "lingui extract --clean",
"i18n:check": "lingui extract --clean && lingui compile --strict", "i18n:check": "lingui extract --clean && lingui compile --strict",
"build": "lingui extract --clean && vite build && tsc --noEmit && bun scripts/generate-sw.mjs", "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": { "dependencies": {
"@hello-pangea/dnd": "^18.0.1", "@hello-pangea/dnd": "^18.0.1",
@@ -73,6 +74,7 @@
"typescript": "^5.7.2", "typescript": "^5.7.2",
"vite": "^7.1.7", "vite": "^7.1.7",
"vite-tsconfig-paths": "^5.1.4", "vite-tsconfig-paths": "^5.1.4",
"vitest": "^4.1.11",
"workbox-build": "^7.4.1" "workbox-build": "^7.4.1"
} }
} }
+15 -2
View File
@@ -18,7 +18,13 @@ import { ColorSchemeScript, mantineHtmlProps } from "@mantine/core";
import { HeaderConfig } from "@/features/core/types/header-config"; import { HeaderConfig } from "@/features/core/types/header-config";
import { playerQueries } from "@/features/players/queries"; import { playerQueries } from "@/features/players/queries";
import { ensureServerQueryData } from "@/lib/tanstack-query/utils/ensure"; import { ensureServerQueryData } from "@/lib/tanstack-query/utils/ensure";
import { DEFAULT_LOCALE, ensureMessages, resolveLocale } from "@/lib/i18n"; import {
DEFAULT_LOCALE,
ensureMessages,
getCachedMessages,
i18nMessagesQueryKey,
resolveLocale,
} from "@/lib/i18n";
import { getRootMeta } from "@/lib/i18n/meta"; import { getRootMeta } from "@/lib/i18n/meta";
import FullScreenLoader from "@/components/full-screen-loader"; import FullScreenLoader from "@/components/full-screen-loader";
import { CHROME_COLORS } from "@/lib/mantine/theme-colors"; import { CHROME_COLORS } from "@/lib/mantine/theme-colors";
@@ -132,7 +138,14 @@ export const Route = createRootRouteWithContext<{
context.queryClient, context.queryClient,
playerQueries.auth() playerQueries.auth()
); );
await ensureMessages(resolveLocale(auth?.metadata?.locale)); const locale = resolveLocale(auth?.metadata?.locale);
await ensureMessages(locale);
if (typeof window === "undefined" && locale !== DEFAULT_LOCALE) {
context.queryClient.setQueryData(
i18nMessagesQueryKey(locale),
getCachedMessages(locale)
);
}
return { auth }; return { auth };
} catch (error: any) { } catch (error: any) {
if (isRedirect(error) || error instanceof Response) throw error; if (isRedirect(error) || error instanceof Response) throw error;
+14 -3
View File
@@ -2,9 +2,15 @@ import { useEffect, useState } from 'react'
import { Box, Paper, Group, Text, ActionIcon } from '@mantine/core' import { Box, Paper, Group, Text, ActionIcon } from '@mantine/core'
import { DownloadIcon, XIcon } from '@phosphor-icons/react' import { DownloadIcon, XIcon } from '@phosphor-icons/react'
import { Trans, useLingui } from '@lingui/react/macro' import { Trans, useLingui } from '@lingui/react/macro'
import { useAuth } from '@/contexts/auth-context'
import { useIsMobile } from '@/hooks/use-is-mobile'
const ABOVE_MOBILE_NAV_OFFSET = 'calc(4.5rem + env(safe-area-inset-bottom, 0px))'
export function IOSInstallPrompt() { export function IOSInstallPrompt() {
const { t } = useLingui() const { t } = useLingui()
const { user } = useAuth()
const isMobile = useIsMobile()
const [show, setShow] = useState(false) const [show, setShow] = useState(false)
const [platform, setPlatform] = useState<'ios' | 'android' | null>(null) const [platform, setPlatform] = useState<'ios' | 'android' | null>(null)
@@ -36,22 +42,27 @@ export function IOSInstallPrompt() {
? t`Tap Share → Add to Home Screen` ? t`Tap Share → Add to Home Screen`
: t`Tap Menu (⋮) → Add to Home screen` : t`Tap Menu (⋮) → Add to Home screen`
const aboveBottomNav = Boolean(user) && isMobile
const bottomStyle = aboveBottomNav
? { bottom: ABOVE_MOBILE_NAV_OFFSET, paddingBottom: '8px' }
: { bottom: 0, paddingBottom: 'calc(8px + env(safe-area-inset-bottom, 0px))' }
return ( return (
<Box style={{ position: 'fixed', bottom: 0, left: 0, right: 0, zIndex: 1000, padding: '8px' }}> <Box style={{ position: 'fixed', left: 0, right: 0, zIndex: 1000, padding: '8px', ...bottomStyle }}>
<Paper shadow="lg" p="sm" style={{ background: 'var(--mantine-color-blue-9)', color: 'white' }}> <Paper shadow="lg" p="sm" style={{ background: 'var(--mantine-color-blue-9)', color: 'white' }}>
<Group justify="space-between" wrap="nowrap"> <Group justify="space-between" wrap="nowrap">
<Group gap="xs" wrap="nowrap" style={{ flex: 1 }}> <Group gap="xs" wrap="nowrap" style={{ flex: 1 }}>
<DownloadIcon size={20} style={{ flexShrink: 0 }} /> <DownloadIcon size={20} style={{ flexShrink: 0 }} />
<Box style={{ flex: 1, minWidth: 0 }}> <Box style={{ flex: 1, minWidth: 0 }}>
<Text size="sm" fw={500} style={{ lineHeight: 1.3 }}> <Text size="sm" fw={500} style={{ lineHeight: 1.3 }}>
<Trans>Please install FLXN • This will save me Twilio credits as you won't be signed out!</Trans> <Trans>Add FLXN to your home screen for a smoother experience</Trans>
</Text> </Text>
<Text size="xs" opacity={0.9} style={{ lineHeight: 1.2 }}> <Text size="xs" opacity={0.9} style={{ lineHeight: 1.2 }}>
{instructions} {instructions}
</Text> </Text>
</Box> </Box>
</Group> </Group>
<ActionIcon variant="subtle" color="white" onClick={handleDismiss}> <ActionIcon variant="subtle" color="white" onClick={handleDismiss} aria-label={t`Dismiss`}>
<XIcon size={18} /> <XIcon size={18} />
</ActionIcon> </ActionIcon>
</Group> </Group>
+3 -2
View File
@@ -1,5 +1,6 @@
import { toServerResult } from "@/lib/tanstack-query/utils/to-server-result"; import { toServerResult } from "@/lib/tanstack-query/utils/to-server-result";
import { superTokensAdminFunctionMiddleware, superTokensFunctionMiddleware } from "@/utils/supertokens"; import { superTokensAdminFunctionMiddleware, superTokensFunctionMiddleware } from "@/utils/supertokens";
import { serverFnLoggingMiddleware } from "@/utils/activities";
import { createServerFn } from "@tanstack/react-start"; import { createServerFn } from "@tanstack/react-start";
import { pbAdmin } from "@/lib/pocketbase/client"; import { pbAdmin } from "@/lib/pocketbase/client";
import { z } from "zod"; import { z } from "zod";
@@ -14,7 +15,7 @@ export const getPlayerBadges = createServerFn()
); );
export const migrateBadgeProgress = createServerFn() export const migrateBadgeProgress = createServerFn()
.middleware([superTokensAdminFunctionMiddleware]) .middleware([superTokensAdminFunctionMiddleware, serverFnLoggingMiddleware])
.handler(async () => .handler(async () =>
toServerResult(async () => startBadgeMigration()) toServerResult(async () => startBadgeMigration())
); );
@@ -40,7 +41,7 @@ export const awardManualBadge = createServerFn()
playerId: z.string(), playerId: z.string(),
badgeId: z.string(), badgeId: z.string(),
})) }))
.middleware([superTokensAdminFunctionMiddleware]) .middleware([superTokensAdminFunctionMiddleware, serverFnLoggingMiddleware])
.handler(async ({ data }) => .handler(async ({ data }) =>
toServerResult(async () => { toServerResult(async () => {
const result = await pbAdmin.awardManualBadge(data.playerId, data.badgeId); const result = await pbAdmin.awardManualBadge(data.playerId, data.badgeId);
+56 -2
View File
@@ -1,15 +1,57 @@
import { useLayoutEffect, useRef } from "react";
import { Title, AppShell, Flex } from "@mantine/core"; import { Title, AppShell, Flex } from "@mantine/core";
import { useLingui } from "@lingui/react"; import { useLingui } from "@lingui/react";
import { HeaderConfig } from "../types/header-config"; import { HeaderConfig } from "../types/header-config";
import BackButton from "./back-button"; import BackButton from "./back-button";
const MIN_TITLE_SCALE = 0.55;
const Header = ({ collapsed, title, titleValues, withBackButton }: HeaderConfig) => { const Header = ({ collapsed, title, titleValues, withBackButton }: HeaderConfig) => {
const { i18n } = useLingui(); const { i18n } = useLingui();
const titleRef = useRef<HTMLHeadingElement>(null);
const resolvedTitle = const resolvedTitle =
typeof title === "string" || title === undefined typeof title === "string" || title === undefined
? title ? title
: i18n._({ ...title, values: titleValues }); : i18n._({ ...title, values: titleValues });
useLayoutEffect(() => {
const el = titleRef.current;
if (!el) return;
let cancelled = false;
const textWidth = () => {
const range = document.createRange();
range.selectNodeContents(el);
return range.getBoundingClientRect().width;
};
const fit = () => {
el.style.fontSize = "";
const available = el.clientWidth - 1;
if (available <= 0) return;
let needed = textWidth();
if (needed <= available) return;
const natural = parseFloat(getComputedStyle(el).fontSize);
let scale = Math.max(available / needed, MIN_TITLE_SCALE);
el.style.fontSize = `${natural * scale}px`;
for (let i = 0; i < 3 && scale > MIN_TITLE_SCALE && textWidth() > available; i++) {
scale = Math.max(scale * 0.98, MIN_TITLE_SCALE);
el.style.fontSize = `${natural * scale}px`;
}
};
fit();
document.fonts?.ready.then(() => {
if (!cancelled) fit();
});
const observer = new ResizeObserver(fit);
observer.observe(el);
return () => {
cancelled = true;
observer.disconnect();
};
}, [resolvedTitle]);
return ( return (
<AppShell.Header <AppShell.Header
id='app-header' id='app-header'
@@ -20,8 +62,20 @@ const Header = ({ collapsed, title, titleValues, withBackButton }: HeaderConfig)
}} }}
> >
{ withBackButton && <BackButton /> } { withBackButton && <BackButton /> }
<Flex justify='center' px='md' mt={8}> <Flex justify='center' px={48} mt={8} w='100%'>
<Title order={1} lts='0.08em' style={{ userSelect: 'none' }}> <Title
ref={titleRef}
order={1}
lts='0.08em'
style={{
userSelect: 'none',
width: '100%',
textAlign: 'center',
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
}}
>
{resolvedTitle?.toLocaleUpperCase()} {resolvedTitle?.toLocaleUpperCase()}
</Title> </Title>
</Flex> </Flex>
@@ -5,11 +5,10 @@ const eventListerOptions = {
}; };
const useVisualViewportSize = () => { const useVisualViewportSize = () => {
const windowExists = typeof window !== 'undefined';
const [windowSize, setWindowSize] = useState({ const [windowSize, setWindowSize] = useState({
width: windowExists ? window.visualViewport?.width || 0 : 0, width: 0,
height: windowExists ? window.visualViewport?.height || 0 : 0, height: 0,
top: windowExists ? window.visualViewport?.offsetTop || 0 : 0, top: 0,
}); });
const rafRef = useRef<number | null>(null); const rafRef = useRef<number | null>(null);
+1 -1
View File
@@ -84,7 +84,7 @@ export const updatePlayer = createServerFn()
export const createPlayer = createServerFn() export const createPlayer = createServerFn()
.validator(playerInputSchema) .validator(playerInputSchema)
.middleware([superTokensFunctionMiddleware]) .middleware([superTokensFunctionMiddleware, serverFnLoggingMiddleware])
.handler(async ({ context, data }) => .handler(async ({ context, data }) =>
toServerResult(async () => { toServerResult(async () => {
const userAuthId = context.userAuthId; const userAuthId = context.userAuthId;
+1 -1
View File
@@ -148,7 +148,7 @@ export const generateRandomTeams = createServerFn()
tournamentId: z.string(), tournamentId: z.string(),
seed: z.number().optional() seed: z.number().optional()
})) }))
.middleware([superTokensAdminFunctionMiddleware]) .middleware([superTokensAdminFunctionMiddleware, serverFnLoggingMiddleware])
.handler(async ({ data }) => .handler(async ({ data }) =>
toServerResult(async () => { toServerResult(async () => {
const freeAgents = await pbAdmin.getFreeAgents(data.tournamentId); const freeAgents = await pbAdmin.getFreeAgents(data.tournamentId);
+16 -1
View File
@@ -32,6 +32,8 @@ export function resolveLocale(candidate: string | null | undefined): AppLocale {
: DEFAULT_LOCALE; : DEFAULT_LOCALE;
} }
const inflightLoads: Partial<Record<AppLocale, Promise<Messages>>> = {};
export async function ensureMessages(locale: AppLocale): Promise<Messages> { export async function ensureMessages(locale: AppLocale): Promise<Messages> {
const cached = messageCache[locale]; const cached = messageCache[locale];
if (cached) return cached; if (cached) return cached;
@@ -39,11 +41,24 @@ export async function ensureMessages(locale: AppLocale): Promise<Messages> {
const loader = catalogLoaders[`/src/locales/${locale}/messages.po`]; const loader = catalogLoaders[`/src/locales/${locale}/messages.po`];
if (!loader) return messageCache[DEFAULT_LOCALE]!; if (!loader) return messageCache[DEFAULT_LOCALE]!;
const { messages } = await loader(); const pending = (inflightLoads[locale] ??= loader()
.then(({ messages }) => {
messageCache[locale] = messages; messageCache[locale] = messages;
return messages; return messages;
})
.finally(() => {
delete inflightLoads[locale];
}));
return pending;
} }
export function primeMessages(locale: AppLocale, messages: Messages) {
messageCache[locale] ??= messages;
}
export const i18nMessagesQueryKey = (locale: AppLocale) =>
["i18n-messages", locale] as const;
export function getCachedMessages(locale: AppLocale): Messages { export function getCachedMessages(locale: AppLocale): Messages {
return messageCache[locale] ?? messageCache[DEFAULT_LOCALE]!; return messageCache[locale] ?? messageCache[DEFAULT_LOCALE]!;
} }
+27 -9
View File
@@ -1,11 +1,14 @@
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { useQuery } from "@tanstack/react-query"; import { useQuery, useQueryClient } from "@tanstack/react-query";
import { I18nProvider } from "@lingui/react"; import { I18nProvider } from "@lingui/react";
import type { Messages } from "@lingui/core";
import { playerQueries } from "@/features/players/queries"; import { playerQueries } from "@/features/players/queries";
import { import {
createI18n, createI18n,
ensureMessages, ensureMessages,
hasCachedMessages, hasCachedMessages,
i18nMessagesQueryKey,
primeMessages,
resolveLocale, resolveLocale,
} from "@/lib/i18n"; } from "@/lib/i18n";
@@ -29,27 +32,42 @@ export const LinguiProvider = ({ children }: { children: React.ReactNode }) => {
refetchOnWindowFocus: false, refetchOnWindowFocus: false,
}); });
const queryClient = useQueryClient();
const locale = resolveLocale(data?.metadata?.locale); const locale = resolveLocale(data?.metadata?.locale);
const [catalogVersion, setCatalogVersion] = useState(0); const [catalogVersion, setCatalogVersion] = useState(0);
// Fresh instance per locale (and per SSR request tree). __root beforeLoad
// preloads the catalog on authed routes; on public routes (login) it may
// not be cached yet — createI18n falls back to en until the effect below
// loads it and bumps catalogVersion.
const i18n = useMemo( const i18n = useMemo(
() => createI18n(locale), () => {
if (!hasCachedMessages(locale)) {
const seeded = queryClient.getQueryData<Messages>(
i18nMessagesQueryKey(locale)
);
if (seeded) primeMessages(locale, seeded);
}
return createI18n(locale);
},
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
[locale, catalogVersion] [locale, catalogVersion, queryClient]
); );
useEffect(() => { useEffect(() => {
if (hasCachedMessages(locale)) return; if (hasCachedMessages(locale)) return;
let cancelled = false; let cancelled = false;
ensureMessages(locale).then(() => { let retryTimer: ReturnType<typeof setTimeout> | undefined;
const load = () => {
ensureMessages(locale).then(
() => {
if (!cancelled) setCatalogVersion((v) => v + 1); if (!cancelled) setCatalogVersion((v) => v + 1);
}); },
() => {
if (!cancelled) retryTimer = setTimeout(load, 3000);
}
);
};
load();
return () => { return () => {
cancelled = true; cancelled = true;
if (retryTimer) clearTimeout(retryTimer);
}; };
}, [locale]); }, [locale]);
+27 -3
View File
@@ -19,17 +19,32 @@ class PocketBaseAdminClient {
this.pb = new PocketBase(process.env.POCKETBASE_URL); this.pb = new PocketBase(process.env.POCKETBASE_URL);
this.pb.beforeSend = async (url, options) => { this.pb.beforeSend = async (url, options) => {
// Auth requests skip the gate: awaiting authPromise here would deadlock them.
if (!url.includes("/collections/_superusers/auth-")) {
try {
await this.authPromise; await this.authPromise;
} catch {}
if (this.pb.authStore.isValid && this.isTokenExpiringSoon()) { if (!this.pb.authStore.isValid) {
this.authPromise = this.authenticate();
await this.authPromise;
this.startTokenRefresh();
} else if (this.isTokenExpiringSoon()) {
try { try {
await this.refreshAuth(); await this.refreshAuth();
} catch (error) { } catch (error) {
console.error('Failed to refresh admin token, re-authenticating:', error); console.error('Failed to refresh admin token, re-authenticating:', error);
await this.authenticate(); this.authPromise = this.authenticate();
await this.authPromise;
} }
} }
options.headers = {
...options.headers,
Authorization: this.pb.authStore.token,
};
}
options.cache = "no-store"; options.cache = "no-store";
options.headers = { options.headers = {
...options.headers, ...options.headers,
@@ -54,8 +69,15 @@ class PocketBaseAdminClient {
Object.assign(this, createPushService(this.pb)); Object.assign(this, createPushService(this.pb));
this.authPromise = this.authenticate(); this.authPromise = this.authenticate();
this.authPromise.then(() => { this.authPromise
.then(() => {
this.startTokenRefresh(); this.startTokenRefresh();
})
.catch(() => {
console.error(
"PocketBase admin authentication failed - is PocketBase running and are " +
"POCKETBASE_URL / POCKETBASE_ADMIN_EMAIL / POCKETBASE_ADMIN_PASSWORD set?"
);
}); });
} }
@@ -98,6 +120,8 @@ class PocketBaseAdminClient {
} }
private startTokenRefresh() { private startTokenRefresh() {
if (this.refreshInterval) return;
this.refreshInterval = setInterval(async () => { this.refreshInterval = setInterval(async () => {
try { try {
await this.refreshAuth(); await this.refreshAuth();
+7 -5
View File
@@ -1,5 +1,7 @@
import PocketBase from "pocketbase"; import PocketBase from "pocketbase";
import { PlayerInfo } from "@/features/players/types"; import { PlayerInfo } from "@/features/players/types";
import { pbFilter } from "../util/filter";
import { likePattern } from "../util/like-pattern";
export interface Activity { export interface Activity {
id: string; id: string;
@@ -61,15 +63,15 @@ export function createActivitiesService(pb: PocketBase) {
const filters: string[] = []; const filters: string[] = [];
if (name) { if (name) {
filters.push(`name ~ "${name}"`); filters.push(pbFilter(pb, "name ~ {:name}", { name: likePattern(name) }));
} }
if (player) { if (player) {
filters.push(`player = "${player}"`); filters.push(pbFilter(pb, "player = {:player}", { player }));
} }
if (success !== undefined) { if (success !== undefined) {
filters.push(`success = ${success}`); filters.push(pbFilter(pb, "success = {:success}", { success }));
} }
const filterString = filters.length > 0 ? filters.join(" && ") : ""; const filterString = filters.length > 0 ? filters.join(" && ") : "";
@@ -98,7 +100,7 @@ export function createActivitiesService(pb: PocketBase) {
async getActivitiesByUser(userId: string, limit: number = 50): Promise<Activity[]> { async getActivitiesByUser(userId: string, limit: number = 50): Promise<Activity[]> {
const result = await pb.collection("activities").getList<Activity>(1, limit, { const result = await pb.collection("activities").getList<Activity>(1, limit, {
filter: `player = "${userId}"`, filter: pbFilter(pb, "player = {:userId}", { userId }),
sort: "-created", sort: "-created",
}); });
return result.items; return result.items;
@@ -106,7 +108,7 @@ export function createActivitiesService(pb: PocketBase) {
async getActivitiesByFunction(functionName: string, limit: number = 50): Promise<Activity[]> { async getActivitiesByFunction(functionName: string, limit: number = 50): Promise<Activity[]> {
const result = await pb.collection("activities").getList<Activity>(1, limit, { const result = await pb.collection("activities").getList<Activity>(1, limit, {
filter: `name = "${functionName}"`, filter: pbFilter(pb, "name = {:functionName}", { functionName }),
sort: "-created", sort: "-created",
}); });
return result.items; return result.items;
+118
View File
@@ -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'"
);
});
});
+20
View File
@@ -0,0 +1,20 @@
import type PocketBase from "pocketbase";
export type FilterParam = string | number | boolean | Date | null;
const PLACEHOLDER = /\{:([^}]+)\}/g;
// pb.filter $-expands string replacements; doubling `$` keeps the value literal.
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<string, FilterParam>
) =>
raw.replace(PLACEHOLDER, (token, key: string) =>
Object.hasOwn(params, key) ? quote(pb, params[key]) : token
);
@@ -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);
}
);
});
+3
View File
@@ -0,0 +1,3 @@
// Escapes LIKE metacharacters and wraps in `%` for a literal substring match.
export const likePattern = (term: string) =>
`%${term.replace(/[\\%_]/g, (char) => `\\${char}`)}%`;
+36
View File
@@ -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);
});
});
+18
View File
@@ -0,0 +1,18 @@
export const SENSITIVE_KEY = /token|secret|password|phone|otp|code|auth|key/i;
const REDACTED = "[redacted]";
export const redactValue = (value: unknown): unknown => {
if (Array.isArray(value)) return value.map(redactValue);
if (value && typeof value === "object") {
if (value instanceof Error) {
return { name: value.name, message: value.message, stack: value.stack };
}
const out: Record<string, unknown> = {};
for (const [key, v] of Object.entries(value as Record<string, unknown>)) {
out[key] = SENSITIVE_KEY.test(key) ? REDACTED : redactValue(v);
}
return out;
}
return value;
};
@@ -1,6 +1,5 @@
import { logger } from "../../logger"; import { logger } from "../../logger";
import { ErrorType, ServerError, ServerResult } from "../types"; import { ErrorType, ServerError, ServerResult } from "../types";
import { getRequest } from "@tanstack/react-start/server";
import { isRedirect } from "@tanstack/react-router"; import { isRedirect } from "@tanstack/react-router";
export const createServerError = ( export const createServerError = (
@@ -20,54 +19,15 @@ export const createServerError = (
export const toServerResult = async <T>( export const toServerResult = async <T>(
serverFn: () => Promise<T> serverFn: () => Promise<T>
): Promise<ServerResult<T>> => { ): Promise<ServerResult<T>> => {
const startTime = Date.now();
try { try {
const data = await serverFn(); const data = await serverFn();
return { success: true, data }; return { success: true, data };
} catch (error) { } catch (error) {
if (isRedirect(error) || error instanceof Response) throw error; if (isRedirect(error) || error instanceof Response) throw error;
const duration = Date.now() - startTime;
logger.error('Server Fn Error', error); logger.error('Server Fn Error', error);
const mappedError = mapKnownError(error); return { success: false, error: 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 };
} }
}; };
+10 -6
View File
@@ -392,6 +392,10 @@ msgstr "Aktivitäten"
msgid "Activity Details" msgid "Activity Details"
msgstr "Aktivitätsdetails" msgstr "Aktivitätsdetails"
#: src/components/ios-install-prompt.tsx:58
msgid "Add FLXN to your home screen for a smoother experience"
msgstr "Leg FLXN auf deinen Homescreen für ein flüssigeres Erlebnis"
#: src/features/tournaments/components/edit-enrolled-players.tsx:117 #: src/features/tournaments/components/edit-enrolled-players.tsx:117
msgid "Add Player" msgid "Add Player"
msgstr "Spieler hinzufügen" msgstr "Spieler hinzufügen"
@@ -853,6 +857,10 @@ msgstr "deviceId ist für die Übertragungsaktion erforderlich"
msgid "Disconnect Spotify" msgid "Disconnect Spotify"
msgstr "Spotify trennen" msgstr "Spotify trennen"
#: src/components/ios-install-prompt.tsx:65
msgid "Dismiss"
msgstr "Schließen"
#: src/features/bracket/components/match-report.tsx:159 #: src/features/bracket/components/match-report.tsx:159
msgid "Does this look right?" msgid "Does this look right?"
msgstr "Sieht das richtig aus?" msgstr "Sieht das richtig aus?"
@@ -1885,10 +1893,6 @@ msgstr "Spieler sind erforderlich"
msgid "Playing on {0}" msgid "Playing on {0}"
msgstr "Wird auf {0} abgespielt" msgstr "Wird auf {0} abgespielt"
#: src/components/ios-install-prompt.tsx:47
msgid "Please install FLXN • This will save me Twilio credits as you won't be signed out!"
msgstr "Installier FLXN • Das spart mir Twilio-Credits, weil du nicht ausgeloggt wirst!"
#: src/features/tournaments/components/group-stage-view.tsx:314 #: src/features/tournaments/components/group-stage-view.tsx:314
msgid "Populate Knockout Bracket" msgid "Populate Knockout Bracket"
msgstr "K.-o.-Runde befüllen" msgstr "K.-o.-Runde befüllen"
@@ -2347,11 +2351,11 @@ msgstr "T"
msgid "Tap an opponent below to compare" msgid "Tap an opponent below to compare"
msgstr "Tippe unten auf einen Gegner zum Vergleichen" msgstr "Tippe unten auf einen Gegner zum Vergleichen"
#: src/components/ios-install-prompt.tsx:37 #: src/components/ios-install-prompt.tsx:43
msgid "Tap Menu (⋮) → Add to Home screen" msgid "Tap Menu (⋮) → Add to Home screen"
msgstr "Tippe auf Menü (⋮) → Zum Home-Bildschirm hinzufügen" msgstr "Tippe auf Menü (⋮) → Zum Home-Bildschirm hinzufügen"
#: src/components/ios-install-prompt.tsx:36 #: src/components/ios-install-prompt.tsx:42
msgid "Tap Share → Add to Home Screen" msgid "Tap Share → Add to Home Screen"
msgstr "Tippe auf Teilen → Zum Home-Bildschirm hinzufügen" msgstr "Tippe auf Teilen → Zum Home-Bildschirm hinzufügen"
+10 -6
View File
@@ -392,6 +392,10 @@ msgstr "Activities"
msgid "Activity Details" msgid "Activity Details"
msgstr "Activity Details" msgstr "Activity Details"
#: src/components/ios-install-prompt.tsx:58
msgid "Add FLXN to your home screen for a smoother experience"
msgstr "Add FLXN to your home screen for a smoother experience"
#: src/features/tournaments/components/edit-enrolled-players.tsx:117 #: src/features/tournaments/components/edit-enrolled-players.tsx:117
msgid "Add Player" msgid "Add Player"
msgstr "Add Player" msgstr "Add Player"
@@ -853,6 +857,10 @@ msgstr "deviceId is required for transfer action"
msgid "Disconnect Spotify" msgid "Disconnect Spotify"
msgstr "Disconnect Spotify" msgstr "Disconnect Spotify"
#: src/components/ios-install-prompt.tsx:65
msgid "Dismiss"
msgstr "Dismiss"
#: src/features/bracket/components/match-report.tsx:159 #: src/features/bracket/components/match-report.tsx:159
msgid "Does this look right?" msgid "Does this look right?"
msgstr "Does this look right?" msgstr "Does this look right?"
@@ -1885,10 +1893,6 @@ msgstr "Players are required"
msgid "Playing on {0}" msgid "Playing on {0}"
msgstr "Playing on {0}" msgstr "Playing on {0}"
#: src/components/ios-install-prompt.tsx:47
msgid "Please install FLXN • This will save me Twilio credits as you won't be signed out!"
msgstr "Please install FLXN • This will save me Twilio credits as you won't be signed out!"
#: src/features/tournaments/components/group-stage-view.tsx:314 #: src/features/tournaments/components/group-stage-view.tsx:314
msgid "Populate Knockout Bracket" msgid "Populate Knockout Bracket"
msgstr "Populate Knockout Bracket" msgstr "Populate Knockout Bracket"
@@ -2347,11 +2351,11 @@ msgstr "T"
msgid "Tap an opponent below to compare" msgid "Tap an opponent below to compare"
msgstr "Tap an opponent below to compare" msgstr "Tap an opponent below to compare"
#: src/components/ios-install-prompt.tsx:37 #: src/components/ios-install-prompt.tsx:43
msgid "Tap Menu (⋮) → Add to Home screen" msgid "Tap Menu (⋮) → Add to Home screen"
msgstr "Tap Menu (⋮) → Add to Home screen" msgstr "Tap Menu (⋮) → Add to Home screen"
#: src/components/ios-install-prompt.tsx:36 #: src/components/ios-install-prompt.tsx:42
msgid "Tap Share → Add to Home Screen" msgid "Tap Share → Add to Home Screen"
msgstr "Tap Share → Add to Home Screen" msgstr "Tap Share → Add to Home Screen"
+10 -6
View File
@@ -392,6 +392,10 @@ msgstr "Actividades"
msgid "Activity Details" msgid "Activity Details"
msgstr "Detalles de la actividad" msgstr "Detalles de la actividad"
#: src/components/ios-install-prompt.tsx:58
msgid "Add FLXN to your home screen for a smoother experience"
msgstr "Agrega FLXN a tu pantalla de inicio para una experiencia más fluida"
#: src/features/tournaments/components/edit-enrolled-players.tsx:117 #: src/features/tournaments/components/edit-enrolled-players.tsx:117
msgid "Add Player" msgid "Add Player"
msgstr "Agregar Jugador" msgstr "Agregar Jugador"
@@ -853,6 +857,10 @@ msgstr "Se requiere deviceId para la acción de transferencia"
msgid "Disconnect Spotify" msgid "Disconnect Spotify"
msgstr "Desconectar Spotify" msgstr "Desconectar Spotify"
#: src/components/ios-install-prompt.tsx:65
msgid "Dismiss"
msgstr "Cerrar"
#: src/features/bracket/components/match-report.tsx:159 #: src/features/bracket/components/match-report.tsx:159
msgid "Does this look right?" msgid "Does this look right?"
msgstr "¿Se ve bien así?" msgstr "¿Se ve bien así?"
@@ -1885,10 +1893,6 @@ msgstr "Los jugadores son obligatorios"
msgid "Playing on {0}" msgid "Playing on {0}"
msgstr "Reproduciendo en {0}" msgstr "Reproduciendo en {0}"
#: src/components/ios-install-prompt.tsx:47
msgid "Please install FLXN • This will save me Twilio credits as you won't be signed out!"
msgstr "Por favor instala FLXN • Así me ahorras créditos de Twilio porque no se cerrará tu sesión"
#: src/features/tournaments/components/group-stage-view.tsx:314 #: src/features/tournaments/components/group-stage-view.tsx:314
msgid "Populate Knockout Bracket" msgid "Populate Knockout Bracket"
msgstr "Llenar el bracket eliminatorio" msgstr "Llenar el bracket eliminatorio"
@@ -2347,11 +2351,11 @@ msgstr "T"
msgid "Tap an opponent below to compare" msgid "Tap an opponent below to compare"
msgstr "Toca un oponente abajo para comparar" msgstr "Toca un oponente abajo para comparar"
#: src/components/ios-install-prompt.tsx:37 #: src/components/ios-install-prompt.tsx:43
msgid "Tap Menu (⋮) → Add to Home screen" msgid "Tap Menu (⋮) → Add to Home screen"
msgstr "Toca Menú (⋮) → Agregar a pantalla de inicio" msgstr "Toca Menú (⋮) → Agregar a pantalla de inicio"
#: src/components/ios-install-prompt.tsx:36 #: src/components/ios-install-prompt.tsx:42
msgid "Tap Share → Add to Home Screen" msgid "Tap Share → Add to Home Screen"
msgstr "Toca Compartir → Agregar a pantalla de inicio" msgstr "Toca Compartir → Agregar a pantalla de inicio"
+10 -6
View File
@@ -392,6 +392,10 @@ msgstr "アクティビティ"
msgid "Activity Details" msgid "Activity Details"
msgstr "アクティビティの詳細" msgstr "アクティビティの詳細"
#: src/components/ios-install-prompt.tsx:58
msgid "Add FLXN to your home screen for a smoother experience"
msgstr "FLXNをホーム画面に追加すると、もっと快適に使えます"
#: src/features/tournaments/components/edit-enrolled-players.tsx:117 #: src/features/tournaments/components/edit-enrolled-players.tsx:117
msgid "Add Player" msgid "Add Player"
msgstr "プレイヤーを追加" msgstr "プレイヤーを追加"
@@ -853,6 +857,10 @@ msgstr "転送操作にはdeviceIdが必要です"
msgid "Disconnect Spotify" msgid "Disconnect Spotify"
msgstr "Spotifyの連携を解除" msgstr "Spotifyの連携を解除"
#: src/components/ios-install-prompt.tsx:65
msgid "Dismiss"
msgstr "閉じる"
#: src/features/bracket/components/match-report.tsx:159 #: src/features/bracket/components/match-report.tsx:159
msgid "Does this look right?" msgid "Does this look right?"
msgstr "これで合っていますか?" msgstr "これで合っていますか?"
@@ -1885,10 +1893,6 @@ msgstr "プレイヤーの選択が必要です"
msgid "Playing on {0}" msgid "Playing on {0}"
msgstr "{0}で再生中" msgstr "{0}で再生中"
#: src/components/ios-install-prompt.tsx:47
msgid "Please install FLXN • This will save me Twilio credits as you won't be signed out!"
msgstr "FLXNをインストールしてください • ログアウトされなくなるので、Twilioのクレジット節約になります!"
#: src/features/tournaments/components/group-stage-view.tsx:314 #: src/features/tournaments/components/group-stage-view.tsx:314
msgid "Populate Knockout Bracket" msgid "Populate Knockout Bracket"
msgstr "ノックアウトブラケットを作成" msgstr "ノックアウトブラケットを作成"
@@ -2347,11 +2351,11 @@ msgstr "T"
msgid "Tap an opponent below to compare" msgid "Tap an opponent below to compare"
msgstr "下の対戦相手をタップして比較" msgstr "下の対戦相手をタップして比較"
#: src/components/ios-install-prompt.tsx:37 #: src/components/ios-install-prompt.tsx:43
msgid "Tap Menu (⋮) → Add to Home screen" msgid "Tap Menu (⋮) → Add to Home screen"
msgstr "メニュー (⋮) をタップ →「ホーム画面に追加」" msgstr "メニュー (⋮) をタップ →「ホーム画面に追加」"
#: src/components/ios-install-prompt.tsx:36 #: src/components/ios-install-prompt.tsx:42
msgid "Tap Share → Add to Home Screen" msgid "Tap Share → Add to Home Screen"
msgstr "共有をタップ →「ホーム画面に追加」" msgstr "共有をタップ →「ホーム画面に追加」"
+14
View File
@@ -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;
};
+226
View File
@@ -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<unknown>;
data: unknown;
context: unknown;
serverFnMeta?: { id: string; name?: string; filename?: string };
}) => Promise<unknown>;
const runMiddleware = (opts: Parameters<ServerHandler>[0]) =>
(serverFnLoggingMiddleware as any).options.server(opts) as ReturnType<ServerHandler>;
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,
});
});
});
+72 -13
View File
@@ -1,6 +1,7 @@
import { createMiddleware } from "@tanstack/react-start"; import { createMiddleware } from "@tanstack/react-start";
import { getRequest } from "@tanstack/react-start/server"; import { getRequest } from "@tanstack/react-start/server";
import { Logger } from "@/lib/logger"; import { Logger } from "@/lib/logger";
import { redactValue } from "@/lib/redact";
import type { ActivityInput } from "@/lib/pocketbase/services/activities"; import type { ActivityInput } from "@/lib/pocketbase/services/activities";
const logger = new Logger("Activities"); const logger = new Logger("Activities");
@@ -16,16 +17,61 @@ 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 };
// The URL segment is a hash of the fn id; serverFnMeta carries the readable name.
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";
};
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({ export const serverFnLoggingMiddleware = createMiddleware({
type: "function", type: "function",
}).server(async ({ next, data, context }) => { }).server(async ({ next, data, context, serverFnMeta }) => {
const request = getRequest(); const request = getRequest();
const url = new URL(request.url); const name = serverFnName(request, serverFnMeta);
const pathParts = url.pathname.split('/').filter(Boolean); const playerId = (context as any)?.metadata?.player_id as string | undefined;
const serverFnName = pathParts[pathParts.length - 1] || 'unknown'; const userAgent = request.headers.get("user-agent") || undefined;
const userId = (context as any)?.metadata?.player_id || 'unknown'; const safeArgs = redactArguments(data);
const userAgent = request.headers.get('user-agent') || undefined;
const startTime = Date.now(); const startTime = Date.now();
@@ -33,12 +79,25 @@ export const serverFnLoggingMiddleware = createMiddleware({
const result = await next(); const result = await next();
const duration = Date.now() - startTime; const duration = Date.now() - startTime;
const envelope = (result as { result?: unknown })?.result;
const failed =
!!envelope &&
typeof envelope === "object" &&
"success" in envelope &&
(envelope as { success: boolean }).success === false;
recordActivity({ recordActivity({
name: serverFnName, name,
player: userId !== 'unknown' ? userId : undefined, player: playerId,
duration, duration,
success: true, success: !failed,
arguments: data, error: failed
? auditErrorMessage(
(envelope as { error?: { code?: string; userMessage?: string } })
.error
)
: undefined,
arguments: safeArgs,
user_agent: userAgent, user_agent: userAgent,
}); });
@@ -48,12 +107,12 @@ export const serverFnLoggingMiddleware = createMiddleware({
const errorMessage = error instanceof Error ? error.message : String(error); const errorMessage = error instanceof Error ? error.message : String(error);
recordActivity({ recordActivity({
name: serverFnName, name,
player: userId !== 'unknown' ? userId : undefined, player: playerId,
duration, duration,
success: false, success: false,
error: errorMessage, error: errorMessage,
arguments: data, arguments: safeArgs,
user_agent: userAgent, user_agent: userAgent,
}); });
+7 -2
View File
@@ -52,7 +52,7 @@ export const superTokensFunctionMiddleware = createMiddleware({
export const superTokensAdminFunctionMiddleware = createMiddleware({ export const superTokensAdminFunctionMiddleware = createMiddleware({
type: "function", type: "function",
}).server(async ({ next }) => { }).server(async ({ next, serverFnMeta }) => {
const request = getRequest(); const request = getRequest();
try { try {
@@ -62,7 +62,12 @@ export const superTokensAdminFunctionMiddleware = createMiddleware({
return next({ context }); return next({ context });
} }
logger.error("Unauthorized user in admin function.", context); 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"); throw new Error("Unauthorized");
} catch (error: any) { } catch (error: any) {
if (error.message === "SESSION_REFRESH_REQUIRED") { if (error.message === "SESSION_REFRESH_REQUIRED") {
+12
View File
@@ -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,
},
})