Merge branch 'main' into fix/1494-traditional-chinese-lang

This commit is contained in:
dangered wolf
2026-08-28 05:36:17 -04:00
committed by GitHub
25 changed files with 1201 additions and 304 deletions
+263 -107
View File
@@ -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": {
+1 -1
View File
@@ -15,7 +15,7 @@
},
"dependencies": {
"@astrojs/starlight": "^0.41.7",
"astro": "^7.2.2",
"astro": "^7.2.4",
"starlight-openapi": "^0.26.1"
}
}
+166 -166
View File
@@ -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": {
+5 -5
View File
@@ -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"
+2 -2
View File
@@ -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"
}
}
@@ -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<BlueskySearchActorsResponse>(
'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<BlueskySearchActorsTypeaheadResponse>(
'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). */
@@ -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<APIUserListResults> => {
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<APITypeaheadResponse> => {
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: []
};
};
@@ -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<Response | MastodonFetchErr> {
): Promise<MastodonRedirectResult> {
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<T>(
@@ -92,7 +101,7 @@ async function mastodonFetch<T>(
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<MastodonFetchResult<MastodonSearchResponse>> =>
mastodonFetch<MastodonSearchResponse>(domain, '/api/v2/search', {
q,
type: 'accounts',
resolve: false,
limit: params.limit,
offset: params.offset
});
export const searchStatuses = async (
domain: string,
q: string,
@@ -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<APIUserListResults> => {
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 }
};
};
@@ -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)'
}
];
@@ -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<APISearchResults | ApiQueryError> => {
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<APIUserListResults | ApiQueryError> => {
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
}
};
};
@@ -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<string, unknown> | 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}`
};
}
}
+11
View File
@@ -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;
+24 -1
View File
@@ -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<typeof mastodonSearchV2Route
return jsonAfterNormalize<typeof mastodonSearchV2Route>(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<typeof mastodonSearchUsersV2Route>(c, payload, httpStatus);
};
export const mastodonProfileAPIRequest: RouteHandler<typeof mastodonProfileV2Route> = async c => {
const { domain, handle } = c.req.valid('param');
const processedResponse = await mastodonUserProfileAPI(handle, domain);
@@ -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);
@@ -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}',
+3
View File
@@ -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);
+65 -5
View File
@@ -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 =
+25 -1
View File
@@ -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<typeof searchV2Route> = async c => {
return jsonAfterNormalize<typeof searchV2Route>(c, payload, httpStatus);
};
export const searchUsersAPIRequest: RouteHandler<typeof searchUsersV2Route> = 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<typeof searchUsersV2Route>(c, payload, httpStatus);
};
export const trendsAPIRequest: RouteHandler<typeof trendsV2Route> = async c => {
const host = twitterBuildHostFromContext(c);
const query = c.req.valid('query');
+51 -1
View File
@@ -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<typeof blueskySearchV2Route>
return jsonAfterNormalize<typeof blueskySearchV2Route>(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<typeof blueskySearchUsersV2Route>(c, payload, httpStatus);
};
export const blueskyTypeaheadAPIRequest: RouteHandler<typeof blueskyTypeaheadV2Route> = 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<typeof blueskyTypeaheadV2Route>(c, payload, httpStatus);
};
export const blueskyTrendsAPIRequest: RouteHandler<typeof blueskyTrendsV2Route> = async c => {
const query = c.req.valid('query');
const type = query.type ?? 'trending';
+6
View File
@@ -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);
+81
View File
@@ -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({
+48
View File
@@ -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}`);
});
@@ -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": {}
}
+39 -1
View File
@@ -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'
);
});