diff --git a/docs/package-lock.json b/docs/package-lock.json index dc2e6fee..5a0b97d2 100644 --- a/docs/package-lock.json +++ b/docs/package-lock.json @@ -9,7 +9,7 @@ "version": "1.0.0", "dependencies": { "@astrojs/starlight": "^0.41.7", - "astro": "^7.2.2", + "astro": "^7.2.4", "starlight-openapi": "^0.26.1" } }, @@ -173,61 +173,6 @@ "node": ">=14.0.0" } }, - "node_modules/@astrojs/compiler-binding-wasm32-wasi/node_modules/@emnapi/core": { - "version": "2.0.0-alpha.3", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-2.0.0-alpha.3.tgz", - "integrity": "sha512-AZypUeJ/yByuxyS7BlSNRDOMLMlROYtjYdIAuBmJssVz1UJDSeYxLrdizhXCFYhedC5bqd/ASy8EuNXbVVXp9g==", - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "@emnapi/wasi-threads": "2.0.1", - "tslib": "^2.4.0" - } - }, - "node_modules/@astrojs/compiler-binding-wasm32-wasi/node_modules/@emnapi/runtime": { - "version": "2.0.0-alpha.3", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-2.0.0-alpha.3.tgz", - "integrity": "sha512-hFPAhMUjJD9BSyCANEISPOogeXC9Zo9ZQl7L6vKnaVsMkCtzznaW/naYypeyl0Gv5rYfWYsZbpixTMpjDJzQeA==", - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, - "node_modules/@astrojs/compiler-binding-wasm32-wasi/node_modules/@emnapi/wasi-threads": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-2.0.1.tgz", - "integrity": "sha512-9DsSk+o5NBX0CCJT8s0EROGSGxjR/tKu6aBTaVyq+SjAEQH4XcdcRxPBRzsBLizTTJ49MJjF+jgu3qnO9GLQcQ==", - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, - "node_modules/@astrojs/compiler-binding-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.2.0.tgz", - "integrity": "sha512-kDoONqMa+VnZ4vvvu/ZUurpJ4gkZU57e7g69qpNgWhYcZFPUHZM2CEMKm+cG6ufDVALbjMvfmMjFVqaK7uEMnA==", - "license": "MIT", - "optional": true, - "dependencies": { - "@tybys/wasm-util": "^0.10.3" - }, - "engines": { - "node": "^20.19.0 || ^22.13.0 || >=23.5.0" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/Brooooooklyn" - }, - "peerDependencies": { - "@emnapi/core": "^2.0.0-alpha.3", - "@emnapi/runtime": "^2.0.0-alpha.3" - } - }, "node_modules/@astrojs/compiler-binding-win32-arm64-msvc": { "version": "0.3.2", "resolved": "https://registry.npmjs.org/@astrojs/compiler-binding-win32-arm64-msvc/-/compiler-binding-win32-arm64-msvc-0.3.2.tgz", @@ -288,42 +233,200 @@ "unified": "^11.0.5" } }, - "node_modules/@astrojs/markdown-remark": { - "version": "7.2.2", - "resolved": "https://registry.npmjs.org/@astrojs/markdown-remark/-/markdown-remark-7.2.2.tgz", - "integrity": "sha512-FGfmK84zSNcrsBd0dl1gXE9JvZYElp8EXQa2jpHVAxG4deGKAp43wspxFupjADJX7MSsMRHwYCnfT6EyVmgeFQ==", + "node_modules/@astrojs/markdown-satteri": { + "version": "0.3.7", + "resolved": "https://registry.npmjs.org/@astrojs/markdown-satteri/-/markdown-satteri-0.3.7.tgz", + "integrity": "sha512-NHcHbrKW/opbZnTZQ5nH293BdcK2VV0tjuzI88CLnvy2njiEJVwUQ5KFnYd4NoWgHJCY/I1CyjGWCR4Vv3khXQ==", "license": "MIT", "dependencies": { - "@astrojs/internal-helpers": "0.10.2", + "@astrojs/internal-helpers": "0.10.4", "@astrojs/prism": "4.0.2", "github-slugger": "^2.0.0", - "hast-util-from-html": "^2.0.3", - "hast-util-to-text": "^4.0.2", - "mdast-util-definitions": "^6.0.0", - "rehype-raw": "^7.0.0", - "rehype-stringify": "^10.0.1", - "remark-gfm": "^4.0.1", - "remark-parse": "^11.0.0", - "remark-rehype": "^11.1.2", - "remark-smartypants": "^3.0.2", - "unified": "^11.0.5", - "unist-util-remove-position": "^5.0.0", - "unist-util-visit": "^5.1.0", - "unist-util-visit-parents": "^6.0.2", - "vfile": "^6.0.3" + "satteri": "^0.10.3" } }, - "node_modules/@astrojs/markdown-satteri": { - "version": "0.3.5", - "resolved": "https://registry.npmjs.org/@astrojs/markdown-satteri/-/markdown-satteri-0.3.5.tgz", - "integrity": "sha512-CvWVEFAbay7YO+i9SaqDJubipA5ckiVB89QWoMJ5XC0m5CtFg8JwZ7Kau6X9sYY7FZURH0w2l03ISH2jOS/RDQ==", + "node_modules/@astrojs/markdown-satteri/node_modules/@astrojs/internal-helpers": { + "version": "0.10.4", + "resolved": "https://registry.npmjs.org/@astrojs/internal-helpers/-/internal-helpers-0.10.4.tgz", + "integrity": "sha512-nozZSy/mKYLqe4YrqbKtdOszedAfXYCtw3wZ0d+CAjz4GqQ4L9rl1ltIL5BlgwmYVinJg/RZ0MgGuWOdlyRZlA==", "license": "MIT", "dependencies": { - "@astrojs/internal-helpers": "0.10.2", - "@astrojs/prism": "4.0.2", - "github-slugger": "^2.0.0", - "hast-util-from-html": "^2.0.3", - "satteri": "^0.9.1" + "@types/hast": "^3.0.4", + "@types/mdast": "^4.0.4", + "js-yaml": "^4.3.0", + "picomatch": "^4.0.4", + "retext-smartypants": "^6.2.0", + "shiki": "^4.0.2", + "smol-toml": "^1.6.0", + "unified": "^11.0.5" + } + }, + "node_modules/@astrojs/markdown-satteri/node_modules/@bruits/satteri-darwin-arm64": { + "version": "0.10.5", + "resolved": "https://registry.npmjs.org/@bruits/satteri-darwin-arm64/-/satteri-darwin-arm64-0.10.5.tgz", + "integrity": "sha512-27KTVl4TJkVahMy/ohyA7qd4938G5UNneFUz/PsScYfpIhj0IVAS23mpcJXdPF44sa6nva198lmV/cKIb2YPyA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@astrojs/markdown-satteri/node_modules/@bruits/satteri-darwin-x64": { + "version": "0.10.5", + "resolved": "https://registry.npmjs.org/@bruits/satteri-darwin-x64/-/satteri-darwin-x64-0.10.5.tgz", + "integrity": "sha512-IjnLe3nKspq6qaeqGgjT7MT8VrTV74yWRlaag7ZdNsI8TDAYZ0iPxMCo+9KQZHUk5EyVB+reBI/PFWL5KuFw9Q==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@astrojs/markdown-satteri/node_modules/@bruits/satteri-linux-arm64-gnu": { + "version": "0.10.5", + "resolved": "https://registry.npmjs.org/@bruits/satteri-linux-arm64-gnu/-/satteri-linux-arm64-gnu-0.10.5.tgz", + "integrity": "sha512-glkYXZCJywjP13v67eAyAMSJdF+ncvEbYvgi/wOtffL9tQ27lr/zsyzUfgs+ovjJ9d8JNQKiXeiArJcX8PJL9w==", + "cpu": [ + "arm64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@astrojs/markdown-satteri/node_modules/@bruits/satteri-linux-arm64-musl": { + "version": "0.10.5", + "resolved": "https://registry.npmjs.org/@bruits/satteri-linux-arm64-musl/-/satteri-linux-arm64-musl-0.10.5.tgz", + "integrity": "sha512-yWdgG1g17Nh2QyGVlFUxGRa3FEFwiMcpZEyMNWkbM3deC94cmVc+/i9OuyFpdKuWo3GkgoCtYVOoxk1uCnCZIA==", + "cpu": [ + "arm64" + ], + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@astrojs/markdown-satteri/node_modules/@bruits/satteri-linux-x64-gnu": { + "version": "0.10.5", + "resolved": "https://registry.npmjs.org/@bruits/satteri-linux-x64-gnu/-/satteri-linux-x64-gnu-0.10.5.tgz", + "integrity": "sha512-FVaLoPT1fBgGl0J+AYebyyXJYBachGl8Oyyrf1lye4RTqCB4S0Gwkj1uM9RJyThUOvx5VUmAT1CnNh1SFHA+kw==", + "cpu": [ + "x64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@astrojs/markdown-satteri/node_modules/@bruits/satteri-linux-x64-musl": { + "version": "0.10.5", + "resolved": "https://registry.npmjs.org/@bruits/satteri-linux-x64-musl/-/satteri-linux-x64-musl-0.10.5.tgz", + "integrity": "sha512-EHpVAx2bqW3GINHTKkljtxVfQmVDGWIuwOYOP5YghTj+0PkBa2o8oKPRtQ9Kbsr1Fye8jtUcDjhwj2jMNugZKg==", + "cpu": [ + "x64" + ], + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@astrojs/markdown-satteri/node_modules/@bruits/satteri-wasm32-wasi": { + "version": "0.10.5", + "resolved": "https://registry.npmjs.org/@bruits/satteri-wasm32-wasi/-/satteri-wasm32-wasi-0.10.5.tgz", + "integrity": "sha512-ypz8c/Zmipxp4IoeDa228Gstv6TLzVmNs3yC6wKCoNSOjx1iwpgzu87Y3hTkXFdwChVGU85qeUDuOIarGUZQLw==", + "cpu": [ + "wasm32" + ], + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.11.1", + "@emnapi/runtime": "1.11.1", + "@napi-rs/wasm-runtime": "^1.2.3" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@astrojs/markdown-satteri/node_modules/@bruits/satteri-win32-arm64-msvc": { + "version": "0.10.5", + "resolved": "https://registry.npmjs.org/@bruits/satteri-win32-arm64-msvc/-/satteri-win32-arm64-msvc-0.10.5.tgz", + "integrity": "sha512-siTV88nb0LRqNpkL2gXboqCwVdq95sLtzMHS1/3eONV2gLbB3NAK46wmSMvCO/yquBvI2lvaFIfd8P12ecsxBw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@astrojs/markdown-satteri/node_modules/@bruits/satteri-win32-x64-msvc": { + "version": "0.10.5", + "resolved": "https://registry.npmjs.org/@bruits/satteri-win32-x64-msvc/-/satteri-win32-x64-msvc-0.10.5.tgz", + "integrity": "sha512-C3IfPvfvMXmlzBxaMPKFS1XiuV9pu2mC7YqkPk7PSvTgPZ8gbdASIpHpztDLvTTQjqZ0z1Ol8tK5X+V6XXC0wQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@astrojs/markdown-satteri/node_modules/@emnapi/core": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz", + "integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.2", + "tslib": "^2.4.0" + } + }, + "node_modules/@astrojs/markdown-satteri/node_modules/satteri": { + "version": "0.10.5", + "resolved": "https://registry.npmjs.org/satteri/-/satteri-0.10.5.tgz", + "integrity": "sha512-Ao1LKpAEa9Wdg0otgbVKViZHEq9ebdXe4DMrp3s9vQAU0HNIuHnFEuMuOcm0ZIXyV0Yzxj91NvhLpvXZJO/5ZQ==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.5", + "@types/hast": "^3.0.5", + "@types/mdast": "^4.0.4", + "@types/unist": "^3.0.3" + }, + "optionalDependencies": { + "@bruits/satteri-darwin-arm64": "0.10.5", + "@bruits/satteri-darwin-x64": "0.10.5", + "@bruits/satteri-linux-arm64-gnu": "0.10.5", + "@bruits/satteri-linux-arm64-musl": "0.10.5", + "@bruits/satteri-linux-x64-gnu": "0.10.5", + "@bruits/satteri-linux-x64-musl": "0.10.5", + "@bruits/satteri-wasm32-wasi": "0.10.5", + "@bruits/satteri-win32-arm64-msvc": "0.10.5", + "@bruits/satteri-win32-x64-msvc": "0.10.5" } }, "node_modules/@astrojs/mdx": { @@ -360,6 +463,31 @@ } } }, + "node_modules/@astrojs/mdx/node_modules/@astrojs/markdown-remark": { + "version": "7.2.2", + "resolved": "https://registry.npmjs.org/@astrojs/markdown-remark/-/markdown-remark-7.2.2.tgz", + "integrity": "sha512-FGfmK84zSNcrsBd0dl1gXE9JvZYElp8EXQa2jpHVAxG4deGKAp43wspxFupjADJX7MSsMRHwYCnfT6EyVmgeFQ==", + "license": "MIT", + "dependencies": { + "@astrojs/internal-helpers": "0.10.2", + "@astrojs/prism": "4.0.2", + "github-slugger": "^2.0.0", + "hast-util-from-html": "^2.0.3", + "hast-util-to-text": "^4.0.2", + "mdast-util-definitions": "^6.0.0", + "rehype-raw": "^7.0.0", + "rehype-stringify": "^10.0.1", + "remark-gfm": "^4.0.1", + "remark-parse": "^11.0.0", + "remark-rehype": "^11.1.2", + "remark-smartypants": "^3.0.2", + "unified": "^11.0.5", + "unist-util-remove-position": "^5.0.0", + "unist-util-visit": "^5.1.0", + "unist-util-visit-parents": "^6.0.2", + "vfile": "^6.0.3" + } + }, "node_modules/@astrojs/prism": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/@astrojs/prism/-/prism-4.0.2.tgz", @@ -1728,21 +1856,24 @@ } }, "node_modules/@napi-rs/wasm-runtime": { - "version": "1.1.6", - "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz", - "integrity": "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==", + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.2.3.tgz", + "integrity": "sha512-UMduMbqO5s5zF2NkNacMT/yK5Y5QiKvWr2+50bzIIxFDwVJ2h49b+oyjaCGPhJxd2/gC2x39EHv/gHVuu36x2Q==", "license": "MIT", "optional": true, "dependencies": { "@tybys/wasm-util": "^0.10.3" }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=23.5.0" + }, "funding": { "type": "github", "url": "https://github.com/sponsors/Brooooooklyn" }, "peerDependencies": { - "@emnapi/core": "^1.7.1", - "@emnapi/runtime": "^1.7.1" + "@emnapi/core": "^1.7.1 || ^2.0.0-alpha.4", + "@emnapi/runtime": "^1.7.1 || ^2.0.0-alpha.4" } }, "node_modules/@oslojs/encoding": { @@ -2318,9 +2449,9 @@ } }, "node_modules/@types/hast": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.4.tgz", - "integrity": "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==", + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.5.tgz", + "integrity": "sha512-rp/ezSWaD1m44dPKICGhiskI13nVr7qTloFwDa/IYkhhf5nzwP+zIQcIJh3WIFSBOy/H1PzB40jPjMDksN4F+g==", "license": "MIT", "dependencies": { "@types/unist": "*" @@ -2551,14 +2682,14 @@ } }, "node_modules/astro": { - "version": "7.2.2", - "resolved": "https://registry.npmjs.org/astro/-/astro-7.2.2.tgz", - "integrity": "sha512-OvLWCpXpb43lVYcWzSBJ0sk44qcEYYRZCjadtalLfAwb2RaC3P/zT+blZykBw/o6suw6sQQkn00ugN36akD8Mw==", + "version": "7.2.4", + "resolved": "https://registry.npmjs.org/astro/-/astro-7.2.4.tgz", + "integrity": "sha512-+cuLsBns2wwUHI9a10xZMbjrF91m7+QNwqTVeljTx0B8Lf+8h0LgVGjdVIL2FALDKD2I565lczeeS3BFC+KdZg==", "license": "MIT", "dependencies": { "@astrojs/compiler-rs": "^0.3.2", - "@astrojs/internal-helpers": "0.10.2", - "@astrojs/markdown-satteri": "0.3.5", + "@astrojs/internal-helpers": "0.10.4", + "@astrojs/markdown-satteri": "0.3.7", "@astrojs/telemetry": "3.3.3", "@capsizecss/unpack": "^4.0.0", "@clack/prompts": "^1.1.0", @@ -2602,7 +2733,7 @@ "tinyexec": "^1.0.4", "tinyglobby": "^0.2.15", "ultrahtml": "^1.6.0", - "unifont": "~0.7.4", + "unifont": "~0.7.5", "unstorage": "^1.17.5", "vite": "^8.0.13", "vitefu": "^1.1.2", @@ -2626,7 +2757,7 @@ "sharp": "^0.34.0 || ^0.35.0" }, "peerDependencies": { - "@astrojs/markdown-remark": "7.2.2" + "@astrojs/markdown-remark": "7.2.4" }, "peerDependenciesMeta": { "@astrojs/markdown-remark": { @@ -2647,10 +2778,26 @@ "astro": "^4.0.0-beta || ^5.0.0-beta || ^3.3.0 || ^6.0.0-beta || ^7.0.0" } }, + "node_modules/astro/node_modules/@astrojs/internal-helpers": { + "version": "0.10.4", + "resolved": "https://registry.npmjs.org/@astrojs/internal-helpers/-/internal-helpers-0.10.4.tgz", + "integrity": "sha512-nozZSy/mKYLqe4YrqbKtdOszedAfXYCtw3wZ0d+CAjz4GqQ4L9rl1ltIL5BlgwmYVinJg/RZ0MgGuWOdlyRZlA==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.4", + "@types/mdast": "^4.0.4", + "js-yaml": "^4.3.0", + "picomatch": "^4.0.4", + "retext-smartypants": "^6.2.0", + "shiki": "^4.0.2", + "smol-toml": "^1.6.0", + "unified": "^11.0.5" + } + }, "node_modules/astro/node_modules/magic-string": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.2.0.tgz", - "integrity": "sha512-ptco+HFxTLgjafSLim2LojBSwfg5feBjd+SqyiwdGkzC38UPdZy3zgrHMI2CoTf5fJL38tbHMYWVzIH8BxGqJw==", + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.2.2.tgz", + "integrity": "sha512-veT/+7iXrXzT39XnEN4lOxtNl72dMgJ8Lp+5Bd6YcMSWpb0n0MjBM8Uuooi6jgJr8dhUW2swQgBmoZVMni5SVg==", "license": "MIT", "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" @@ -7157,6 +7304,15 @@ "integrity": "sha512-Ql87qFHB3s/De2ClA9e0gsnS6zXG27SkTiSJwjCc9MebbfapQfuPzumMIUMi38ezPZVNFcHI9sUIepeQfw8J8Q==", "license": "MIT" }, + "node_modules/undici": { + "version": "8.10.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-8.10.0.tgz", + "integrity": "sha512-HvltHd7avK13QIw/oLe4qoOLyoVSoafqJ2jYOrtMRBkbYT31eiBQ8O0ehRKZiEZCMEyLFQNIADpgCWC5fALvYQ==", + "license": "MIT", + "engines": { + "node": ">=22.19.0" + } + }, "node_modules/undici-types": { "version": "7.16.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", @@ -7183,14 +7339,14 @@ } }, "node_modules/unifont": { - "version": "0.7.4", - "resolved": "https://registry.npmjs.org/unifont/-/unifont-0.7.4.tgz", - "integrity": "sha512-oHeis4/xl42HUIeHuNZRGEvxj5AaIKR+bHPNegRq5LV1gdc3jundpONbjglKpihmJf+dswygdMJn3eftGIMemg==", + "version": "0.7.5", + "resolved": "https://registry.npmjs.org/unifont/-/unifont-0.7.5.tgz", + "integrity": "sha512-ULe/Cs+ZIsq+dcFofNkhqielCrUJnb5mr+Yc4EBM2VlL+6OZR6+cjtI2mT1bJvRBrVncqHAbLURxmPLcCXzWMg==", "license": "MIT", "dependencies": { "css-tree": "^3.1.0", - "ofetch": "^1.5.1", - "ohash": "^2.0.11" + "ohash": "^2.0.11", + "undici": "^8.0.0" } }, "node_modules/unist-util-find-after": { diff --git a/docs/package.json b/docs/package.json index c8551048..5df50534 100644 --- a/docs/package.json +++ b/docs/package.json @@ -15,7 +15,7 @@ }, "dependencies": { "@astrojs/starlight": "^0.41.7", - "astro": "^7.2.2", + "astro": "^7.2.4", "starlight-openapi": "^0.26.1" } } diff --git a/package-lock.json b/package-lock.json index 0e60290d..85fca243 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,16 +14,16 @@ "dependencies": { "@fxembed/atmosphere": "*", "@hono/sentry": "^1.2.2", - "@hono/zod-openapi": "^1.6.0", + "@hono/zod-openapi": "^1.6.1", "cheerio": "^1.2.0", - "hono": "^4.13.2", + "hono": "^4.13.3", "i18next": "^26.3.6", "i18next-icu": "^2.4.4", "zod": "^4.4.3" }, "devDependencies": { - "@aws-sdk/client-s3": "^3.1110.0", - "@aws-sdk/s3-request-presigner": "^3.1110.0", + "@aws-sdk/client-s3": "^3.1111.0", + "@aws-sdk/s3-request-presigner": "^3.1111.0", "@cloudflare/vitest-pool-workers": "^0.21.3", "@cloudflare/workers-types": "^4.20260702.1", "@eslint/js": "^10.0.1", @@ -43,7 +43,7 @@ "tsx": "^4.23.12", "typescript": "^6.0.3", "typescript-eslint": "^8.67.0", - "vitest": "^4.1.10", + "vitest": "^4.1.11", "wrangler": "^4.107.1" } }, @@ -60,14 +60,14 @@ } }, "node_modules/@aws-sdk/checksums": { - "version": "3.1000.27", - "resolved": "https://registry.npmjs.org/@aws-sdk/checksums/-/checksums-3.1000.27.tgz", - "integrity": "sha512-insWOqKKNUrbN/dohEG7BJ0U5GkyqhjbMb/NHNaLUtq+7my2M8C4EnZZZoxMmXRqCC+P9dEr+KyJA2JGGzoKLg==", + "version": "3.1000.28", + "resolved": "https://registry.npmjs.org/@aws-sdk/checksums/-/checksums-3.1000.28.tgz", + "integrity": "sha512-VCpnmyHQ1IH49ni3LXnQj7DPr7rmcJmzYeiCkYdCcfgNtkvOj38cdcL9lapBWoItZWFACJPFJlymqC7/gem3Gw==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.977.7", - "@aws-sdk/types": "^3.974.3", + "@aws-sdk/core": "^3.977.8", + "@aws-sdk/types": "^3.974.4", "@smithy/core": "^3.31.1", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" @@ -77,18 +77,18 @@ } }, "node_modules/@aws-sdk/client-s3": { - "version": "3.1110.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/client-s3/-/client-s3-3.1110.0.tgz", - "integrity": "sha512-40xbEcWjdaYKlZ4/NvndIJ3LotAEQAvHVQ7Z4NVy4Z4xGRN7xXJlHI9bMh/4aMJQ++6h5W5sv+wqjfk0rEKOBg==", + "version": "3.1111.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-s3/-/client-s3-3.1111.0.tgz", + "integrity": "sha512-VnLT6aSTN8tWl/NsXUysXNZor7wQBp9CRwufo7kt8cwGXvHLZ0S/cV1K9WFcREGboVYSo3NGQ3ZvU7LRidh2aQ==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@aws-sdk/checksums": "^3.1000.27", - "@aws-sdk/core": "^3.977.7", - "@aws-sdk/credential-provider-node": "^3.972.79", - "@aws-sdk/middleware-sdk-s3": "^3.972.73", - "@aws-sdk/signature-v4-multi-region": "^3.996.44", - "@aws-sdk/types": "^3.974.3", + "@aws-sdk/checksums": "^3.1000.28", + "@aws-sdk/core": "^3.977.8", + "@aws-sdk/credential-provider-node": "^3.972.80", + "@aws-sdk/middleware-sdk-s3": "^3.972.74", + "@aws-sdk/signature-v4-multi-region": "^3.996.45", + "@aws-sdk/types": "^3.974.4", "@smithy/core": "^3.31.1", "@smithy/fetch-http-handler": "^5.6.13", "@smithy/node-http-handler": "^4.9.13", @@ -100,14 +100,14 @@ } }, "node_modules/@aws-sdk/core": { - "version": "3.977.7", - "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.977.7.tgz", - "integrity": "sha512-I88Iov89NVmjSmJLKSv7Cn9M2J+a2942OkA8nZCbz+sl4ZeY4zEOcoLOrbt1GRfQ8zEQKnjAJdXixA3J/p1fDQ==", + "version": "3.977.8", + "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.977.8.tgz", + "integrity": "sha512-7+Kcrkvrk9lM/m7jRhHpT4jCdvzGHsuaSRbF8TdzzkY1mRzp/Ogwf9c7H29k4gGhey0BBWhCWr16+t0J61gwmg==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@aws-sdk/types": "^3.974.3", - "@aws-sdk/xml-builder": "^3.972.38", + "@aws-sdk/types": "^3.974.4", + "@aws-sdk/xml-builder": "^3.972.39", "@aws/lambda-invoke-store": "^0.3.0", "@smithy/core": "^3.31.1", "@smithy/signature-v4": "^5.6.12", @@ -120,14 +120,14 @@ } }, "node_modules/@aws-sdk/credential-provider-env": { - "version": "3.972.68", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.68.tgz", - "integrity": "sha512-2a20A/IdNOwUvaDq91iqqS7BA0XlNMfW3iLGZGZLJv0EbUqhSxB0PIx4rQQqssvWj1uXImb3/UCCdHz/+1dOiA==", + "version": "3.972.69", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.69.tgz", + "integrity": "sha512-AreCFzcB4kH2HF9031Ot0jSJr3KXvRg6e8uDeub20JEVdZU3Bv0sTq1plc7VsT3KiqutlzH7l0j50UcCWHUioA==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.977.7", - "@aws-sdk/types": "^3.974.3", + "@aws-sdk/core": "^3.977.8", + "@aws-sdk/types": "^3.974.4", "@smithy/core": "^3.31.1", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" @@ -137,14 +137,14 @@ } }, "node_modules/@aws-sdk/credential-provider-http": { - "version": "3.972.70", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.70.tgz", - "integrity": "sha512-0yRem2Fs52r/Nn6UAqIlpjexfaYj8ziEozOe9tamtAVT/5bzFLKx8O2r7MaRqgS3hGKHIa1Jij9nKHSsNnb04A==", + "version": "3.972.71", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.71.tgz", + "integrity": "sha512-A8ObcqVmDMnk4F9NozZ7JwmUu9Q4xyBJkmyq1C5U+wNM9ht9J7+EuuyabsLWXZnOoTqFaJuYBYTKf5CTipkEjA==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.977.7", - "@aws-sdk/types": "^3.974.3", + "@aws-sdk/core": "^3.977.8", + "@aws-sdk/types": "^3.974.4", "@smithy/core": "^3.31.1", "@smithy/fetch-http-handler": "^5.6.13", "@smithy/node-http-handler": "^4.9.13", @@ -156,21 +156,21 @@ } }, "node_modules/@aws-sdk/credential-provider-ini": { - "version": "3.973.13", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.973.13.tgz", - "integrity": "sha512-2M39DE02XpYYaSWYk/4AsImXYUU/1L2xmTMLUpMMWq7DfLv191/vCRy3baKtdr45AkJQyVgSjmuVOLm15SwrRQ==", + "version": "3.973.14", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.973.14.tgz", + "integrity": "sha512-7c+Wti2LsERNWMfm7ySz3/6RPopFW3Nmn7s63Xpcq6R/tRuY5hpvkHA2xVgi5ukJbvok9l0IDtVEvqTtg+X7dw==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.977.7", - "@aws-sdk/credential-provider-env": "^3.972.68", - "@aws-sdk/credential-provider-http": "^3.972.70", - "@aws-sdk/credential-provider-login": "^3.972.75", - "@aws-sdk/credential-provider-process": "^3.972.68", - "@aws-sdk/credential-provider-sso": "^3.973.12", - "@aws-sdk/credential-provider-web-identity": "^3.972.74", - "@aws-sdk/nested-clients": "^3.997.42", - "@aws-sdk/types": "^3.974.3", + "@aws-sdk/core": "^3.977.8", + "@aws-sdk/credential-provider-env": "^3.972.69", + "@aws-sdk/credential-provider-http": "^3.972.71", + "@aws-sdk/credential-provider-login": "^3.972.76", + "@aws-sdk/credential-provider-process": "^3.972.69", + "@aws-sdk/credential-provider-sso": "^3.973.13", + "@aws-sdk/credential-provider-web-identity": "^3.972.75", + "@aws-sdk/nested-clients": "^3.997.43", + "@aws-sdk/types": "^3.974.4", "@smithy/core": "^3.31.1", "@smithy/credential-provider-imds": "^4.4.16", "@smithy/types": "^4.16.1", @@ -181,15 +181,15 @@ } }, "node_modules/@aws-sdk/credential-provider-login": { - "version": "3.972.75", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.75.tgz", - "integrity": "sha512-jaTESuJlQsoUZ44f/i2puyPt8VlF/dMMJ9HM3cStYtk7eKX4N9UWi83OLixUkoOJH3BwWlPLCq9YIK9nfWhVBg==", + "version": "3.972.76", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.76.tgz", + "integrity": "sha512-LVixwOnEJfrrfKHeZjBA8pIMTZjNDq8ak8VpcoWUuCJDrSnBNU8POJksULMgvN089P0MXtQYH2Zs627/MK1K0g==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.977.7", - "@aws-sdk/nested-clients": "^3.997.42", - "@aws-sdk/types": "^3.974.3", + "@aws-sdk/core": "^3.977.8", + "@aws-sdk/nested-clients": "^3.997.43", + "@aws-sdk/types": "^3.974.4", "@smithy/core": "^3.31.1", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" @@ -199,19 +199,19 @@ } }, "node_modules/@aws-sdk/credential-provider-node": { - "version": "3.972.79", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.79.tgz", - "integrity": "sha512-RIw5dof1EHkWubrZzPC941CDtnFG1iAXsxbFgLkhdYZXHc4icU13c/uxSMI0J5eUx9bxa7LjfpdjfClBB1QsDA==", + "version": "3.972.80", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.80.tgz", + "integrity": "sha512-bE2qh8ww4iClO1jHsBXdOE8FUgzDbdxbyorNjSCoPSkQd51k3jODItuPZfuwcLHZqDXsH+bI4AMHhqtuyR7mSg==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@aws-sdk/credential-provider-env": "^3.972.68", - "@aws-sdk/credential-provider-http": "^3.972.70", - "@aws-sdk/credential-provider-ini": "^3.973.13", - "@aws-sdk/credential-provider-process": "^3.972.68", - "@aws-sdk/credential-provider-sso": "^3.973.12", - "@aws-sdk/credential-provider-web-identity": "^3.972.74", - "@aws-sdk/types": "^3.974.3", + "@aws-sdk/credential-provider-env": "^3.972.69", + "@aws-sdk/credential-provider-http": "^3.972.71", + "@aws-sdk/credential-provider-ini": "^3.973.14", + "@aws-sdk/credential-provider-process": "^3.972.69", + "@aws-sdk/credential-provider-sso": "^3.973.13", + "@aws-sdk/credential-provider-web-identity": "^3.972.75", + "@aws-sdk/types": "^3.974.4", "@smithy/core": "^3.31.1", "@smithy/credential-provider-imds": "^4.4.16", "@smithy/types": "^4.16.1", @@ -222,14 +222,14 @@ } }, "node_modules/@aws-sdk/credential-provider-process": { - "version": "3.972.68", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.68.tgz", - "integrity": "sha512-nLP3Pda2MQTFJ25hKBMmUuB9Uv+bTZQNlufbeCwklP549Vwnkd8bRLJoCKp5k6xjmdyptrPrOfGOhN0mKuca8A==", + "version": "3.972.69", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.69.tgz", + "integrity": "sha512-9kpTNdZTrcqXTfhxM7fgl9Z68ek3Fu5oe3Yf+A/pJGibEqpgZxz2tSY7SinmyCIU2PJ+ygY4FPoBBnLpocMtrQ==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.977.7", - "@aws-sdk/types": "^3.974.3", + "@aws-sdk/core": "^3.977.8", + "@aws-sdk/types": "^3.974.4", "@smithy/core": "^3.31.1", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" @@ -239,16 +239,16 @@ } }, "node_modules/@aws-sdk/credential-provider-sso": { - "version": "3.973.12", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.973.12.tgz", - "integrity": "sha512-EmgyyHn+f9WCcelp3L/vci+LGbX8GigWaVphRArjVo5Pktkr9YnLy/mQ6VDkDyBD72dtfRNTgHmD2ts4rTDXKQ==", + "version": "3.973.13", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.973.13.tgz", + "integrity": "sha512-Oc81qauMPzUoTnAS2YKpNwY6sY/LUyQTEeaf6yP197WMxkEBQfcKLR1MFpD7+pNTubXnfkH6gwpji+Gc7iyD2Q==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.977.7", - "@aws-sdk/nested-clients": "^3.997.42", - "@aws-sdk/token-providers": "3.1108.0", - "@aws-sdk/types": "^3.974.3", + "@aws-sdk/core": "^3.977.8", + "@aws-sdk/nested-clients": "^3.997.43", + "@aws-sdk/token-providers": "3.1111.0", + "@aws-sdk/types": "^3.974.4", "@smithy/core": "^3.31.1", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" @@ -258,15 +258,15 @@ } }, "node_modules/@aws-sdk/credential-provider-web-identity": { - "version": "3.972.74", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.74.tgz", - "integrity": "sha512-0YfczxGXF3RjGj8z7QG/Ho2HnLGKDHfPSHiTs47UU1U/+mmwISDN+rvGKt2zh+3FX8NdT4xd95LGBGyhQw2dgQ==", + "version": "3.972.75", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.75.tgz", + "integrity": "sha512-YPN6uoGDgjjjeVFZrcOeCJqmB6zpXoeeNgIjqe+DexJaWqdjVfCCe+VAZwli9Z2h8KhFW8oxkO39emQ1tyz/Mw==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.977.7", - "@aws-sdk/nested-clients": "^3.997.42", - "@aws-sdk/types": "^3.974.3", + "@aws-sdk/core": "^3.977.8", + "@aws-sdk/nested-clients": "^3.997.43", + "@aws-sdk/types": "^3.974.4", "@smithy/core": "^3.31.1", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" @@ -276,15 +276,15 @@ } }, "node_modules/@aws-sdk/middleware-sdk-s3": { - "version": "3.972.73", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-sdk-s3/-/middleware-sdk-s3-3.972.73.tgz", - "integrity": "sha512-oy7sRA5HvHcAvkcKX6F8RI240jcOf3c8y/Gqjs9qemIibdKQqGBIi0uwa+47ZRYqGLpdEO28TQU4G73yUzo06Q==", + "version": "3.972.74", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-sdk-s3/-/middleware-sdk-s3-3.972.74.tgz", + "integrity": "sha512-2lzoV2z2QO5KJZYGOCnIZ1WVQgzMECvwuzr1xb034a++8QW4U4eGrmC2u4yg1xvNv4TLL/Uv5DLyuAiw0b9z7Q==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.977.7", - "@aws-sdk/signature-v4-multi-region": "^3.996.44", - "@aws-sdk/types": "^3.974.3", + "@aws-sdk/core": "^3.977.8", + "@aws-sdk/signature-v4-multi-region": "^3.996.45", + "@aws-sdk/types": "^3.974.4", "@smithy/core": "^3.31.1", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" @@ -294,15 +294,15 @@ } }, "node_modules/@aws-sdk/nested-clients": { - "version": "3.997.42", - "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.997.42.tgz", - "integrity": "sha512-XWRyon2MTHXD/zMoo0Mbge6Vwf+iE0qQaM/RyGO6NfZ9WukCFiQL27nQVZjYy2JwSIg+iXZxKOX95OBXqlSM4w==", + "version": "3.997.43", + "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.997.43.tgz", + "integrity": "sha512-bit+VpqWNyi3wHxFoTsTliNXimCSL2r2OeDTm7ZrG+YsTZ2D7ofDJ6r/t9PVBn80i6/v0X2h9Tgw6QP2MAKfPw==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.977.7", - "@aws-sdk/signature-v4-multi-region": "^3.996.44", - "@aws-sdk/types": "^3.974.3", + "@aws-sdk/core": "^3.977.8", + "@aws-sdk/signature-v4-multi-region": "^3.996.45", + "@aws-sdk/types": "^3.974.4", "@smithy/core": "^3.31.1", "@smithy/fetch-http-handler": "^5.6.13", "@smithy/node-http-handler": "^4.9.13", @@ -314,15 +314,15 @@ } }, "node_modules/@aws-sdk/s3-request-presigner": { - "version": "3.1110.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/s3-request-presigner/-/s3-request-presigner-3.1110.0.tgz", - "integrity": "sha512-xr7vfMj3Mea1r1+GzdBT/9LpKN2l+xBMSzbGoj3Uia6ERPMjx55BeqVtVGBHWw5+IKSGCx5SVC7Jn+3FxntMLg==", + "version": "3.1111.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/s3-request-presigner/-/s3-request-presigner-3.1111.0.tgz", + "integrity": "sha512-ACp/VtDTw6AjFW5Q3M59uAMrBbAHeQS0UBIOIgUROO98Z3uhpiy37k9RmvxbXYVugmTcdNTy4DPVQtLG8sDy6g==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.977.7", - "@aws-sdk/signature-v4-multi-region": "^3.996.44", - "@aws-sdk/types": "^3.974.3", + "@aws-sdk/core": "^3.977.8", + "@aws-sdk/signature-v4-multi-region": "^3.996.45", + "@aws-sdk/types": "^3.974.4", "@smithy/core": "^3.31.1", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" @@ -332,13 +332,13 @@ } }, "node_modules/@aws-sdk/signature-v4-multi-region": { - "version": "3.996.44", - "resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.996.44.tgz", - "integrity": "sha512-ZSfQ35Qn4MhSY+A0Whyr+KBx+wJKZUyBsOrjB2pSHOafRzbFe47T8XcXM8hZqUAC69qnqIy0C9ArxTuud0CC2w==", + "version": "3.996.45", + "resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.996.45.tgz", + "integrity": "sha512-bBuyztukzXq6plzFGHAWiQt0QXo+HL8b8lX5cFTzkez/74PtS1c0qPFCIVuHkyoT+miH2qOjAcm1/yoro2ESPA==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@aws-sdk/types": "^3.974.3", + "@aws-sdk/types": "^3.974.4", "@smithy/signature-v4": "^5.6.12", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" @@ -348,15 +348,15 @@ } }, "node_modules/@aws-sdk/token-providers": { - "version": "3.1108.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1108.0.tgz", - "integrity": "sha512-rI80zxDxGJ6904eC/YbjkdjY6JdaZvQ01kOmrMvw7cFQGIHo27fhnIVbMSVDS4T6foQImjxYSRoOu/uSJscXDw==", + "version": "3.1111.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1111.0.tgz", + "integrity": "sha512-JfljgoVtl+s3Qy21n9a7Z48uCQaOXcN74KJ3TEQfPoB293GrXFSt6HSQJF1sTZ8c/5QedEvd3NjJQMO4u9qa5A==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.977.7", - "@aws-sdk/nested-clients": "^3.997.42", - "@aws-sdk/types": "^3.974.3", + "@aws-sdk/core": "^3.977.8", + "@aws-sdk/nested-clients": "^3.997.43", + "@aws-sdk/types": "^3.974.4", "@smithy/core": "^3.31.1", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" @@ -366,9 +366,9 @@ } }, "node_modules/@aws-sdk/types": { - "version": "3.974.3", - "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.974.3.tgz", - "integrity": "sha512-ECAqfpNsef+7MO8qtR0h9KcFIBAygaE7Cm6UOiQl+ft+uVap+1G7bNEjs4mdJE2OnA4m6k7i8peH8uGIAsOMGw==", + "version": "3.974.4", + "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.974.4.tgz", + "integrity": "sha512-dSFDNG00MEz0/xl5gxL62giLd1iYyJsTxZ1I1DOj6lC+bbgLB4TRsYClJg3b62dhXT1uATzsTNXPnC+33EJV3A==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -380,9 +380,9 @@ } }, "node_modules/@aws-sdk/xml-builder": { - "version": "3.972.38", - "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.38.tgz", - "integrity": "sha512-grf7mzfVxBS5AlsuTvBN7uDpzqohFww9fRPCO+EBSUdvtsYMcPSKdz54h/7XiscqNcUM1Ae1MF7JLHmiYYuzbQ==", + "version": "3.972.39", + "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.39.tgz", + "integrity": "sha512-FTti8DS5MMWXNUWiRwXAJeYS+0GHHiMy0+7XOhcwk63ILHmfS2UFy2z/HNpZCSOJJ3P3dnWY6hfYNW3DF0nXUA==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -2304,9 +2304,9 @@ } }, "node_modules/@hono/zod-openapi": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/@hono/zod-openapi/-/zod-openapi-1.6.0.tgz", - "integrity": "sha512-42HXUBIaGmQh+hZGLw3Hy5piOreIXgnBnUSs55mtn2gnW/xWtU9nQwEUtRRCk5to8Vm2XbfO0J83eUCwCf5eTg==", + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@hono/zod-openapi/-/zod-openapi-1.6.1.tgz", + "integrity": "sha512-z1xS3FZxl4bBkU3kMIsQsLtsqxPTha5XZCsDEPMSZ6+3RRBQI7KED1GeoDpq2WueXCacsNIjcB0MtCnEHY7ahQ==", "license": "MIT", "dependencies": { "@asteasolutions/zod-to-openapi": "^9.1.0", @@ -4403,16 +4403,16 @@ "license": "ISC" }, "node_modules/@vitest/expect": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.10.tgz", - "integrity": "sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.11.tgz", + "integrity": "sha512-VX2x5vNJXET47KAFzwERI+KRMtTTCSWTfSMKsW7JsUsXV4psq++e3DvZpuTDOpHcxytiDs6p2nhVb2tVDiiUYw==", "dev": true, "license": "MIT", "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", - "@vitest/spy": "4.1.10", - "@vitest/utils": "4.1.10", + "@vitest/spy": "4.1.11", + "@vitest/utils": "4.1.11", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" }, @@ -4421,13 +4421,13 @@ } }, "node_modules/@vitest/mocker": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.10.tgz", - "integrity": "sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.11.tgz", + "integrity": "sha512-2XJVD55d1o5AZous5CCGKS74g/riOj9odEt2bQpCVZeblHyHdnMeFl4jl0XjU21stf4mbjUkew2eXQZt65g5CQ==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/spy": "4.1.10", + "@vitest/spy": "4.1.11", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, @@ -4448,9 +4448,9 @@ } }, "node_modules/@vitest/pretty-format": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.10.tgz", - "integrity": "sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.11.tgz", + "integrity": "sha512-yiZzPbGTS9Sr/JpFl8zHrcIkAofNbFV6k21vIgQN/cY/oxZeXhJv5sc/MBJ5jFKWmWs+oJHw0UXLZjmf931+Vw==", "dev": true, "license": "MIT", "dependencies": { @@ -4461,13 +4461,13 @@ } }, "node_modules/@vitest/runner": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.10.tgz", - "integrity": "sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.11.tgz", + "integrity": "sha512-LztvUgdwMNJMIkj3hQnnxiC2Xy1zNxq928W/xhjCLaNCzqTZOudjwbQf6v9IntZGPw132i2Lq2rgTRZHD3JHNw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/utils": "4.1.10", + "@vitest/utils": "4.1.11", "pathe": "^2.0.3" }, "funding": { @@ -4475,14 +4475,14 @@ } }, "node_modules/@vitest/snapshot": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.10.tgz", - "integrity": "sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.11.tgz", + "integrity": "sha512-pN7ikn1ON7h8ee4gIAp4AzyK+zBtJPzVbqOgu5LCEh4VaJVbPQcgYQYJIMGQPXVeJJq1fnfazis7a5pFNPahog==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.1.10", - "@vitest/utils": "4.1.10", + "@vitest/pretty-format": "4.1.11", + "@vitest/utils": "4.1.11", "magic-string": "^0.30.21", "pathe": "^2.0.3" }, @@ -4491,9 +4491,9 @@ } }, "node_modules/@vitest/spy": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.10.tgz", - "integrity": "sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.11.tgz", + "integrity": "sha512-apNa/prQy2qCeywhnixOHPRCgGNhvg7T4Dapfl1GahLp/R+uhBm5cPyFoNVyqsNd2h1nJxL6BqqdIjiABL60YA==", "dev": true, "license": "MIT", "funding": { @@ -4501,13 +4501,13 @@ } }, "node_modules/@vitest/utils": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.10.tgz", - "integrity": "sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.11.tgz", + "integrity": "sha512-zTCVGpyFsGWBhllOyKlTw/vnr6D9qxsfSDyfbyZmTyjHw5N/VuvzHpHoQjm2ZJzn4RJgx5w4r7V0er69CmLgPQ==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.1.10", + "@vitest/pretty-format": "4.1.11", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" }, @@ -5952,9 +5952,9 @@ } }, "node_modules/hono": { - "version": "4.13.2", - "resolved": "https://registry.npmjs.org/hono/-/hono-4.13.2.tgz", - "integrity": "sha512-JydRilDRkYBQMt9qR9U92mXxmbGqsqSn/IKOrh4e7/gEbn+0zSr8igTu0obwJoNGN4sez28DIql7FBHWydoJpA==", + "version": "4.13.3", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.13.3.tgz", + "integrity": "sha512-r8AO2mYHoLxSHkgafNeC/BXyb2vWRxD3jem4Ts+ptav8oTG5FIRifAjuJEmZI4bSvvc2ns0GxmIYiZnHqN3mMw==", "license": "MIT", "engines": { "node": ">=16.9.0" @@ -8018,19 +8018,19 @@ } }, "node_modules/vitest": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.10.tgz", - "integrity": "sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.11.tgz", + "integrity": "sha512-fhACrNXUidIbGSBr5FlbuBkO7VWC1ZyLl0DO4CU2DrQoAPxX84Ysxs+HeGQpii5lZWV1Q4gBZTTu49mF+A6Edw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/expect": "4.1.10", - "@vitest/mocker": "4.1.10", - "@vitest/pretty-format": "4.1.10", - "@vitest/runner": "4.1.10", - "@vitest/snapshot": "4.1.10", - "@vitest/spy": "4.1.10", - "@vitest/utils": "4.1.10", + "@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", @@ -8058,12 +8058,12 @@ "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", - "@vitest/browser-playwright": "4.1.10", - "@vitest/browser-preview": "4.1.10", - "@vitest/browser-webdriverio": "4.1.10", - "@vitest/coverage-istanbul": "4.1.10", - "@vitest/coverage-v8": "4.1.10", - "@vitest/ui": "4.1.10", + "@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": "*", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" @@ -8452,13 +8452,13 @@ "version": "0.0.1", "license": "MIT", "dependencies": { - "@hono/zod-openapi": "^1.6.0", + "@hono/zod-openapi": "^1.6.1", "zod": "^4.4.3" }, "devDependencies": { "@types/node": "^24.13.3", "typescript": "^6.0.3", - "vitest": "^4.1.10" + "vitest": "^4.1.11" } }, "packages/atmosphere/node_modules/@types/node": { diff --git a/package.json b/package.json index 899bb959..522dbc4a 100644 --- a/package.json +++ b/package.json @@ -33,8 +33,8 @@ "author": "dangered wolf", "license": "MIT", "devDependencies": { - "@aws-sdk/client-s3": "^3.1110.0", - "@aws-sdk/s3-request-presigner": "^3.1110.0", + "@aws-sdk/client-s3": "^3.1111.0", + "@aws-sdk/s3-request-presigner": "^3.1111.0", "@cloudflare/vitest-pool-workers": "^0.21.3", "@cloudflare/workers-types": "^4.20260702.1", "@eslint/js": "^10.0.1", @@ -54,15 +54,15 @@ "tsx": "^4.23.12", "typescript": "^6.0.3", "typescript-eslint": "^8.67.0", - "vitest": "^4.1.10", + "vitest": "^4.1.11", "wrangler": "^4.107.1" }, "dependencies": { "@fxembed/atmosphere": "*", "@hono/sentry": "^1.2.2", - "@hono/zod-openapi": "^1.6.0", + "@hono/zod-openapi": "^1.6.1", "cheerio": "^1.2.0", - "hono": "^4.13.2", + "hono": "^4.13.3", "i18next": "^26.3.6", "i18next-icu": "^2.4.4", "zod": "^4.4.3" diff --git a/packages/atmosphere/package.json b/packages/atmosphere/package.json index 6e4ec7c4..cb55719c 100644 --- a/packages/atmosphere/package.json +++ b/packages/atmosphere/package.json @@ -108,10 +108,10 @@ "devDependencies": { "@types/node": "^24.13.3", "typescript": "^6.0.3", - "vitest": "^4.1.10" + "vitest": "^4.1.11" }, "dependencies": { - "@hono/zod-openapi": "^1.6.0", + "@hono/zod-openapi": "^1.6.1", "zod": "^4.4.3" } } diff --git a/packages/atmosphere/src/providers/bluesky/client.ts b/packages/atmosphere/src/providers/bluesky/client.ts index 7143f5c5..b74f5d96 100644 --- a/packages/atmosphere/src/providers/bluesky/client.ts +++ b/packages/atmosphere/src/providers/bluesky/client.ts @@ -398,6 +398,67 @@ export const fetchSearchPosts = async ( return result; }; +/** + * `app.bsky.actor.searchActors` against the public AppView. Unlike `searchPosts`, actor search is + * served unauthenticated, so this works on a plain AppView with no proxy credentials. + */ +export const fetchSearchActors = async ( + params: { + q: string; + limit: number; + cursor?: string; + }, + opts?: BlueskyFetchOpts +): Promise< + { ok: true; data: BlueskySearchActorsResponse } | { ok: false; status: number; body: string } +> => { + const result = await executeBlueskyXrpc( + 'app.bsky.actor.searchActors', + { + q: params.q, + limit: params.limit, + cursor: params.cursor + }, + opts + ); + if (!result.ok) { + console.log('Bluesky searchActors failed', result.status, result.body?.slice?.(0, 200)); + } + return result; +}; + +/** + * `app.bsky.actor.searchActorsTypeahead` — the prefix-matching sibling of `searchActors`, meant + * for autocomplete while the user is still typing. No cursor: it answers one short page by design. + */ +export const fetchSearchActorsTypeahead = async ( + params: { + q: string; + limit: number; + }, + opts?: BlueskyFetchOpts +): Promise< + | { ok: true; data: BlueskySearchActorsTypeaheadResponse } + | { ok: false; status: number; body: string } +> => { + const result = await executeBlueskyXrpc( + 'app.bsky.actor.searchActorsTypeahead', + { + q: params.q, + limit: params.limit + }, + opts + ); + if (!result.ok) { + console.log( + 'Bluesky searchActorsTypeahead failed', + result.status, + result.body?.slice?.(0, 200) + ); + } + return result; +}; + const GET_PROFILES_MAX_ACTORS = 25; /** Batch `app.bsky.actor.getProfiles` (max 25 actors per request per lexicon). */ diff --git a/packages/atmosphere/src/providers/bluesky/search.ts b/packages/atmosphere/src/providers/bluesky/search.ts index 514c2013..56ab3300 100644 --- a/packages/atmosphere/src/providers/bluesky/search.ts +++ b/packages/atmosphere/src/providers/bluesky/search.ts @@ -1,6 +1,12 @@ -import type { APIBlueskyStatus, APISearchResultsBluesky } from '../../types/api-schemas.js'; +import type { + APIBlueskyStatus, + APISearchResultsBluesky, + APITypeaheadResponse, + APIUserListResults +} from '../../types/api-schemas.js'; import { buildAPIBlueskyPost } from './processor.js'; -import { fetchSearchPosts } from './client.js'; +import { fetchSearchActors, fetchSearchActorsTypeahead, fetchSearchPosts } from './client.js'; +import { blueskyProfileViewToApiUser } from './profileFollowers.js'; import { isBlueskyGalleryEmbed } from './gallery.js'; import type { BlueskyBuildHost } from './build-host.js'; @@ -101,3 +107,87 @@ export const blueskySearchAPI = async ( cursor: { top: null, bottom: nextCursor } }; }; + +/** + * People search via `app.bsky.actor.searchActors`. Returns the same `APIUserListResults` envelope + * as the follower and repost lists, so a client renders one profile list whatever produced it. + * + * `#profileView` carries no counts, so `followers`, `following`, and `statuses` come back 0 — a + * client that needs them fetches the full profile. `getFollowers` and `getLikes` behave the same. + */ +export const blueskySearchUsersAPI = async ( + options: { + q: string; + count: number; + cursor: string | null; + }, + opts?: { credentialKey?: string } +): Promise => { + const result = await fetchSearchActors( + { + q: options.q, + limit: options.count, + cursor: options.cursor ?? undefined + }, + { credentialKey: opts?.credentialKey } + ); + + if (!result.ok) { + if (result.status === 400 || result.status === 404) { + return { code: 404, results: [], cursor: { top: null, bottom: null } }; + } + return { code: 500, results: [], cursor: { top: null, bottom: null } }; + } + + const actors = result.data.actors ?? []; + + return { + code: 200, + results: actors.map(blueskyProfileViewToApiUser), + cursor: { top: null, bottom: result.data.cursor ?? null } + }; +}; + +/** + * Autocomplete while the user types, via `app.bsky.actor.searchActorsTypeahead`. + * + * Same envelope as FxTwitter `/2/typeahead` so one client call site serves both networks, but + * Bluesky indexes only accounts — there is no hashtag or event autocomplete behind it, so + * `topics` and `events` are always empty rather than absent. + */ +export const blueskyTypeaheadAPI = async ( + options: { + q: string; + count: number; + }, + opts?: { credentialKey?: string } +): Promise => { + const empty = (code: number): APITypeaheadResponse => ({ + code, + query: options.q, + num_results: 0, + users: [], + topics: [], + events: [] + }); + + const result = await fetchSearchActorsTypeahead( + { q: options.q, limit: options.count }, + { credentialKey: opts?.credentialKey } + ); + + if (!result.ok) { + return empty(result.status === 400 || result.status === 404 ? 404 : 500); + } + + const users = (result.data.actors ?? []).map(blueskyProfileViewToApiUser); + + return { + code: 200, + query: options.q, + num_results: users.length, + users, + topics: [], + events: [] + }; +}; diff --git a/packages/atmosphere/src/providers/mastodon/client.ts b/packages/atmosphere/src/providers/mastodon/client.ts index 40c484e3..12992339 100644 --- a/packages/atmosphere/src/providers/mastodon/client.ts +++ b/packages/atmosphere/src/providers/mastodon/client.ts @@ -36,15 +36,24 @@ const mastodonFetchInit = (signal: AbortSignal): RequestInit => ({ const isRedirectStatus = (s: number): boolean => s === 301 || s === 302 || s === 303 || s === 307 || s === 308; +/** + * Result of the redirect hop. Tagged explicitly rather than by `instanceof Response`: the two + * arms are told apart by a field we set ourselves, so this does not depend on the runtime's + * `Response` global being the same class the local `fetch` constructs. It is not, on React + * Native — and an `instanceof` test there silently returned the raw `Response` as if it were a + * result object, giving callers `ok: true` with `data: undefined`. + */ +type MastodonRedirectResult = { redirected: true; res: Response } | MastodonFetchErr; + /** Single same-host hop (e.g. trailing slash / canonical URL) without following cross-origin redirects. */ async function resolveMastodonRedirectIfNeeded( initialUrl: string, res: Response, expectedHost: string, signal: AbortSignal -): Promise { +): Promise { if (!isRedirectStatus(res.status)) { - return res; + return { redirected: true, res }; } const loc = res.headers.get('Location'); if (!loc) { @@ -63,11 +72,11 @@ async function resolveMastodonRedirectIfNeeded( body: `Mastodon redirect to different host rejected (${resolved.hostname})` }; } - return fetch(resolved.href, mastodonFetchInit(signal)); + return { redirected: true, res: await fetch(resolved.href, mastodonFetchInit(signal)) }; } -function isMastodonFetchErr(x: Response | MastodonFetchErr): x is MastodonFetchErr { - return !(x instanceof Response); +function isMastodonFetchErr(x: MastodonRedirectResult): x is MastodonFetchErr { + return !('redirected' in x); } async function mastodonFetch( @@ -92,7 +101,7 @@ async function mastodonFetch( clearTimeout(t); return afterRedirect; } - res = afterRedirect; + res = afterRedirect.res; } catch (e) { clearTimeout(t); const msg = e instanceof Error ? e.message : String(e); @@ -221,6 +230,25 @@ export const fetchAccountFollowing = async ( { limit: params.limit, max_id: params.max_id } ); +/** + * `GET /api/v2/search?type=accounts`. Unlike the statuses half of the same endpoint, account + * search is served to unauthenticated callers, and unlike `/api/v1/accounts/search` it does not + * require a token at all. `resolve` stays off so a query never makes the instance go fetch a + * remote account on our behalf. + */ +export const searchAccounts = async ( + domain: string, + q: string, + params: { limit: number; offset?: number } +): Promise> => + mastodonFetch(domain, '/api/v2/search', { + q, + type: 'accounts', + resolve: false, + limit: params.limit, + offset: params.offset + }); + export const searchStatuses = async ( domain: string, q: string, diff --git a/packages/atmosphere/src/providers/mastodon/search.ts b/packages/atmosphere/src/providers/mastodon/search.ts index 10ae21b0..9cc66a5c 100644 --- a/packages/atmosphere/src/providers/mastodon/search.ts +++ b/packages/atmosphere/src/providers/mastodon/search.ts @@ -1,6 +1,15 @@ -import type { APIMastodonStatus, APISearchResultsMastodon } from '../../types/api-schemas.js'; -import { assertSafeMastodonDomain, nextMaxIdFromLinkHeader, searchStatuses } from './client.js'; -import { buildAPIMastodonPost } from './processor.js'; +import type { + APIMastodonStatus, + APISearchResultsMastodon, + APIUserListResults +} from '../../types/api-schemas.js'; +import { + assertSafeMastodonDomain, + nextMaxIdFromLinkHeader, + searchAccounts, + searchStatuses +} from './client.js'; +import { buildAPIMastodonPost, mastodonAccountToApiUser } from './processor.js'; import type { MastodonBuildHost } from './build-host.js'; const decodeCursor = (cursor: string | null): { max_id?: string; offset?: number } | undefined => { @@ -114,3 +123,47 @@ export const mastodonSearchAPI = async ( cursor: { top: null, bottom } }; }; + +/** + * People search via `GET /api/v2/search?type=accounts`. + * + * Mastodon has no cross-instance account index, so results are whatever the queried instance has + * already federated. A large, well-connected instance therefore finds far more accounts than a + * small one — the domain the caller picks is the search corpus, not just the transport. + * + * Single page by design: Mastodon serves the first page of search to anonymous callers but + * answers any `offset` with 401 ("Search queries pagination is not supported without + * authentication"), and Atmosphere talks to instances unauthenticated. Emitting a cursor here + * would only hand the caller a "load more" that always fails, so `cursor.bottom` stays null. + */ +export const mastodonSearchUsersAPI = async ( + domain: string, + options: { + q: string; + count: number; + } +): Promise => { + try { + assertSafeMastodonDomain(domain); + } catch { + return { code: 400, results: [], cursor: { top: null, bottom: null } }; + } + + const result = await searchAccounts(domain, options.q, { limit: options.count }); + + if (!result.ok) { + if (result.status === 401) { + return { code: 401, results: [], cursor: { top: null, bottom: null } }; + } + if (result.status === 404 || result.status === 400) { + return { code: 404, results: [], cursor: { top: null, bottom: null } }; + } + return { code: 500, results: [], cursor: { top: null, bottom: null } }; + } + + return { + code: 200, + results: (result.data.accounts ?? []).map(a => mastodonAccountToApiUser(a, domain)), + cursor: { top: null, bottom: null } + }; +}; diff --git a/packages/atmosphere/src/providers/twitter/proxy/errors.ts b/packages/atmosphere/src/providers/twitter/proxy/errors.ts index 57dec96d..fa14ed47 100644 --- a/packages/atmosphere/src/providers/twitter/proxy/errors.ts +++ b/packages/atmosphere/src/providers/twitter/proxy/errors.ts @@ -205,6 +205,11 @@ const ERROR_RULES: ErrorRule[] = [ match: ({ json }) => parseSearchTimelineClientError(json) === 'blocklisted', disposition: 'ignore', log: 'SearchTimeline blocklisted query (expected client error)' + }, + { + match: ({ json }) => parseSearchTimelineClientError(json) === 'query_too_long', + disposition: 'ignore', + log: 'SearchTimeline query exceeds max length (expected client error)' } ]; diff --git a/packages/atmosphere/src/providers/twitter/search.ts b/packages/atmosphere/src/providers/twitter/search.ts index 695c0db0..7a2c0924 100644 --- a/packages/atmosphere/src/providers/twitter/search.ts +++ b/packages/atmosphere/src/providers/twitter/search.ts @@ -6,9 +6,17 @@ import { isTombstone } from '../../helpers/tombstone.js'; import { isSearchTimelineClientErrorResponse, parseSearchTimelineClientError, - searchTimelineClientErrorToApiQueryError + searchQueryTooLongError, + searchTimelineClientErrorToApiQueryError, + TWITTER_SEARCH_RAW_QUERY_MAX_LENGTH } from './searchErrors.js'; -import type { APISearchResults, APITwitterStatus, ApiQueryError } from '../../types/api-schemas.js'; +import type { + APISearchResults, + APITwitterStatus, + APIUserListResults, + ApiQueryError +} from '../../types/api-schemas.js'; +import { convertToApiUser } from './profile.js'; import type { FetchResults } from '../../types/fetch-results.js'; import type { TwitterBuildHost } from './build-host.js'; @@ -436,6 +444,10 @@ export const searchAPI = async ( host: TwitterBuildHost, language?: string ): Promise => { + if (query.length > TWITTER_SEARCH_RAW_QUERY_MAX_LENGTH) { + return searchQueryTooLongError(query.length); + } + const product = feedToProduct(feed); let response: TwitterSearchTimelineResponse | null; @@ -500,3 +512,66 @@ export const searchAPI = async ( } }; }; + +/** + * People search — the same `SearchTimeline` query with `product: 'People'`, which returns + * `TimelineUser` rows instead of tweets. Reuses the follower/reposter row extractor, so the + * envelope matches `/2/profile/{handle}/followers` and a client renders one profile list. + */ +export const searchUsersAPI = async ( + query: string, + count: number, + cursor: string | null, + host: TwitterBuildHost, + language?: string +): Promise => { + if (query.length > TWITTER_SEARCH_RAW_QUERY_MAX_LENGTH) { + return searchQueryTooLongError(query.length); + } + + let response: TwitterSearchTimelineResponse | null; + + try { + response = (await graphqlRequest(host, { + query: SearchTimelineQuery, + variables: { + rawQuery: query, + count, + product: 'People', + cursor: cursor ?? null + }, + headers: buildLanguageHeaders(language), + validator: (_response: unknown) => { + if (isSearchTimelineClientErrorResponse(_response)) { + return true; + } + const r = _response as TwitterSearchTimelineResponse; + return Array.isArray(r?.data?.search_by_raw_query?.search_timeline?.timeline?.instructions); + } + })) as TwitterSearchTimelineResponse; + } catch (e) { + console.error('User search request failed', e); + return { code: 500, results: [], cursor: { top: null, bottom: null } }; + } + + const clientError = parseSearchTimelineClientError(response); + if (clientError) { + return searchTimelineClientErrorToApiQueryError(clientError); + } + + const instructions = response?.data?.search_by_raw_query?.search_timeline?.timeline?.instructions; + if (!instructions) { + return { code: 404, results: [], cursor: { top: null, bottom: null } }; + } + + const { users, cursors } = processUserRelationshipTimelineInstructionsImpl(instructions); + + return { + code: 200, + results: users.map(user => convertToApiUser(user)), + cursor: { + top: cursors.find(cur => cur.cursorType === 'Top')?.value ?? null, + bottom: cursors.find(cur => cur.cursorType === 'Bottom')?.value ?? null + } + }; +}; diff --git a/packages/atmosphere/src/providers/twitter/searchErrors.ts b/packages/atmosphere/src/providers/twitter/searchErrors.ts index 8c294075..b99326dc 100644 --- a/packages/atmosphere/src/providers/twitter/searchErrors.ts +++ b/packages/atmosphere/src/providers/twitter/searchErrors.ts @@ -1,6 +1,22 @@ import type { ApiQueryError } from '../../types/api-schemas.js'; -export type SearchTimelineClientErrorKind = 'empty_query' | 'blocklisted'; +/** X SearchTimeline rejects `rawQuery` longer than this; match that cap on FxTwitter `/2/search`. */ +export const TWITTER_SEARCH_RAW_QUERY_MAX_LENGTH = 512; + +export type SearchTimelineClientErrorKind = 'empty_query' | 'blocklisted' | 'query_too_long'; + +const QUERY_TOO_LONG_RE = /Raw query length \d+ exceeds max allowed \d+/i; + +export function formatSearchQueryTooLongMessage(length: number): string { + return `Raw query length ${length} exceeds max allowed ${TWITTER_SEARCH_RAW_QUERY_MAX_LENGTH}`; +} + +export function searchQueryTooLongError(length: number): ApiQueryError { + return { + code: 400, + message: formatSearchQueryTooLongMessage(length) + }; +} function asRecord(value: unknown): Record | undefined { if (value !== null && typeof value === 'object') { @@ -49,6 +65,9 @@ export function parseSearchTimelineClientError( if (message.includes('denylisted in Search Content Control tool')) { return 'blocklisted'; } + if (QUERY_TOO_LONG_RE.test(message)) { + return 'query_too_long'; + } return null; } @@ -70,5 +89,10 @@ export function searchTimelineClientErrorToApiQueryError( code: 400, message: 'Search query is blocked by X content controls' }; + case 'query_too_long': + return { + code: 400, + message: `Raw query length exceeds max allowed ${TWITTER_SEARCH_RAW_QUERY_MAX_LENGTH}` + }; } } diff --git a/packages/atmosphere/src/raw/vendor/bluesky.d.ts b/packages/atmosphere/src/raw/vendor/bluesky.d.ts index f372a7bd..b07206e9 100644 --- a/packages/atmosphere/src/raw/vendor/bluesky.d.ts +++ b/packages/atmosphere/src/raw/vendor/bluesky.d.ts @@ -325,6 +325,17 @@ declare type BlueskySearchPostsResponse = { hitsTotal?: number; }; +/** `app.bsky.actor.searchActors` output (subset; `actors` are `#profileView`). */ +declare type BlueskySearchActorsResponse = { + actors?: BlueskyProfileView[]; + cursor?: string; +}; + +/** `app.bsky.actor.searchActorsTypeahead` output (subset; no cursor — prefix autocomplete only). */ +declare type BlueskySearchActorsTypeaheadResponse = { + actors?: BlueskyProfileView[]; +}; + /** `app.bsky.unspecced.getTrendingTopics` (subset; public AppView). */ declare type BlueskyTrendingTopicRow = { topic: string; diff --git a/src/providers/mastodon/atmosphere-handlers.ts b/src/providers/mastodon/atmosphere-handlers.ts index 2571f812..bb1d2ab0 100644 --- a/src/providers/mastodon/atmosphere-handlers.ts +++ b/src/providers/mastodon/atmosphere-handlers.ts @@ -14,6 +14,7 @@ import { mastodonProfileMediaV2Route, mastodonProfileStatusesV2Route, mastodonProfileV2Route, + mastodonSearchUsersV2Route, mastodonSearchV2Route, mastodonStatusLikesV2Route, mastodonStatusRepostsV2Route, @@ -33,7 +34,10 @@ import { mastodonProfileMediaAPI, mastodonProfileStatusesAPI } from '@fxembed/atmosphere/providers/mastodon/profileStatuses'; -import { mastodonSearchAPI } from '@fxembed/atmosphere/providers/mastodon/search'; +import { + mastodonSearchAPI, + mastodonSearchUsersAPI +} from '@fxembed/atmosphere/providers/mastodon/search'; import { mastodonStatusLikesAPI } from '@fxembed/atmosphere/providers/mastodon/statusLikes'; import { mastodonStatusRepostsAPI } from '@fxembed/atmosphere/providers/mastodon/statusReposts'; import { mastodonBuildHostFromContext } from './build-host-adapter'; @@ -163,6 +167,25 @@ export const mastodonSearchAPIRequest: RouteHandler(c, payload, httpStatus); }; +export const mastodonSearchUsersAPIRequest: RouteHandler< + typeof mastodonSearchUsersV2Route +> = async c => { + const { domain } = c.req.valid('param'); + const query = c.req.valid('query'); + const searchResponse = await mastodonSearchUsersAPI(domain, { + q: query.q, + count: query.count ?? 30 + }); + const { httpStatus, payload } = normalizeApiJsonResponse( + searchResponse, + [200, 400, 401, 404, 500] as const, + 'mastodonSearchUsersAPIRequest' + ); + c.status(httpStatus); + setApiHeaders(c); + return jsonAfterNormalize(c, payload, httpStatus); +}; + export const mastodonProfileAPIRequest: RouteHandler = async c => { const { domain, handle } = c.req.valid('param'); const processedResponse = await mastodonUserProfileAPI(handle, domain); diff --git a/src/providers/mastodon/atmosphere-register.ts b/src/providers/mastodon/atmosphere-register.ts index 13f79f60..715b1faa 100644 --- a/src/providers/mastodon/atmosphere-register.ts +++ b/src/providers/mastodon/atmosphere-register.ts @@ -7,6 +7,7 @@ import { mastodonProfileMediaAPIRequest, mastodonProfileStatusesAPIRequest, mastodonSearchAPIRequest, + mastodonSearchUsersAPIRequest, mastodonStatusAPIRequest, mastodonStatusLikesAPIRequest, mastodonStatusRepostsAPIRequest, @@ -19,6 +20,7 @@ import { mastodonProfileMediaV2Route, mastodonProfileStatusesV2Route, mastodonProfileV2Route, + mastodonSearchUsersV2Route, mastodonSearchV2Route, mastodonStatusLikesV2Route, mastodonStatusRepostsV2Route, @@ -33,6 +35,7 @@ export const registerMastodonAtmosphereRoutes = (atmosphere: OpenAPIHono) => { atmosphere.openapi(mastodonThreadV2Route, mastodonThreadAPIRequest); atmosphere.openapi(mastodonConversationV2Route, mastodonConversationAPIRequest); atmosphere.openapi(mastodonSearchV2Route, mastodonSearchAPIRequest); + atmosphere.openapi(mastodonSearchUsersV2Route, mastodonSearchUsersAPIRequest); atmosphere.openapi(mastodonProfileV2Route, mastodonProfileAPIRequest); atmosphere.openapi(mastodonProfileFollowersV2Route, mastodonProfileFollowersAPIRequest); atmosphere.openapi(mastodonProfileFollowingV2Route, mastodonProfileFollowingAPIRequest); diff --git a/src/providers/mastodon/atmosphere-routes.ts b/src/providers/mastodon/atmosphere-routes.ts index dd264df2..e4af25fa 100644 --- a/src/providers/mastodon/atmosphere-routes.ts +++ b/src/providers/mastodon/atmosphere-routes.ts @@ -255,6 +255,43 @@ export const mastodonSearchV2Route = createRoute({ } }); +export const mastodonSearchUsersV2Route = createRoute({ + method: 'get', + path: '/2/mastodon/{domain}/search/users', + summary: 'Search accounts on an instance', + description: + 'Uses Mastodon `GET /api/v2/search?type=accounts`, which (unlike the statuses half of the same endpoint) is served to unauthenticated callers. Same envelope as `/2/mastodon/{domain}/profile/{handle}/followers` (`code`, `results`, `cursor`). There is no cross-instance account index, so results are whatever `{domain}` has already federated — a large instance is a much larger corpus than a small one. Single page: Mastodon rejects any paginated search from an anonymous caller, so `cursor.bottom` is always null.', + request: { + params: domainParam, + query: z.object({ + q: mastodonSearchQueryString({ description: 'Search query', example: 'gargron' }), + count: z.coerce.number().int().min(1).max(100).optional().openapi({ default: 30 }) + }) + }, + responses: { + 200: { + description: 'Matching accounts', + content: { 'application/json': { schema: APIUserListResultsSchema } } + }, + 400: { + description: 'Invalid query', + content: { 'application/json': { schema: ApiQueryErrorSchema } } + }, + 401: { + description: 'Upstream requires authentication', + content: { 'application/json': { schema: APIUserListResultsSchema } } + }, + 404: { + description: 'No results', + content: { 'application/json': { schema: APIUserListResultsSchema } } + }, + 500: { + description: 'Upstream error', + content: { 'application/json': { schema: APIUserListResultsSchema } } + } + } +}); + export const mastodonProfileV2Route = createRoute({ method: 'get', path: '/2/mastodon/{domain}/profile/{handle}', diff --git a/src/realms/api/router.ts b/src/realms/api/router.ts index 6ae083bf..289b8ab1 100644 --- a/src/realms/api/router.ts +++ b/src/realms/api/router.ts @@ -15,6 +15,7 @@ import { profileFollowingAPIRequest, profileStatusesAPIRequest, searchAPIRequest, + searchUsersAPIRequest, statusAPIRequest, statusRepostsAPIRequest, statusQuotesAPIRequest, @@ -34,6 +35,7 @@ import { profileFollowingV2Route, profileStatusesV2Route, profileV2Route, + searchUsersV2Route, searchV2Route, statusV2Route, statusRepostsV2Route, @@ -77,6 +79,7 @@ api.openapi(profileFollowersV2Route, profileFollowersAPIRequest); api.openapi(profileFollowingV2Route, profileFollowingAPIRequest); api.openapi(profileV2Route, profileAPIRequest); api.openapi(searchV2Route, searchAPIRequest); +api.openapi(searchUsersV2Route, searchUsersAPIRequest); api.openapi(typeaheadV2Route, typeaheadAPIRequest); api.openapi(trendsV2Route, trendsAPIRequest); diff --git a/src/realms/api/routes.ts b/src/realms/api/routes.ts index 02493254..eb9e46bf 100644 --- a/src/realms/api/routes.ts +++ b/src/realms/api/routes.ts @@ -4,6 +4,10 @@ import { PUBLIC_EXPLORE_TIMELINE_KINDS, type PublicExploreTimelineKind } from '@fxembed/atmosphere/providers/twitter/trends'; +import { + formatSearchQueryTooLongMessage, + TWITTER_SEARCH_RAW_QUERY_MAX_LENGTH +} from '@fxembed/atmosphere/providers/twitter/searchErrors'; import { APIProfileRelationshipListSchema, APIUserListResultsSchema, @@ -22,13 +26,27 @@ import { const twitterSearchQueryHasEffectiveContent = (raw: string): boolean => raw.replace(/_/g, '').trim().length > 0; -const twitterSearchQueryString = (openapiMeta: { description: string; example: string }) => - z +const twitterSearchQueryString = (openapiMeta: { + description: string; + example: string; + maxLength?: number; +}) => { + const maxLength = openapiMeta.maxLength; + return z .string() .refine(twitterSearchQueryHasEffectiveContent, { message: 'Search query must not be empty' }) + .superRefine((value, ctx) => { + if (maxLength !== undefined && value.length > maxLength) { + ctx.addIssue({ + code: 'custom', + message: formatSearchQueryTooLongMessage(value.length) + }); + } + }) .openapi(openapiMeta); +}; const aboutAccountQuery = z.object({ about_account: z.string().optional().openapi({ @@ -560,8 +578,9 @@ export const searchV2Route = createRoute({ request: { query: z.object({ q: twitterSearchQueryString({ - description: 'Search query (non-empty)', - example: 'puppies' + description: `Search query (non-empty, max ${TWITTER_SEARCH_RAW_QUERY_MAX_LENGTH} characters)`, + example: 'puppies', + maxLength: TWITTER_SEARCH_RAW_QUERY_MAX_LENGTH }), feed: z.enum(['latest', 'top', 'media']).optional().openapi({ description: 'Search tab (default latest)', @@ -581,7 +600,7 @@ export const searchV2Route = createRoute({ content: { 'application/json': { schema: APISearchResultsSchema } } }, 400: { - description: 'Invalid `q` parameter', + description: `Invalid \`q\` parameter (empty or longer than ${TWITTER_SEARCH_RAW_QUERY_MAX_LENGTH} characters)`, content: { 'application/json': { schema: ApiQueryErrorSchema } } }, 404: { @@ -595,6 +614,47 @@ export const searchV2Route = createRoute({ } }); +export const searchUsersV2Route = createRoute({ + method: 'get', + path: '/2/search/users', + summary: 'Search people', + description: + 'Search accounts using the People tab of X search. Same envelope as `/2/profile/{handle}/followers` (`code`, `results`, `cursor`). Pass `cursor.bottom` back as `cursor` for the next page. `/2/typeahead` is the lighter, unpaginated autocomplete over the same corpus.', + request: { + query: z.object({ + q: twitterSearchQueryString({ + description: `Search query (non-empty, max ${TWITTER_SEARCH_RAW_QUERY_MAX_LENGTH} characters)`, + example: 'jack', + maxLength: TWITTER_SEARCH_RAW_QUERY_MAX_LENGTH + }), + count: z.coerce.number().int().min(1).max(100).optional().openapi({ + description: 'Page size (default 30)', + default: 30 + }), + cursor: z.string().optional(), + ...langQuery.shape + }) + }, + responses: { + 200: { + description: 'Matching accounts', + content: { 'application/json': { schema: APIUserListResultsSchema } } + }, + 400: { + description: `Invalid \`q\` parameter (empty or longer than ${TWITTER_SEARCH_RAW_QUERY_MAX_LENGTH} characters)`, + content: { 'application/json': { schema: ApiQueryErrorSchema } } + }, + 404: { + description: 'No results or timeline unavailable', + content: { 'application/json': { schema: APIUserListResultsSchema } } + }, + 500: { + description: 'Upstream or processing error', + content: { 'application/json': { schema: APIUserListResultsSchema } } + } + } +}); + const trendsTypeDescription = `Explore timeline kind. Supported: ${PUBLIC_EXPLORE_TIMELINE_KINDS.join(', ')}`; const typeaheadResultTypeDescription = diff --git a/src/realms/api/routes/twitter.ts b/src/realms/api/routes/twitter.ts index 6df3c00c..8d51a807 100644 --- a/src/realms/api/routes/twitter.ts +++ b/src/realms/api/routes/twitter.ts @@ -12,7 +12,7 @@ import { profileAboutAPI } from '@fxembed/atmosphere/providers/twitter/profile'; import { attachAboutAccountData } from '@fxembed/atmosphere/providers/twitter/aboutAccount'; -import { searchAPI } from '@fxembed/atmosphere/providers/twitter/search'; +import { searchAPI, searchUsersAPI } from '@fxembed/atmosphere/providers/twitter/search'; import { profileArticlesAPI, profileFollowersAPI, @@ -38,6 +38,7 @@ import { profileAboutV2Route, profileStatusesV2Route, profileV2Route, + searchUsersV2Route, searchV2Route, statusV2Route, statusRepostsV2Route, @@ -392,6 +393,29 @@ export const searchAPIRequest: RouteHandler = async c => { return jsonAfterNormalize(c, payload, httpStatus); }; +export const searchUsersAPIRequest: RouteHandler = async c => { + const host = twitterBuildHostFromContext(c); + const query = c.req.valid('query'); + + const searchResponse = await searchUsersAPI( + query.q, + query.count ?? 30, + query.cursor ?? null, + host, + query.lang + ); + const { httpStatus, payload } = normalizeApiJsonResponse( + searchResponse, + [200, 400, 404, 500] as const, + 'searchUsersAPIRequest' + ); + c.status(httpStatus); + for (const [header, value] of Object.entries(Constants.API_RESPONSE_HEADERS)) { + c.header(header, value); + } + return jsonAfterNormalize(c, payload, httpStatus); +}; + export const trendsAPIRequest: RouteHandler = async c => { const host = twitterBuildHostFromContext(c); const query = c.req.valid('query'); diff --git a/src/realms/bluesky-api/handlers.ts b/src/realms/bluesky-api/handlers.ts index 97ad6f77..d638453c 100644 --- a/src/realms/bluesky-api/handlers.ts +++ b/src/realms/bluesky-api/handlers.ts @@ -18,7 +18,11 @@ import { blueskyProfileFollowersAPI, blueskyProfileFollowingAPI } from '@fxembed/atmosphere/providers/bluesky/profileFollowers'; -import { blueskySearchAPI } from '@fxembed/atmosphere/providers/bluesky/search'; +import { + blueskySearchAPI, + blueskySearchUsersAPI, + blueskyTypeaheadAPI +} from '@fxembed/atmosphere/providers/bluesky/search'; import { blueskyTrendsAPI } from '@fxembed/atmosphere/providers/bluesky/trends'; import { blueskyStatusLikesAPI } from '@fxembed/atmosphere/providers/bluesky/statusLikes'; import { blueskyStatusRepostsAPI } from '@fxembed/atmosphere/providers/bluesky/statusReposts'; @@ -30,8 +34,10 @@ import { blueskyProfileMediaV2Route, blueskyProfileStatusesV2Route, blueskyProfileV2Route, + blueskySearchUsersV2Route, blueskySearchV2Route, blueskyTrendsV2Route, + blueskyTypeaheadV2Route, blueskyStatusLikesV2Route, blueskyStatusRepostsV2Route, blueskyStatusV2Route, @@ -213,6 +219,50 @@ export const blueskySearchAPIRequest: RouteHandler return jsonAfterNormalize(c, payload, httpStatus); }; +export const blueskySearchUsersAPIRequest: RouteHandler< + typeof blueskySearchUsersV2Route +> = async c => { + const query = c.req.valid('query'); + const searchResponse = await blueskySearchUsersAPI( + { + q: query.q, + count: query.count ?? 30, + cursor: query.cursor ?? null + }, + { credentialKey: c.env?.CREDENTIAL_KEY } + ); + + const { httpStatus, payload } = normalizeApiJsonResponse( + searchResponse, + [200, 400, 404, 500] as const, + 'blueskySearchUsersAPIRequest' + ); + c.status(httpStatus); + for (const [header, value] of Object.entries(Constants.API_RESPONSE_HEADERS)) { + c.header(header, value); + } + return jsonAfterNormalize(c, payload, httpStatus); +}; + +export const blueskyTypeaheadAPIRequest: RouteHandler = async c => { + const query = c.req.valid('query'); + const response = await blueskyTypeaheadAPI( + { q: query.q, count: query.count ?? 8 }, + { credentialKey: c.env?.CREDENTIAL_KEY } + ); + + const { httpStatus, payload } = normalizeApiJsonResponse( + response, + [200, 400, 404, 500] as const, + 'blueskyTypeaheadAPIRequest' + ); + c.status(httpStatus); + for (const [header, value] of Object.entries(Constants.API_RESPONSE_HEADERS)) { + c.header(header, value); + } + return jsonAfterNormalize(c, payload, httpStatus); +}; + export const blueskyTrendsAPIRequest: RouteHandler = async c => { const query = c.req.valid('query'); const type = query.type ?? 'trending'; diff --git a/src/realms/bluesky-api/router.ts b/src/realms/bluesky-api/router.ts index ca8c702e..a9958e6d 100644 --- a/src/realms/bluesky-api/router.ts +++ b/src/realms/bluesky-api/router.ts @@ -12,6 +12,8 @@ import { blueskyProfileMediaV2Route, blueskyProfileStatusesV2Route, blueskyProfileV2Route, + blueskySearchUsersV2Route, + blueskyTypeaheadV2Route, blueskySearchV2Route, blueskyTrendsV2Route, blueskyStatusLikesV2Route, @@ -28,6 +30,8 @@ import { blueskyProfileMediaAPIRequest, blueskyProfileStatusesAPIRequest, blueskySearchAPIRequest, + blueskySearchUsersAPIRequest, + blueskyTypeaheadAPIRequest, blueskyTrendsAPIRequest, blueskyStatusAPIRequest, blueskyStatusLikesAPIRequest, @@ -58,6 +62,8 @@ blueskyApi.openapi(blueskyStatusLikesV2Route, blueskyStatusLikesAPIRequest); blueskyApi.openapi(blueskyThreadV2Route, blueskyThreadAPIRequest); blueskyApi.openapi(blueskyConversationV2Route, blueskyConversationAPIRequest); blueskyApi.openapi(blueskySearchV2Route, blueskySearchAPIRequest); +blueskyApi.openapi(blueskySearchUsersV2Route, blueskySearchUsersAPIRequest); +blueskyApi.openapi(blueskyTypeaheadV2Route, blueskyTypeaheadAPIRequest); blueskyApi.openapi(blueskyTrendsV2Route, blueskyTrendsAPIRequest); blueskyApi.openapi(blueskyProfileV2Route, blueskyProfileAPIRequest); blueskyApi.openapi(blueskyProfileFollowersV2Route, blueskyProfileFollowersAPIRequest); diff --git a/src/realms/bluesky-api/routes.ts b/src/realms/bluesky-api/routes.ts index 3dc75fc2..251d1bc8 100644 --- a/src/realms/bluesky-api/routes.ts +++ b/src/realms/bluesky-api/routes.ts @@ -1,6 +1,7 @@ import { createRoute, z } from '@hono/zod-openapi'; import { APIProfileRelationshipListSchema, + APITypeaheadResponseSchema, APIUserListResultsSchema, ApiQueryErrorSchema, APISearchResultsBlueskySchema, @@ -314,6 +315,86 @@ export const blueskySearchV2Route = createRoute({ } }); +export const blueskySearchUsersV2Route = createRoute({ + method: 'get', + path: '/2/search/users', + summary: 'Search people', + description: + 'Search accounts via Bluesky `app.bsky.actor.searchActors`. Same envelope as `/2/profile/{handle}/followers` (`code`, `results`, `cursor`); pass `cursor.bottom` back as `cursor` for the next page. Rows come from `#profileView`, which carries no counts, so `followers`, `following`, and `statuses` are 0 — fetch `/2/profile/{handle}` for a full profile.', + request: { + query: z.object({ + q: blueskySearchQueryString({ + description: 'Search query (non-empty)', + example: 'jay' + }), + count: z.coerce.number().int().min(1).max(100).optional().openapi({ + description: 'Page size (default 30)', + default: 30 + }), + cursor: z + .string() + .optional() + .openapi({ description: 'Pagination cursor from prior response (`cursor.bottom`)' }) + }) + }, + responses: { + 200: { + description: 'Matching accounts', + content: { 'application/json': { schema: APIUserListResultsSchema } } + }, + 400: { + description: 'Invalid `q` parameter', + content: { 'application/json': { schema: ApiQueryErrorSchema } } + }, + 404: { + description: 'No results or search unavailable', + content: { 'application/json': { schema: APIUserListResultsSchema } } + }, + 500: { + description: 'Upstream or processing error', + content: { 'application/json': { schema: APIUserListResultsSchema } } + } + } +}); + +export const blueskyTypeaheadV2Route = createRoute({ + method: 'get', + path: '/2/typeahead', + summary: 'Search typeahead suggestions', + description: + 'Account autocomplete via Bluesky `app.bsky.actor.searchActorsTypeahead`, in the same envelope as FxTwitter `GET /2/typeahead`. Bluesky indexes only accounts for autocomplete, so `topics` and `events` are always empty. Unpaginated by design — use `/2/search/users` for a full, cursored people search.', + request: { + query: z.object({ + q: blueskySearchQueryString({ + description: 'Prefix or query string', + example: 'jac' + }), + count: z.coerce.number().int().min(1).max(50).optional().openapi({ + description: 'Maximum suggestions (default 8)', + default: 8 + }) + }) + }, + responses: { + 200: { + description: 'Typeahead payload', + content: { 'application/json': { schema: APITypeaheadResponseSchema } } + }, + 400: { + description: 'Invalid `q` parameter', + content: { 'application/json': { schema: ApiQueryErrorSchema } } + }, + 404: { + description: 'No suggestions or upstream returned an error payload', + content: { 'application/json': { schema: APITypeaheadResponseSchema } } + }, + 500: { + description: 'Upstream or processing error', + content: { 'application/json': { schema: APITypeaheadResponseSchema } } + } + } +}); + const blueskyTrendsTypeDescription = `Trend list. \`trending\` returns Bluesky live topics first, then suggested topic feeds to fill \`count\`. \`suggested\` returns only suggested feeds. Upstream: \`app.bsky.unspecced.getTrendingTopics\` (max 25 rows per request).`; export const blueskyTrendsV2Route = createRoute({ diff --git a/test/api.search.test.ts b/test/api.search.test.ts index fee08540..4267b3f6 100644 --- a/test/api.search.test.ts +++ b/test/api.search.test.ts @@ -1,5 +1,6 @@ import { test, expect } from 'vitest'; import type { APITwitterStatus } from '../src/realms/api/schemas'; +import { TWITTER_SEARCH_RAW_QUERY_MAX_LENGTH } from '@fxembed/atmosphere/providers/twitter/searchErrors'; import { app } from '../src/worker'; import { botHeaders, twitterBaseUrl } from './helpers/data'; import harness from './helpers/harness'; @@ -103,6 +104,38 @@ test('API search accepts count parameter', async () => { expect(response.code).toEqual(200); }); +test('API search rejects q longer than 512 characters with 400', async () => { + const tooLong = 'a'.repeat(TWITTER_SEARCH_RAW_QUERY_MAX_LENGTH + 1); + const result = await app.request( + new Request(`https://api.fxtwitter.com/2/search?q=${tooLong}`, { + method: 'GET', + headers: botHeaders + }), + undefined, + harness + ); + expect(result.status).toEqual(400); + const body = (await result.json()) as { code?: number; message?: string; success?: boolean }; + expect(body.code).toEqual(400); + expect(body.message).toContain( + `Raw query length ${tooLong.length} exceeds max allowed ${TWITTER_SEARCH_RAW_QUERY_MAX_LENGTH}` + ); + expect(body.success).toBeUndefined(); +}); + +test('API search accepts q of exactly 512 characters', async () => { + const atLimit = 'a'.repeat(TWITTER_SEARCH_RAW_QUERY_MAX_LENGTH); + const result = await app.request( + new Request(`https://api.fxtwitter.com/2/search?q=${atLimit}`, { + method: 'GET', + headers: botHeaders + }), + undefined, + harness + ); + expect(result.status).not.toEqual(400); +}); + test('API search returns 400 for upstream empty query error', async () => { const result = await app.request( new Request('https://api.fxtwitter.com/2/search?q=empty_query_error', { @@ -132,3 +165,18 @@ test('API search returns 400 for upstream blocklisted query error', async () => expect(body.code).toEqual(400); expect(body.message).toContain('blocked by X content controls'); }); + +test('API search returns 400 for upstream query too long error', async () => { + const result = await app.request( + new Request('https://api.fxtwitter.com/2/search?q=query_too_long_error', { + method: 'GET', + headers: botHeaders + }), + undefined, + harness + ); + expect(result.status).toEqual(400); + const body = (await result.json()) as { code?: number; message?: string }; + expect(body.code).toEqual(400); + expect(body.message).toContain(`exceeds max allowed ${TWITTER_SEARCH_RAW_QUERY_MAX_LENGTH}`); +}); diff --git a/test/mocks/SearchTimeline/query_too_long_error.json b/test/mocks/SearchTimeline/query_too_long_error.json new file mode 100644 index 00000000..4e435b1a --- /dev/null +++ b/test/mocks/SearchTimeline/query_too_long_error.json @@ -0,0 +1,21 @@ +{ + "errors": [ + { + "message": "BadRequest: Raw query length 2268 exceeds max allowed 512", + "locations": [{ "line": 4, "column": 7 }], + "path": ["search_by_raw_query", "search_timeline", "timeline"], + "extensions": { + "name": "BadRequestError", + "source": "Client", + "code": 214, + "kind": "Validation", + "tracing": { "trace_id": "00000000000000000000000000000000" } + }, + "code": 214, + "kind": "Validation", + "name": "BadRequestError", + "source": "Client" + } + ], + "data": {} +} diff --git a/test/searchTimelineErrors.test.ts b/test/searchTimelineErrors.test.ts index 6d8a0cf8..0b081fd0 100644 --- a/test/searchTimelineErrors.test.ts +++ b/test/searchTimelineErrors.test.ts @@ -1,8 +1,11 @@ import { test, expect } from 'vitest'; import { + formatSearchQueryTooLongMessage, isSearchTimelineClientErrorResponse, parseSearchTimelineClientError, - searchTimelineClientErrorToApiQueryError + searchQueryTooLongError, + searchTimelineClientErrorToApiQueryError, + TWITTER_SEARCH_RAW_QUERY_MAX_LENGTH } from '@fxembed/atmosphere/providers/twitter/searchErrors'; const emptyQueryError = { @@ -27,6 +30,17 @@ const blocklistedError = { data: {} }; +const queryTooLongError = { + errors: [ + { + message: 'BadRequest: Raw query length 2268 exceeds max allowed 512', + path: ['search_by_raw_query', 'search_timeline', 'timeline'], + code: 214 + } + ], + data: {} +}; + test('parseSearchTimelineClientError detects empty query', () => { expect(parseSearchTimelineClientError(emptyQueryError)).toBe('empty_query'); expect(isSearchTimelineClientErrorResponse(emptyQueryError)).toBe(true); @@ -36,6 +50,11 @@ test('parseSearchTimelineClientError detects blocklisted query', () => { expect(parseSearchTimelineClientError(blocklistedError)).toBe('blocklisted'); }); +test('parseSearchTimelineClientError detects query too long', () => { + expect(parseSearchTimelineClientError(queryTooLongError)).toBe('query_too_long'); + expect(isSearchTimelineClientErrorResponse(queryTooLongError)).toBe(true); +}); + test('parseSearchTimelineClientError ignores unrelated GraphQL errors', () => { expect( parseSearchTimelineClientError({ @@ -58,6 +77,11 @@ test('parseSearchTimelineClientError accepts known messages without path', () => errors: [{ message: 'BadRequest: Query is denylisted in Search Content Control tool.' }] }) ).toBe('blocklisted'); + expect( + parseSearchTimelineClientError({ + errors: [{ message: 'BadRequest: Raw query length 2268 exceeds max allowed 512' }] + }) + ).toBe('query_too_long'); }); test('searchTimelineClientErrorToApiQueryError maps to API 400 messages', () => { @@ -69,4 +93,18 @@ test('searchTimelineClientErrorToApiQueryError maps to API 400 messages', () => code: 400, message: 'Search query is blocked by X content controls' }); + expect(searchTimelineClientErrorToApiQueryError('query_too_long')).toEqual({ + code: 400, + message: `Raw query length exceeds max allowed ${TWITTER_SEARCH_RAW_QUERY_MAX_LENGTH}` + }); +}); + +test('searchQueryTooLongError includes the actual query length', () => { + expect(searchQueryTooLongError(2268)).toEqual({ + code: 400, + message: formatSearchQueryTooLongMessage(2268) + }); + expect(formatSearchQueryTooLongMessage(2268)).toBe( + 'Raw query length 2268 exceeds max allowed 512' + ); });