fix(audit): readable names, single writer, denial rows, redacted args
The activity log recorded Start's hashed function id (a sha256 URL
segment) as the name; the middleware now reads compile-time
serverFnMeta.name with the path segment as fallback. toServerResult
resolves { success:false } instead of throwing, so the middleware logged
failures as successes while toServerResult wrote a duplicate row with its
own dead name parser — the middleware is now the single writer and reads
the envelope's success flag. Admin denials, which threw before the logging
middleware ran, get their own audit row. Arguments are redacted
(phone/otp/token keys) and truncated at 2KB. The admin activities search
binds its filter parameters (likePattern escaping) instead of
interpolating raw input. Adds vitest with node-env tests for the
middleware, redaction, and filter utils, and extends logging coverage to
mutating fns that lacked it.
This commit is contained in:
@@ -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=="],
|
||||
|
||||
+3
-1
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<Activity[]> {
|
||||
const result = await pb.collection("activities").getList<Activity>(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<Activity[]> {
|
||||
const result = await pb.collection("activities").getList<Activity>(1, limit, {
|
||||
filter: `name = "${functionName}"`,
|
||||
filter: pbFilter(pb, "name = {:functionName}", { functionName }),
|
||||
sort: "-created",
|
||||
});
|
||||
return result.items;
|
||||
|
||||
@@ -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'"
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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<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);
|
||||
}
|
||||
);
|
||||
});
|
||||
@@ -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}`)}%`;
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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<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 { 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 <T>(
|
||||
serverFn: () => Promise<T>
|
||||
): Promise<ServerResult<T>> => {
|
||||
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) };
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
+79
-13
@@ -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,
|
||||
});
|
||||
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user