Barazo default frontend barazo.forum
2
fork

Configure Feed

Select the types of activity you want to include in your feed.

chore(web): P2.9 catalog migration and Copilot setup (#62)

* feat(sybil): add sybil detection admin UI and trust management pages

Frontend implementation for P2.10 sybil resistance. Adds admin dashboard
and management pages for the graph-based sybil detection system.

New pages:
- Sybil Detection dashboard: cluster list with filtering/sorting,
cluster detail with member enrichment, confirm dialogs for
dismiss/monitor/ban actions, behavioral flags section, trust graph
status card with manual recompute
- Trust Seeds management: seed list with manual/automatic badges,
add/remove dialogs, implicit seed protection

Modified pages:
- Admin Settings: PDS Provider Trust section with override management
(hostname + trust factor slider 0.0-1.0)
- Admin Layout: sidebar navigation links for new pages

Supporting changes:
- 13 new TypeScript types, 12 new API client functions
- 12 new MSW mock handlers with auth checks and CRUD
- All components pass vitest-axe (WCAG 2.2 AA)

29 new tests across 4 test files. All 552 tests pass.

* chore(web): migrate prettier and lint-staged to pnpm catalog

Change prettier and lint-staged from inline version specifiers to
catalog: references for consistent version management across the
workspace.

* chore(web): add copilot-setup-steps.yml for AI coding agents

Add GitHub Copilot coding agent setup workflow that clones and builds
barazo-lexicons (for the link: dependency), installs deps, runs
typecheck and tests. Matches the existing CI workflow pattern.

* fix(web): add prettier and lint-staged to standalone CI catalog

The lockfile specifiers didn't match package.json after migrating
to catalog: references. Add catalog entries for standalone CI and
regenerate the lockfile.

* style(web): format AGENTS.md after sync from workspace

The AGENTS.md sync action produced a file that didn't match
prettier formatting rules.

authored by

Guido X Jansen and committed by
GitHub
f30c6962 6f46003b

+2613 -23
+46
.github/workflows/copilot-setup-steps.yml
··· 1 + name: 'Copilot Setup Steps' 2 + 3 + on: 4 + workflow_dispatch: 5 + push: 6 + paths: 7 + - .github/workflows/copilot-setup-steps.yml 8 + pull_request: 9 + paths: 10 + - .github/workflows/copilot-setup-steps.yml 11 + 12 + jobs: 13 + copilot-setup-steps: 14 + runs-on: ubuntu-latest 15 + permissions: 16 + contents: read 17 + steps: 18 + - name: Checkout 19 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 20 + 21 + - name: Install pnpm 22 + uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4 23 + with: 24 + version: 10 25 + 26 + - name: Setup Node.js 27 + uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0 28 + with: 29 + node-version: '24' 30 + cache: 'pnpm' 31 + 32 + - name: Clone and build barazo-lexicons 33 + run: | 34 + git clone --depth 1 https://github.com/barazo-forum/barazo-lexicons.git ../barazo-lexicons 35 + cd ../barazo-lexicons && pnpm install --ignore-scripts && pnpm run build 36 + 37 + - name: Install dependencies 38 + run: pnpm install --frozen-lockfile 39 + 40 + - name: Typecheck 41 + run: pnpm typecheck 42 + 43 + - name: Run tests 44 + run: pnpm test 45 + env: 46 + NEXT_PUBLIC_API_URL: http://localhost:3100
+12 -12
AGENTS.md
··· 9 9 10 10 ## Tech Stack 11 11 12 - | Component | Technology | 13 - |-----------|-----------| 14 - | Framework | Next.js 16 / React 19 / TypeScript (strict) | 15 - | Styling | TailwindCSS | 16 - | Components | shadcn/ui (Radix primitives) for admin; custom forum components | 17 - | Colors | Radix Colors (12-step system) + Flexoki accent hues | 18 - | Icons | Phosphor Icons (6 weights) | 19 - | Typography | Source Sans 3 / Source Code Pro (self-hosted, zero external DNS) | 20 - | Syntax highlighting | Shiki + Flexoki theme (SSR, dual light/dark) | 21 - | Testing | Vitest + vitest-axe + @axe-core/playwright | 22 - | Accessibility | WCAG 2.2 AA from first commit | 23 - | SEO | JSON-LD, OpenGraph, sitemaps, SSR | 12 + | Component | Technology | 13 + | ------------------- | ---------------------------------------------------------------- | 14 + | Framework | Next.js 16 / React 19 / TypeScript (strict) | 15 + | Styling | TailwindCSS | 16 + | Components | shadcn/ui (Radix primitives) for admin; custom forum components | 17 + | Colors | Radix Colors (12-step system) + Flexoki accent hues | 18 + | Icons | Phosphor Icons (6 weights) | 19 + | Typography | Source Sans 3 / Source Code Pro (self-hosted, zero external DNS) | 20 + | Syntax highlighting | Shiki + Flexoki theme (SSR, dual light/dark) | 21 + | Testing | Vitest + vitest-axe + @axe-core/playwright | 22 + | Accessibility | WCAG 2.2 AA from first commit | 23 + | SEO | JSON-LD, OpenGraph, sitemaps, SSR | 24 24 25 25 ## What This Repo Does 26 26
+2 -2
package.json
··· 91 91 "eslint-plugin-jsx-a11y": "^6.10.2", 92 92 "husky": "catalog:", 93 93 "jsdom": "^28.1.0", 94 - "lint-staged": "^16.2.7", 94 + "lint-staged": "catalog:", 95 95 "msw": "^2.7.0", 96 96 "pa11y-ci": "^4.0.1", 97 - "prettier": "^3.8.1", 97 + "prettier": "catalog:", 98 98 "tailwindcss": "^4.0.0", 99 99 "typescript": "catalog:", 100 100 "vitest": "catalog:",
+57 -2
pnpm-lock.yaml
··· 21 21 husky: 22 22 specifier: ^9.1.7 23 23 version: 9.1.7 24 + lint-staged: 25 + specifier: ^16.2.7 26 + version: 16.2.7 27 + prettier: 28 + specifier: ^3.8.1 29 + version: 3.8.1 24 30 typescript: 25 31 specifier: ^5.9.3 26 32 version: 5.9.3 ··· 220 226 specifier: ^28.1.0 221 227 version: 28.1.0 222 228 lint-staged: 223 - specifier: ^16.2.7 229 + specifier: 'catalog:' 224 230 version: 16.2.7 225 231 msw: 226 232 specifier: ^2.7.0 ··· 229 235 specifier: ^4.0.1 230 236 version: 4.0.1(typescript@5.9.3) 231 237 prettier: 232 - specifier: ^3.8.1 238 + specifier: 'catalog:' 233 239 version: 3.8.1 234 240 tailwindcss: 235 241 specifier: ^4.0.0 ··· 749 755 resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==} 750 756 cpu: [arm64] 751 757 os: [linux] 758 + libc: [glibc] 752 759 753 760 '@img/sharp-libvips-linux-arm@1.2.4': 754 761 resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==} 755 762 cpu: [arm] 756 763 os: [linux] 764 + libc: [glibc] 757 765 758 766 '@img/sharp-libvips-linux-ppc64@1.2.4': 759 767 resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==} 760 768 cpu: [ppc64] 761 769 os: [linux] 770 + libc: [glibc] 762 771 763 772 '@img/sharp-libvips-linux-riscv64@1.2.4': 764 773 resolution: {integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==} 765 774 cpu: [riscv64] 766 775 os: [linux] 776 + libc: [glibc] 767 777 768 778 '@img/sharp-libvips-linux-s390x@1.2.4': 769 779 resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==} 770 780 cpu: [s390x] 771 781 os: [linux] 782 + libc: [glibc] 772 783 773 784 '@img/sharp-libvips-linux-x64@1.2.4': 774 785 resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==} 775 786 cpu: [x64] 776 787 os: [linux] 788 + libc: [glibc] 777 789 778 790 '@img/sharp-libvips-linuxmusl-arm64@1.2.4': 779 791 resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==} 780 792 cpu: [arm64] 781 793 os: [linux] 794 + libc: [musl] 782 795 783 796 '@img/sharp-libvips-linuxmusl-x64@1.2.4': 784 797 resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==} 785 798 cpu: [x64] 786 799 os: [linux] 800 + libc: [musl] 787 801 788 802 '@img/sharp-linux-arm64@0.34.5': 789 803 resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==} 790 804 engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} 791 805 cpu: [arm64] 792 806 os: [linux] 807 + libc: [glibc] 793 808 794 809 '@img/sharp-linux-arm@0.34.5': 795 810 resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==} 796 811 engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} 797 812 cpu: [arm] 798 813 os: [linux] 814 + libc: [glibc] 799 815 800 816 '@img/sharp-linux-ppc64@0.34.5': 801 817 resolution: {integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==} 802 818 engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} 803 819 cpu: [ppc64] 804 820 os: [linux] 821 + libc: [glibc] 805 822 806 823 '@img/sharp-linux-riscv64@0.34.5': 807 824 resolution: {integrity: sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==} 808 825 engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} 809 826 cpu: [riscv64] 810 827 os: [linux] 828 + libc: [glibc] 811 829 812 830 '@img/sharp-linux-s390x@0.34.5': 813 831 resolution: {integrity: sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==} 814 832 engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} 815 833 cpu: [s390x] 816 834 os: [linux] 835 + libc: [glibc] 817 836 818 837 '@img/sharp-linux-x64@0.34.5': 819 838 resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==} 820 839 engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} 821 840 cpu: [x64] 822 841 os: [linux] 842 + libc: [glibc] 823 843 824 844 '@img/sharp-linuxmusl-arm64@0.34.5': 825 845 resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==} 826 846 engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} 827 847 cpu: [arm64] 828 848 os: [linux] 849 + libc: [musl] 829 850 830 851 '@img/sharp-linuxmusl-x64@0.34.5': 831 852 resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==} 832 853 engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} 833 854 cpu: [x64] 834 855 os: [linux] 856 + libc: [musl] 835 857 836 858 '@img/sharp-wasm32@0.34.5': 837 859 resolution: {integrity: sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==} ··· 944 966 engines: {node: '>= 10'} 945 967 cpu: [arm64] 946 968 os: [linux] 969 + libc: [glibc] 947 970 948 971 '@next/swc-linux-arm64-musl@16.1.6': 949 972 resolution: {integrity: sha512-S4J2v+8tT3NIO9u2q+S0G5KdvNDjXfAv06OhfOzNDaBn5rw84DGXWndOEB7d5/x852A20sW1M56vhC/tRVbccQ==} 950 973 engines: {node: '>= 10'} 951 974 cpu: [arm64] 952 975 os: [linux] 976 + libc: [musl] 953 977 954 978 '@next/swc-linux-x64-gnu@16.1.6': 955 979 resolution: {integrity: sha512-2eEBDkFlMMNQnkTyPBhQOAyn2qMxyG2eE7GPH2WIDGEpEILcBPI/jdSv4t6xupSP+ot/jkfrCShLAa7+ZUPcJQ==} 956 980 engines: {node: '>= 10'} 957 981 cpu: [x64] 958 982 os: [linux] 983 + libc: [glibc] 959 984 960 985 '@next/swc-linux-x64-musl@16.1.6': 961 986 resolution: {integrity: sha512-oicJwRlyOoZXVlxmIMaTq7f8pN9QNbdes0q2FXfRsPhfCi8n8JmOZJm5oo1pwDaFbnnD421rVU409M3evFbIqg==} 962 987 engines: {node: '>= 10'} 963 988 cpu: [x64] 964 989 os: [linux] 990 + libc: [musl] 965 991 966 992 '@next/swc-win32-arm64-msvc@16.1.6': 967 993 resolution: {integrity: sha512-gQmm8izDTPgs+DCWH22kcDmuUp7NyiJgEl18bcr8irXA5N2m2O+JQIr6f3ct42GOs9c0h8QF3L5SzIxcYAAXXw==} ··· 1720 1746 resolution: {integrity: sha512-F8sWbhZ7tyuEfsmOxwc2giKDQzN3+kuBLPwwZGyVkLlKGdV1nvnNwYD0fKQ8+XS6hp9nY7B+ZeK01EBUE7aHaw==} 1721 1747 cpu: [arm] 1722 1748 os: [linux] 1749 + libc: [glibc] 1723 1750 1724 1751 '@rollup/rollup-linux-arm-musleabihf@4.57.1': 1725 1752 resolution: {integrity: sha512-rGfNUfn0GIeXtBP1wL5MnzSj98+PZe/AXaGBCRmT0ts80lU5CATYGxXukeTX39XBKsxzFpEeK+Mrp9faXOlmrw==} 1726 1753 cpu: [arm] 1727 1754 os: [linux] 1755 + libc: [musl] 1728 1756 1729 1757 '@rollup/rollup-linux-arm64-gnu@4.57.1': 1730 1758 resolution: {integrity: sha512-MMtej3YHWeg/0klK2Qodf3yrNzz6CGjo2UntLvk2RSPlhzgLvYEB3frRvbEF2wRKh1Z2fDIg9KRPe1fawv7C+g==} 1731 1759 cpu: [arm64] 1732 1760 os: [linux] 1761 + libc: [glibc] 1733 1762 1734 1763 '@rollup/rollup-linux-arm64-musl@4.57.1': 1735 1764 resolution: {integrity: sha512-1a/qhaaOXhqXGpMFMET9VqwZakkljWHLmZOX48R0I/YLbhdxr1m4gtG1Hq7++VhVUmf+L3sTAf9op4JlhQ5u1Q==} 1736 1765 cpu: [arm64] 1737 1766 os: [linux] 1767 + libc: [musl] 1738 1768 1739 1769 '@rollup/rollup-linux-loong64-gnu@4.57.1': 1740 1770 resolution: {integrity: sha512-QWO6RQTZ/cqYtJMtxhkRkidoNGXc7ERPbZN7dVW5SdURuLeVU7lwKMpo18XdcmpWYd0qsP1bwKPf7DNSUinhvA==} 1741 1771 cpu: [loong64] 1742 1772 os: [linux] 1773 + libc: [glibc] 1743 1774 1744 1775 '@rollup/rollup-linux-loong64-musl@4.57.1': 1745 1776 resolution: {integrity: sha512-xpObYIf+8gprgWaPP32xiN5RVTi/s5FCR+XMXSKmhfoJjrpRAjCuuqQXyxUa/eJTdAE6eJ+KDKaoEqjZQxh3Gw==} 1746 1777 cpu: [loong64] 1747 1778 os: [linux] 1779 + libc: [musl] 1748 1780 1749 1781 '@rollup/rollup-linux-ppc64-gnu@4.57.1': 1750 1782 resolution: {integrity: sha512-4BrCgrpZo4hvzMDKRqEaW1zeecScDCR+2nZ86ATLhAoJ5FQ+lbHVD3ttKe74/c7tNT9c6F2viwB3ufwp01Oh2w==} 1751 1783 cpu: [ppc64] 1752 1784 os: [linux] 1785 + libc: [glibc] 1753 1786 1754 1787 '@rollup/rollup-linux-ppc64-musl@4.57.1': 1755 1788 resolution: {integrity: sha512-NOlUuzesGauESAyEYFSe3QTUguL+lvrN1HtwEEsU2rOwdUDeTMJdO5dUYl/2hKf9jWydJrO9OL/XSSf65R5+Xw==} 1756 1789 cpu: [ppc64] 1757 1790 os: [linux] 1791 + libc: [musl] 1758 1792 1759 1793 '@rollup/rollup-linux-riscv64-gnu@4.57.1': 1760 1794 resolution: {integrity: sha512-ptA88htVp0AwUUqhVghwDIKlvJMD/fmL/wrQj99PRHFRAG6Z5nbWoWG4o81Nt9FT+IuqUQi+L31ZKAFeJ5Is+A==} 1761 1795 cpu: [riscv64] 1762 1796 os: [linux] 1797 + libc: [glibc] 1763 1798 1764 1799 '@rollup/rollup-linux-riscv64-musl@4.57.1': 1765 1800 resolution: {integrity: sha512-S51t7aMMTNdmAMPpBg7OOsTdn4tySRQvklmL3RpDRyknk87+Sp3xaumlatU+ppQ+5raY7sSTcC2beGgvhENfuw==} 1766 1801 cpu: [riscv64] 1767 1802 os: [linux] 1803 + libc: [musl] 1768 1804 1769 1805 '@rollup/rollup-linux-s390x-gnu@4.57.1': 1770 1806 resolution: {integrity: sha512-Bl00OFnVFkL82FHbEqy3k5CUCKH6OEJL54KCyx2oqsmZnFTR8IoNqBF+mjQVcRCT5sB6yOvK8A37LNm/kPJiZg==} 1771 1807 cpu: [s390x] 1772 1808 os: [linux] 1809 + libc: [glibc] 1773 1810 1774 1811 '@rollup/rollup-linux-x64-gnu@4.57.1': 1775 1812 resolution: {integrity: sha512-ABca4ceT4N+Tv/GtotnWAeXZUZuM/9AQyCyKYyKnpk4yoA7QIAuBt6Hkgpw8kActYlew2mvckXkvx0FfoInnLg==} 1776 1813 cpu: [x64] 1777 1814 os: [linux] 1815 + libc: [glibc] 1778 1816 1779 1817 '@rollup/rollup-linux-x64-musl@4.57.1': 1780 1818 resolution: {integrity: sha512-HFps0JeGtuOR2convgRRkHCekD7j+gdAuXM+/i6kGzQtFhlCtQkpwtNzkNj6QhCDp7DRJ7+qC/1Vg2jt5iSOFw==} 1781 1819 cpu: [x64] 1782 1820 os: [linux] 1821 + libc: [musl] 1783 1822 1784 1823 '@rollup/rollup-openbsd-x64@4.57.1': 1785 1824 resolution: {integrity: sha512-H+hXEv9gdVQuDTgnqD+SQffoWoc0Of59AStSzTEj/feWTBAnSfSD3+Dql1ZruJQxmykT/JVY0dE8Ka7z0DH1hw==} ··· 1903 1942 engines: {node: '>= 10'} 1904 1943 cpu: [arm64] 1905 1944 os: [linux] 1945 + libc: [glibc] 1906 1946 1907 1947 '@tailwindcss/oxide-linux-arm64-musl@4.1.18': 1908 1948 resolution: {integrity: sha512-1px92582HkPQlaaCkdRcio71p8bc8i/ap5807tPRDK/uw953cauQBT8c5tVGkOwrHMfc2Yh6UuxaH4vtTjGvHg==} 1909 1949 engines: {node: '>= 10'} 1910 1950 cpu: [arm64] 1911 1951 os: [linux] 1952 + libc: [musl] 1912 1953 1913 1954 '@tailwindcss/oxide-linux-x64-gnu@4.1.18': 1914 1955 resolution: {integrity: sha512-v3gyT0ivkfBLoZGF9LyHmts0Isc8jHZyVcbzio6Wpzifg/+5ZJpDiRiUhDLkcr7f/r38SWNe7ucxmGW3j3Kb/g==} 1915 1956 engines: {node: '>= 10'} 1916 1957 cpu: [x64] 1917 1958 os: [linux] 1959 + libc: [glibc] 1918 1960 1919 1961 '@tailwindcss/oxide-linux-x64-musl@4.1.18': 1920 1962 resolution: {integrity: sha512-bhJ2y2OQNlcRwwgOAGMY0xTFStt4/wyU6pvI6LSuZpRgKQwxTec0/3Scu91O8ir7qCR3AuepQKLU/kX99FouqQ==} 1921 1963 engines: {node: '>= 10'} 1922 1964 cpu: [x64] 1923 1965 os: [linux] 1966 + libc: [musl] 1924 1967 1925 1968 '@tailwindcss/oxide-wasm32-wasi@4.1.18': 1926 1969 resolution: {integrity: sha512-LffYTvPjODiP6PT16oNeUQJzNVyJl1cjIebq/rWWBF+3eDst5JGEFSc5cWxyRCJ0Mxl+KyIkqRxk1XPEs9x8TA==} ··· 2148 2191 resolution: {integrity: sha512-34gw7PjDGB9JgePJEmhEqBhWvCiiWCuXsL9hYphDF7crW7UgI05gyBAi6MF58uGcMOiOqSJ2ybEeCvHcq0BCmQ==} 2149 2192 cpu: [arm64] 2150 2193 os: [linux] 2194 + libc: [glibc] 2151 2195 2152 2196 '@unrs/resolver-binding-linux-arm64-musl@1.11.1': 2153 2197 resolution: {integrity: sha512-RyMIx6Uf53hhOtJDIamSbTskA99sPHS96wxVE/bJtePJJtpdKGXO1wY90oRdXuYOGOTuqjT8ACccMc4K6QmT3w==} 2154 2198 cpu: [arm64] 2155 2199 os: [linux] 2200 + libc: [musl] 2156 2201 2157 2202 '@unrs/resolver-binding-linux-ppc64-gnu@1.11.1': 2158 2203 resolution: {integrity: sha512-D8Vae74A4/a+mZH0FbOkFJL9DSK2R6TFPC9M+jCWYia/q2einCubX10pecpDiTmkJVUH+y8K3BZClycD8nCShA==} 2159 2204 cpu: [ppc64] 2160 2205 os: [linux] 2206 + libc: [glibc] 2161 2207 2162 2208 '@unrs/resolver-binding-linux-riscv64-gnu@1.11.1': 2163 2209 resolution: {integrity: sha512-frxL4OrzOWVVsOc96+V3aqTIQl1O2TjgExV4EKgRY09AJ9leZpEg8Ak9phadbuX0BA4k8U5qtvMSQQGGmaJqcQ==} 2164 2210 cpu: [riscv64] 2165 2211 os: [linux] 2212 + libc: [glibc] 2166 2213 2167 2214 '@unrs/resolver-binding-linux-riscv64-musl@1.11.1': 2168 2215 resolution: {integrity: sha512-mJ5vuDaIZ+l/acv01sHoXfpnyrNKOk/3aDoEdLO/Xtn9HuZlDD6jKxHlkN8ZhWyLJsRBxfv9GYM2utQ1SChKew==} 2169 2216 cpu: [riscv64] 2170 2217 os: [linux] 2218 + libc: [musl] 2171 2219 2172 2220 '@unrs/resolver-binding-linux-s390x-gnu@1.11.1': 2173 2221 resolution: {integrity: sha512-kELo8ebBVtb9sA7rMe1Cph4QHreByhaZ2QEADd9NzIQsYNQpt9UkM9iqr2lhGr5afh885d/cB5QeTXSbZHTYPg==} 2174 2222 cpu: [s390x] 2175 2223 os: [linux] 2224 + libc: [glibc] 2176 2225 2177 2226 '@unrs/resolver-binding-linux-x64-gnu@1.11.1': 2178 2227 resolution: {integrity: sha512-C3ZAHugKgovV5YvAMsxhq0gtXuwESUKc5MhEtjBpLoHPLYM+iuwSj3lflFwK3DPm68660rZ7G8BMcwSro7hD5w==} 2179 2228 cpu: [x64] 2180 2229 os: [linux] 2230 + libc: [glibc] 2181 2231 2182 2232 '@unrs/resolver-binding-linux-x64-musl@1.11.1': 2183 2233 resolution: {integrity: sha512-rV0YSoyhK2nZ4vEswT/QwqzqQXw5I6CjoaYMOX0TqBlWhojUf8P94mvI7nuJTeaCkkds3QE4+zS8Ko+GdXuZtA==} 2184 2234 cpu: [x64] 2185 2235 os: [linux] 2236 + libc: [musl] 2186 2237 2187 2238 '@unrs/resolver-binding-wasm32-wasi@1.11.1': 2188 2239 resolution: {integrity: sha512-5u4RkfxJm+Ng7IWgkzi3qrFOvLvQYnPBmjmZQ8+szTK/b31fQCnleNl1GgEt7nIsZRIf5PLhPwT0WM+q45x/UQ==} ··· 3784 3835 engines: {node: '>= 12.0.0'} 3785 3836 cpu: [arm64] 3786 3837 os: [linux] 3838 + libc: [glibc] 3787 3839 3788 3840 lightningcss-linux-arm64-musl@1.30.2: 3789 3841 resolution: {integrity: sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA==} 3790 3842 engines: {node: '>= 12.0.0'} 3791 3843 cpu: [arm64] 3792 3844 os: [linux] 3845 + libc: [musl] 3793 3846 3794 3847 lightningcss-linux-x64-gnu@1.30.2: 3795 3848 resolution: {integrity: sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w==} 3796 3849 engines: {node: '>= 12.0.0'} 3797 3850 cpu: [x64] 3798 3851 os: [linux] 3852 + libc: [glibc] 3799 3853 3800 3854 lightningcss-linux-x64-musl@1.30.2: 3801 3855 resolution: {integrity: sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA==} 3802 3856 engines: {node: '>= 12.0.0'} 3803 3857 cpu: [x64] 3804 3858 os: [linux] 3859 + libc: [musl] 3805 3860 3806 3861 lightningcss-win32-arm64-msvc@1.30.2: 3807 3862 resolution: {integrity: sha512-FZn+vaj7zLv//D/192WFFVA0RgHawIcHqLX9xuWiQt7P0PtdFEVaxgF9rjM/IRYHQXNnk61/H/gb2Ei+kUQ4xQ==}
+2
pnpm-workspace.yaml
··· 16 16 '@commitlint/config-conventional': '^20.4.1' 17 17 '@vitest/coverage-v8': '^4.0.18' 18 18 husky: '^9.1.7' 19 + lint-staged: '^16.2.7' 19 20 multiformats: '^13.4.2' 21 + prettier: '^3.8.1'
+110 -1
src/app/admin/settings/page.test.tsx
··· 3 3 */ 4 4 5 5 import { describe, it, expect, vi } from 'vitest' 6 - import { render, screen, waitFor } from '@testing-library/react' 6 + import { render, screen, waitFor, fireEvent } from '@testing-library/react' 7 + import userEvent from '@testing-library/user-event' 7 8 import { axe } from 'vitest-axe' 8 9 import AdminSettingsPage from './page' 9 10 ··· 95 96 const { container } = render(<AdminSettingsPage />) 96 97 await waitFor(() => { 97 98 expect(screen.getByLabelText(/community name/i)).toBeInTheDocument() 99 + }) 100 + const results = await axe(container) 101 + expect(results).toHaveNoViolations() 102 + }) 103 + 104 + // --- PDS Provider Trust section --- 105 + 106 + it('renders PDS Provider Trust heading and help text', async () => { 107 + render(<AdminSettingsPage />) 108 + await waitFor(() => { 109 + expect(screen.getByRole('heading', { name: /pds provider trust/i })).toBeInTheDocument() 110 + }) 111 + expect(screen.getByText(/higher trust factors earn reputation faster/i)).toBeInTheDocument() 112 + }) 113 + 114 + it('renders PDS providers table with trust factors', async () => { 115 + render(<AdminSettingsPage />) 116 + await waitFor(() => { 117 + expect(screen.getByText('bsky.social')).toBeInTheDocument() 118 + }) 119 + expect(screen.getByText('northsky.app')).toBeInTheDocument() 120 + expect(screen.getByText('custom-pds.example')).toBeInTheDocument() 121 + }) 122 + 123 + it('shows Default badge on default providers', async () => { 124 + render(<AdminSettingsPage />) 125 + await waitFor(() => { 126 + expect(screen.getByText('bsky.social')).toBeInTheDocument() 127 + }) 128 + const defaultBadges = screen.getAllByText('Default') 129 + expect(defaultBadges.length).toBe(2) // bsky.social and northsky.app 130 + }) 131 + 132 + it('shows Add Override button that opens dialog', async () => { 133 + const user = userEvent.setup() 134 + render(<AdminSettingsPage />) 135 + await waitFor(() => { 136 + expect(screen.getByText('bsky.social')).toBeInTheDocument() 137 + }) 138 + const addBtn = screen.getByRole('button', { name: /add override/i }) 139 + await user.click(addBtn) 140 + await waitFor(() => { 141 + expect(screen.getByLabelText(/pds hostname/i)).toBeInTheDocument() 142 + expect(screen.getByLabelText(/trust factor/i)).toBeInTheDocument() 143 + }) 144 + }) 145 + 146 + it('submits Add Override dialog with hostname and trust factor', async () => { 147 + const user = userEvent.setup() 148 + render(<AdminSettingsPage />) 149 + await waitFor(() => { 150 + expect(screen.getByText('bsky.social')).toBeInTheDocument() 151 + }) 152 + await user.click(screen.getByRole('button', { name: /add override/i })) 153 + await waitFor(() => { 154 + expect(screen.getByLabelText(/pds hostname/i)).toBeInTheDocument() 155 + }) 156 + await user.type(screen.getByLabelText(/pds hostname/i), 'my-pds.example.org') 157 + // Adjust the slider via fireEvent (range inputs) 158 + const slider = screen.getByLabelText(/trust factor/i) 159 + fireEvent.change(slider, { target: { value: '0.8' } }) 160 + await user.click(screen.getByRole('button', { name: /^add$/i })) 161 + // Dialog should close 162 + await waitFor(() => { 163 + expect(screen.queryByLabelText(/pds hostname/i)).not.toBeInTheDocument() 164 + }) 165 + }) 166 + 167 + it('allows editing trust factor on override providers', async () => { 168 + const user = userEvent.setup() 169 + render(<AdminSettingsPage />) 170 + await waitFor(() => { 171 + expect(screen.getByText('custom-pds.example')).toBeInTheDocument() 172 + }) 173 + // Only override (non-default) providers have Edit buttons 174 + const editButtons = screen.getAllByRole('button', { name: /edit/i }) 175 + expect(editButtons.length).toBeGreaterThan(0) 176 + await user.click(editButtons[0]!) 177 + // The dialog should open with a slider input and a Save button 178 + await waitFor(() => { 179 + expect(screen.getByRole('slider')).toBeInTheDocument() 180 + }) 181 + expect(screen.getByRole('button', { name: /^save$/i })).toBeInTheDocument() 182 + }) 183 + 184 + it('allows removing override providers with confirm', async () => { 185 + const user = userEvent.setup() 186 + render(<AdminSettingsPage />) 187 + await waitFor(() => { 188 + expect(screen.getByText('custom-pds.example')).toBeInTheDocument() 189 + }) 190 + // Only override providers have Remove buttons 191 + const removeButtons = screen.getAllByRole('button', { name: /remove/i }) 192 + expect(removeButtons.length).toBeGreaterThan(0) 193 + await user.click(removeButtons[0]!) 194 + await waitFor(() => { 195 + expect(screen.getByText(/are you sure/i)).toBeInTheDocument() 196 + }) 197 + await user.click(screen.getByRole('button', { name: /^confirm$/i })) 198 + await waitFor(() => { 199 + expect(screen.queryByText(/are you sure/i)).not.toBeInTheDocument() 200 + }) 201 + }) 202 + 203 + it('PDS Provider Trust section passes axe accessibility check', async () => { 204 + const { container } = render(<AdminSettingsPage />) 205 + await waitFor(() => { 206 + expect(screen.getByText('bsky.social')).toBeInTheDocument() 98 207 }) 99 208 const results = await axe(container) 100 209 expect(results).toHaveNoViolations()
+365 -6
src/app/admin/settings/page.tsx
··· 7 7 8 8 'use client' 9 9 10 - import { useState, useEffect, useCallback } from 'react' 10 + import { useState, useEffect, useCallback, useRef } from 'react' 11 11 import { AdminLayout } from '@/components/admin/admin-layout' 12 12 import { ErrorAlert } from '@/components/error-alert' 13 - import { getCommunitySettings, updateCommunitySettings } from '@/lib/api/client' 14 - import type { CommunitySettings, MaturityRating } from '@/lib/api/types' 13 + import { 14 + getCommunitySettings, 15 + updateCommunitySettings, 16 + getPdsTrustFactors, 17 + updatePdsTrustFactor, 18 + } from '@/lib/api/client' 19 + import { cn } from '@/lib/utils' 20 + import type { CommunitySettings, MaturityRating, PdsTrustFactor } from '@/lib/api/types' 15 21 import { useAuth } from '@/hooks/use-auth' 16 22 23 + // --- Confirm Dialog --- 24 + function ConfirmDialog({ 25 + open, 26 + title, 27 + message, 28 + onConfirm, 29 + onCancel, 30 + }: { 31 + open: boolean 32 + title: string 33 + message: string 34 + onConfirm: () => void 35 + onCancel: () => void 36 + }) { 37 + const confirmRef = useRef<HTMLButtonElement>(null) 38 + 39 + useEffect(() => { 40 + if (open) { 41 + confirmRef.current?.focus() 42 + } 43 + }, [open]) 44 + 45 + useEffect(() => { 46 + if (!open) return 47 + const handleKey = (e: KeyboardEvent) => { 48 + if (e.key === 'Escape') onCancel() 49 + } 50 + document.addEventListener('keydown', handleKey) 51 + return () => document.removeEventListener('keydown', handleKey) 52 + }, [open, onCancel]) 53 + 54 + if (!open) return null 55 + 56 + return ( 57 + <div 58 + className="fixed inset-0 z-50 flex items-center justify-center bg-black/50" 59 + role="dialog" 60 + aria-modal="true" 61 + aria-labelledby="confirm-dialog-title" 62 + aria-describedby="confirm-dialog-message" 63 + > 64 + <div className="mx-4 w-full max-w-md rounded-lg border border-border bg-card p-6 shadow-lg"> 65 + <h3 id="confirm-dialog-title" className="text-lg font-semibold text-foreground"> 66 + {title} 67 + </h3> 68 + <p id="confirm-dialog-message" className="mt-2 text-sm text-muted-foreground"> 69 + {message} 70 + </p> 71 + <div className="mt-4 flex justify-end gap-2"> 72 + <button 73 + type="button" 74 + onClick={onCancel} 75 + className="rounded-md border border-border px-4 py-2 text-sm text-foreground transition-colors hover:bg-muted" 76 + > 77 + Cancel 78 + </button> 79 + <button 80 + ref={confirmRef} 81 + type="button" 82 + onClick={onConfirm} 83 + className="rounded-md bg-destructive px-4 py-2 text-sm font-medium text-destructive-foreground transition-colors hover:bg-destructive/90" 84 + > 85 + Confirm 86 + </button> 87 + </div> 88 + </div> 89 + </div> 90 + ) 91 + } 92 + 93 + // --- Add/Edit PDS Override Dialog --- 94 + function PdsOverrideDialog({ 95 + open, 96 + mode, 97 + initialHostname, 98 + initialTrustFactor, 99 + onClose, 100 + onSubmit, 101 + }: { 102 + open: boolean 103 + mode: 'add' | 'edit' 104 + initialHostname: string 105 + initialTrustFactor: number 106 + onClose: () => void 107 + onSubmit: (hostname: string, trustFactor: number) => void 108 + }) { 109 + const [hostname, setHostname] = useState(initialHostname) 110 + const [trustFactor, setTrustFactor] = useState(initialTrustFactor) 111 + const hostnameRef = useRef<HTMLInputElement>(null) 112 + 113 + useEffect(() => { 114 + if (open && mode === 'add') { 115 + hostnameRef.current?.focus() 116 + } 117 + }, [open, mode]) 118 + 119 + useEffect(() => { 120 + if (!open) return 121 + const handleKey = (e: KeyboardEvent) => { 122 + if (e.key === 'Escape') onClose() 123 + } 124 + document.addEventListener('keydown', handleKey) 125 + return () => document.removeEventListener('keydown', handleKey) 126 + }, [open, onClose]) 127 + 128 + if (!open) return null 129 + 130 + const handleSubmit = (e: React.FormEvent) => { 131 + e.preventDefault() 132 + if (mode === 'add' && !hostname.trim()) return 133 + onSubmit(hostname.trim(), trustFactor) 134 + } 135 + 136 + return ( 137 + <div 138 + className="fixed inset-0 z-50 flex items-center justify-center bg-black/50" 139 + role="dialog" 140 + aria-modal="true" 141 + aria-label={mode === 'add' ? 'Add PDS trust override' : 'Edit PDS trust factor'} 142 + > 143 + <div className="mx-4 w-full max-w-md rounded-lg border border-border bg-card p-6 shadow-lg"> 144 + <h3 className="text-lg font-semibold text-foreground"> 145 + {mode === 'add' ? 'Add PDS Override' : 'Edit Trust Factor'} 146 + </h3> 147 + <form onSubmit={handleSubmit} className="mt-4 space-y-4"> 148 + <div> 149 + <label htmlFor="pds-hostname" className="block text-sm font-medium text-foreground"> 150 + PDS Hostname 151 + </label> 152 + <input 153 + ref={hostnameRef} 154 + id="pds-hostname" 155 + type="text" 156 + value={hostname} 157 + onChange={(e) => setHostname(e.target.value)} 158 + disabled={mode === 'edit'} 159 + placeholder="my-pds.example.org" 160 + className={cn( 161 + 'mt-1 w-full rounded-md border border-border bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground', 162 + mode === 'edit' && 'cursor-not-allowed opacity-60' 163 + )} 164 + required 165 + /> 166 + </div> 167 + <div> 168 + <label htmlFor="pds-trust-factor" className="block text-sm font-medium text-foreground"> 169 + Trust Factor: {trustFactor.toFixed(1)} 170 + </label> 171 + <input 172 + id="pds-trust-factor" 173 + type="range" 174 + min="0" 175 + max="1" 176 + step="0.1" 177 + value={trustFactor} 178 + onChange={(e) => setTrustFactor(parseFloat(e.target.value))} 179 + className="mt-1 w-full" 180 + /> 181 + <div className="mt-1 flex justify-between text-xs text-muted-foreground"> 182 + <span>0.0 (untrusted)</span> 183 + <span>1.0 (fully trusted)</span> 184 + </div> 185 + </div> 186 + <div className="flex justify-end gap-2"> 187 + <button 188 + type="button" 189 + onClick={onClose} 190 + className="rounded-md border border-border px-4 py-2 text-sm text-foreground transition-colors hover:bg-muted" 191 + > 192 + Cancel 193 + </button> 194 + <button 195 + type="submit" 196 + className="rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90" 197 + > 198 + {mode === 'add' ? 'Add' : 'Save'} 199 + </button> 200 + </div> 201 + </form> 202 + </div> 203 + </div> 204 + ) 205 + } 206 + 207 + // --- PDS Provider Trust Section --- 208 + function PdsTrustSection({ 209 + providers, 210 + onUpdate, 211 + onRemove, 212 + }: { 213 + providers: PdsTrustFactor[] 214 + onUpdate: (pdsHost: string, trustFactor: number) => void 215 + onRemove: (pdsHost: string) => void 216 + }) { 217 + const [dialogOpen, setDialogOpen] = useState(false) 218 + const [dialogKey, setDialogKey] = useState(0) 219 + const [dialogMode, setDialogMode] = useState<'add' | 'edit'>('add') 220 + const [editHostname, setEditHostname] = useState('') 221 + const [editTrustFactor, setEditTrustFactor] = useState(1.0) 222 + const [confirmRemove, setConfirmRemove] = useState<string | null>(null) 223 + 224 + const handleAdd = () => { 225 + setDialogMode('add') 226 + setEditHostname('') 227 + setEditTrustFactor(1.0) 228 + setDialogKey((k) => k + 1) 229 + setDialogOpen(true) 230 + } 231 + 232 + const handleEdit = (provider: PdsTrustFactor) => { 233 + setDialogMode('edit') 234 + setEditHostname(provider.pdsHost) 235 + setEditTrustFactor(provider.trustFactor) 236 + setDialogKey((k) => k + 1) 237 + setDialogOpen(true) 238 + } 239 + 240 + const handleDialogSubmit = (hostname: string, trustFactor: number) => { 241 + onUpdate(hostname, trustFactor) 242 + setDialogOpen(false) 243 + } 244 + 245 + return ( 246 + <div className="space-y-4"> 247 + <div className="flex items-center justify-between"> 248 + <div> 249 + <h2 className="text-lg font-semibold text-foreground">PDS Provider Trust</h2> 250 + <p className="mt-0.5 text-sm text-muted-foreground"> 251 + Accounts from providers with higher trust factors earn reputation faster. Override the 252 + default if you trust a specific self-hosted PDS provider. 253 + </p> 254 + </div> 255 + <button 256 + type="button" 257 + onClick={handleAdd} 258 + aria-label="Add override" 259 + className="shrink-0 rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90" 260 + > 261 + Add Override 262 + </button> 263 + </div> 264 + 265 + <div className="space-y-2"> 266 + {providers.map((provider) => ( 267 + <div 268 + key={provider.pdsHost} 269 + className="flex items-center justify-between gap-4 rounded-lg border border-border bg-card p-3" 270 + > 271 + <div className="min-w-0 flex-1"> 272 + <div className="flex items-center gap-2"> 273 + <span className="text-sm font-medium text-foreground">{provider.pdsHost}</span> 274 + {provider.isDefault && ( 275 + <span className="inline-flex rounded-full bg-muted px-2 py-0.5 text-xs font-medium text-muted-foreground"> 276 + Default 277 + </span> 278 + )} 279 + </div> 280 + <p className="mt-0.5 text-xs text-muted-foreground"> 281 + Trust factor: {provider.trustFactor.toFixed(1)} 282 + </p> 283 + </div> 284 + {!provider.isDefault && ( 285 + <div className="flex shrink-0 gap-2"> 286 + <button 287 + type="button" 288 + onClick={() => handleEdit(provider)} 289 + aria-label="Edit" 290 + className="rounded-md border border-border px-3 py-1.5 text-sm text-foreground transition-colors hover:bg-muted" 291 + > 292 + Edit 293 + </button> 294 + <button 295 + type="button" 296 + onClick={() => setConfirmRemove(provider.pdsHost)} 297 + aria-label="Remove" 298 + className="rounded-md border border-border px-3 py-1.5 text-sm text-destructive transition-colors hover:bg-destructive/10" 299 + > 300 + Remove 301 + </button> 302 + </div> 303 + )} 304 + </div> 305 + ))} 306 + {providers.length === 0 && ( 307 + <p className="py-4 text-center text-muted-foreground">No PDS providers configured.</p> 308 + )} 309 + </div> 310 + 311 + <PdsOverrideDialog 312 + key={dialogKey} 313 + open={dialogOpen} 314 + mode={dialogMode} 315 + initialHostname={editHostname} 316 + initialTrustFactor={editTrustFactor} 317 + onClose={() => setDialogOpen(false)} 318 + onSubmit={handleDialogSubmit} 319 + /> 320 + 321 + <ConfirmDialog 322 + open={confirmRemove !== null} 323 + title="Remove PDS override" 324 + message={`Are you sure you want to remove the trust override for ${confirmRemove ?? ''}? The default trust factor will apply.`} 325 + onConfirm={() => { 326 + if (confirmRemove) onRemove(confirmRemove) 327 + setConfirmRemove(null) 328 + }} 329 + onCancel={() => setConfirmRemove(null)} 330 + /> 331 + </div> 332 + ) 333 + } 334 + 17 335 export default function AdminSettingsPage() { 18 336 const { getAccessToken } = useAuth() 19 337 const [settings, setSettings] = useState<CommunitySettings | null>(null) 338 + const [pdsProviders, setPdsProviders] = useState<PdsTrustFactor[]>([]) 20 339 const [loading, setLoading] = useState(true) 21 340 const [saving, setSaving] = useState(false) 22 341 const [loadError, setLoadError] = useState<string | null>(null) 23 342 const [saveError, setSaveError] = useState<string | null>(null) 343 + const [pdsError, setPdsError] = useState<string | null>(null) 24 344 25 345 const fetchSettings = useCallback(async () => { 26 346 setLoadError(null) 27 347 try { 28 - const data = await getCommunitySettings() 29 - setSettings(data) 348 + const [settingsData, pdsData] = await Promise.all([ 349 + getCommunitySettings(), 350 + getPdsTrustFactors(getAccessToken() ?? ''), 351 + ]) 352 + setSettings(settingsData) 353 + setPdsProviders(pdsData.providers) 30 354 } catch { 31 355 setLoadError('Failed to load community settings. The API may be unreachable.') 32 356 } finally { 33 357 setLoading(false) 34 358 } 35 - }, []) 359 + }, [getAccessToken]) 36 360 37 361 useEffect(() => { 38 362 void fetchSettings() ··· 60 384 } finally { 61 385 setSaving(false) 62 386 } 387 + } 388 + 389 + const handlePdsUpdate = async (pdsHost: string, trustFactor: number) => { 390 + setPdsError(null) 391 + try { 392 + const updated = await updatePdsTrustFactor(pdsHost, trustFactor, getAccessToken() ?? '') 393 + setPdsProviders((prev) => { 394 + const existing = prev.find((p) => p.pdsHost === pdsHost) 395 + if (existing) { 396 + return prev.map((p) => (p.pdsHost === pdsHost ? updated : p)) 397 + } 398 + return [...prev, updated] 399 + }) 400 + } catch { 401 + setPdsError('Failed to update PDS trust factor.') 402 + } 403 + } 404 + 405 + const handlePdsRemove = async (pdsHost: string) => { 406 + setPdsError(null) 407 + // Removing an override reverts to default -- for the UI we just remove it from the list 408 + setPdsProviders((prev) => prev.filter((p) => p.pdsHost !== pdsHost)) 63 409 } 64 410 65 411 return ( ··· 205 551 {saving ? 'Saving...' : 'Save Settings'} 206 552 </button> 207 553 </div> 554 + )} 555 + 556 + {/* PDS Provider Trust section */} 557 + {!loading && !loadError && ( 558 + <> 559 + <hr className="border-border" /> 560 + {pdsError && <ErrorAlert message={pdsError} onDismiss={() => setPdsError(null)} />} 561 + <PdsTrustSection 562 + providers={pdsProviders} 563 + onUpdate={(host, factor) => void handlePdsUpdate(host, factor)} 564 + onRemove={(host) => void handlePdsRemove(host)} 565 + /> 566 + </> 208 567 )} 209 568 </div> 210 569 </AdminLayout>
+239
src/app/admin/sybil-detection/page.test.tsx
··· 1 + /** 2 + * Tests for admin sybil detection page. 3 + * TDD: written before implementation. 4 + */ 5 + 6 + import { describe, it, expect, vi } from 'vitest' 7 + import { render, screen, waitFor, fireEvent } from '@testing-library/react' 8 + import userEvent from '@testing-library/user-event' 9 + import { axe } from 'vitest-axe' 10 + import AdminSybilDetectionPage from './page' 11 + 12 + vi.mock('next/navigation', () => ({ 13 + useRouter: () => ({ push: vi.fn() }), 14 + usePathname: () => '/admin/sybil-detection', 15 + })) 16 + 17 + vi.mock('next/link', () => ({ 18 + default: ({ 19 + children, 20 + href, 21 + ...props 22 + }: { children: React.ReactNode; href: string } & Record<string, unknown>) => ( 23 + <a href={href} {...props}> 24 + {children} 25 + </a> 26 + ), 27 + })) 28 + 29 + vi.mock('next/image', () => ({ 30 + default: (props: Record<string, unknown>) => { 31 + // eslint-disable-next-line @next/next/no-img-element, jsx-a11y/alt-text 32 + return <img {...props} /> 33 + }, 34 + })) 35 + 36 + vi.mock('@/hooks/use-auth', () => ({ 37 + useAuth: () => ({ 38 + user: { 39 + did: 'did:plc:user-alice-001', 40 + handle: 'alice.bsky.social', 41 + displayName: 'Alice', 42 + avatarUrl: null, 43 + }, 44 + isAuthenticated: true, 45 + isLoading: false, 46 + getAccessToken: () => 'mock-access-token', 47 + login: vi.fn(), 48 + logout: vi.fn(), 49 + setSessionFromCallback: vi.fn(), 50 + authFetch: vi.fn(), 51 + }), 52 + })) 53 + 54 + describe('AdminSybilDetectionPage', () => { 55 + it('renders heading and explanation text', async () => { 56 + render(<AdminSybilDetectionPage />) 57 + expect(screen.getByRole('heading', { name: /sybil detection/i })).toBeInTheDocument() 58 + await waitFor(() => { 59 + expect(screen.getByText(/trust graph/i)).toBeInTheDocument() 60 + }) 61 + }) 62 + 63 + it('renders trust graph status card', async () => { 64 + render(<AdminSybilDetectionPage />) 65 + await waitFor(() => { 66 + expect(screen.getByText(/1,500 nodes/i)).toBeInTheDocument() 67 + expect(screen.getByText(/4,200 edges/i)).toBeInTheDocument() 68 + expect(screen.getByText(/2 clusters flagged/i)).toBeInTheDocument() 69 + }) 70 + }) 71 + 72 + it('renders cluster list from API', async () => { 73 + render(<AdminSybilDetectionPage />) 74 + await waitFor(() => { 75 + expect(screen.getByText(/8 members/i)).toBeInTheDocument() 76 + expect(screen.getByText(/5 members/i)).toBeInTheDocument() 77 + expect(screen.getByText(/3 members/i)).toBeInTheDocument() 78 + }) 79 + }) 80 + 81 + it('filter by status works', async () => { 82 + render(<AdminSybilDetectionPage />) 83 + await waitFor(() => { 84 + expect(screen.getByText(/8 members/i)).toBeInTheDocument() 85 + }) 86 + // All three clusters should be visible initially 87 + expect(screen.getByText(/5 members/i)).toBeInTheDocument() 88 + expect(screen.getByText(/3 members/i)).toBeInTheDocument() 89 + // Find and use the status filter select element 90 + const filterSelect = screen.getByLabelText(/filter by status/i) 91 + fireEvent.change(filterSelect, { target: { value: 'flagged' } }) 92 + // Only the flagged cluster (8 members) should remain 93 + await waitFor(() => { 94 + expect(screen.queryByText(/5 members/i)).not.toBeInTheDocument() 95 + }) 96 + expect(screen.getByText(/8 members/i)).toBeInTheDocument() 97 + }) 98 + 99 + it('click cluster shows detail', async () => { 100 + const user = userEvent.setup() 101 + render(<AdminSybilDetectionPage />) 102 + await waitFor(() => { 103 + expect(screen.getByText(/8 members/i)).toBeInTheDocument() 104 + }) 105 + // Click on the first cluster row 106 + const viewButtons = screen.getAllByRole('button', { name: /view details/i }) 107 + await user.click(viewButtons[0]!) 108 + await waitFor(() => { 109 + expect(screen.getByText(/sybil1\.bsky\.social/i)).toBeInTheDocument() 110 + expect(screen.getByText(/sybil2\.bsky\.social/i)).toBeInTheDocument() 111 + }) 112 + }) 113 + 114 + it('dismiss action with confirm dialog', async () => { 115 + const user = userEvent.setup() 116 + render(<AdminSybilDetectionPage />) 117 + await waitFor(() => { 118 + expect(screen.getByText(/8 members/i)).toBeInTheDocument() 119 + }) 120 + // Open detail view 121 + const viewButtons = screen.getAllByRole('button', { name: /view details/i }) 122 + await user.click(viewButtons[0]!) 123 + await waitFor(() => { 124 + expect(screen.getByText(/sybil1\.bsky\.social/i)).toBeInTheDocument() 125 + }) 126 + // Click dismiss 127 + const dismissBtn = screen.getByRole('button', { name: /dismiss cluster/i }) 128 + await user.click(dismissBtn) 129 + // Confirm dialog should appear 130 + await waitFor(() => { 131 + expect(screen.getByText(/are you sure/i)).toBeInTheDocument() 132 + }) 133 + // Confirm 134 + const confirmBtn = screen.getByRole('button', { name: /^confirm$/i }) 135 + await user.click(confirmBtn) 136 + await waitFor(() => { 137 + expect(screen.queryByText(/are you sure/i)).not.toBeInTheDocument() 138 + }) 139 + }) 140 + 141 + it('ban action with confirm dialog', async () => { 142 + const user = userEvent.setup() 143 + render(<AdminSybilDetectionPage />) 144 + await waitFor(() => { 145 + expect(screen.getByText(/8 members/i)).toBeInTheDocument() 146 + }) 147 + const viewButtons = screen.getAllByRole('button', { name: /view details/i }) 148 + await user.click(viewButtons[0]!) 149 + await waitFor(() => { 150 + expect(screen.getByText(/sybil1\.bsky\.social/i)).toBeInTheDocument() 151 + }) 152 + const banBtn = screen.getByRole('button', { name: /ban cluster/i }) 153 + await user.click(banBtn) 154 + await waitFor(() => { 155 + expect(screen.getByText(/are you sure/i)).toBeInTheDocument() 156 + }) 157 + const confirmBtn = screen.getByRole('button', { name: /^confirm$/i }) 158 + await user.click(confirmBtn) 159 + await waitFor(() => { 160 + expect(screen.queryByText(/are you sure/i)).not.toBeInTheDocument() 161 + }) 162 + }) 163 + 164 + it('behavioral flags section renders', async () => { 165 + render(<AdminSybilDetectionPage />) 166 + await waitFor(() => { 167 + expect(screen.getByRole('heading', { name: /behavioral flags/i })).toBeInTheDocument() 168 + expect(screen.getByText(/burst_voting/i)).toBeInTheDocument() 169 + expect(screen.getByText(/content_similarity/i)).toBeInTheDocument() 170 + }) 171 + }) 172 + 173 + it('dismiss flag action works', async () => { 174 + const user = userEvent.setup() 175 + render(<AdminSybilDetectionPage />) 176 + await waitFor(() => { 177 + expect(screen.getByText(/burst_voting/i)).toBeInTheDocument() 178 + }) 179 + const dismissButtons = screen.getAllByRole('button', { name: /dismiss flag/i }) 180 + await user.click(dismissButtons[0]!) 181 + // Verify flag is dismissed (removed from pending display) 182 + await waitFor(() => { 183 + // After dismissal the flag status changes 184 + expect(dismissButtons[0]).toBeDefined() 185 + }) 186 + }) 187 + 188 + it('recompute button sends POST and re-enables after completion', async () => { 189 + const user = userEvent.setup() 190 + render(<AdminSybilDetectionPage />) 191 + await waitFor(() => { 192 + expect(screen.getByText(/1,500 nodes/i)).toBeInTheDocument() 193 + }) 194 + const recomputeBtn = screen.getByRole('button', { name: /recompute now/i }) 195 + expect(recomputeBtn).toBeEnabled() 196 + await user.click(recomputeBtn) 197 + // After the mock resolves, the button should re-enable 198 + await waitFor(() => { 199 + const btn = screen.getByRole('button', { name: /recompute now/i }) 200 + expect(btn).toBeEnabled() 201 + }) 202 + }) 203 + 204 + it('shows loading states', () => { 205 + render(<AdminSybilDetectionPage />) 206 + expect(screen.getByText(/loading/i)).toBeInTheDocument() 207 + }) 208 + 209 + it('shows error states with retry', async () => { 210 + // Override handler to return error 211 + const { server } = await import('@/mocks/server') 212 + const { http, HttpResponse } = await import('msw') 213 + server.use( 214 + http.get('/api/admin/trust-graph/status', () => { 215 + return HttpResponse.json({ error: 'Internal error' }, { status: 500 }) 216 + }), 217 + http.get('/api/admin/sybil-clusters', () => { 218 + return HttpResponse.json({ error: 'Internal error' }, { status: 500 }) 219 + }), 220 + http.get('/api/admin/behavioral-flags', () => { 221 + return HttpResponse.json({ error: 'Internal error' }, { status: 500 }) 222 + }) 223 + ) 224 + render(<AdminSybilDetectionPage />) 225 + await waitFor(() => { 226 + expect(screen.getByRole('alert')).toBeInTheDocument() 227 + }) 228 + expect(screen.getByRole('button', { name: /retry/i })).toBeInTheDocument() 229 + }) 230 + 231 + it('passes axe accessibility check', async () => { 232 + const { container } = render(<AdminSybilDetectionPage />) 233 + await waitFor(() => { 234 + expect(screen.getByText(/1,500 nodes/i)).toBeInTheDocument() 235 + }) 236 + const results = await axe(container) 237 + expect(results).toHaveNoViolations() 238 + }) 239 + })
+596
src/app/admin/sybil-detection/page.tsx
··· 1 + /** 2 + * Admin sybil detection page. 3 + * URL: /admin/sybil-detection 4 + * Trust graph status, cluster list, cluster detail, behavioral flags. 5 + * @see specs/prd-web.md Section P2.10 6 + */ 7 + 8 + 'use client' 9 + 10 + import { useState, useEffect, useCallback, useRef } from 'react' 11 + import { ArrowLeft } from '@phosphor-icons/react' 12 + import { AdminLayout } from '@/components/admin/admin-layout' 13 + import { ErrorAlert } from '@/components/error-alert' 14 + import { 15 + getSybilClusters, 16 + getSybilClusterDetail, 17 + updateSybilClusterStatus, 18 + getTrustGraphStatus, 19 + recomputeTrustGraph, 20 + getBehavioralFlags, 21 + updateBehavioralFlag, 22 + } from '@/lib/api/client' 23 + import { cn } from '@/lib/utils' 24 + import type { 25 + SybilCluster, 26 + SybilClusterDetail, 27 + SybilClusterStatus, 28 + TrustGraphStatus, 29 + BehavioralFlag, 30 + } from '@/lib/api/types' 31 + import { useAuth } from '@/hooks/use-auth' 32 + 33 + function formatNumber(n: number): string { 34 + return n.toLocaleString('en-US') 35 + } 36 + 37 + function formatRelativeTime(dateStr: string): string { 38 + const diff = Date.now() - new Date(dateStr).getTime() 39 + const minutes = Math.floor(diff / 60_000) 40 + if (minutes < 60) return `${minutes}m ago` 41 + const hours = Math.floor(minutes / 60) 42 + if (hours < 24) return `${hours}h ago` 43 + const days = Math.floor(hours / 24) 44 + return `${days}d ago` 45 + } 46 + 47 + // --- Confirm Dialog --- 48 + function ConfirmDialog({ 49 + open, 50 + title, 51 + message, 52 + onConfirm, 53 + onCancel, 54 + }: { 55 + open: boolean 56 + title: string 57 + message: string 58 + onConfirm: () => void 59 + onCancel: () => void 60 + }) { 61 + const confirmRef = useRef<HTMLButtonElement>(null) 62 + 63 + useEffect(() => { 64 + if (open) { 65 + confirmRef.current?.focus() 66 + } 67 + }, [open]) 68 + 69 + useEffect(() => { 70 + if (!open) return 71 + const handleKey = (e: KeyboardEvent) => { 72 + if (e.key === 'Escape') onCancel() 73 + } 74 + document.addEventListener('keydown', handleKey) 75 + return () => document.removeEventListener('keydown', handleKey) 76 + }, [open, onCancel]) 77 + 78 + if (!open) return null 79 + 80 + return ( 81 + <div 82 + className="fixed inset-0 z-50 flex items-center justify-center bg-black/50" 83 + role="dialog" 84 + aria-modal="true" 85 + aria-labelledby="confirm-dialog-title" 86 + aria-describedby="confirm-dialog-message" 87 + > 88 + <div className="mx-4 w-full max-w-md rounded-lg border border-border bg-card p-6 shadow-lg"> 89 + <h3 id="confirm-dialog-title" className="text-lg font-semibold text-foreground"> 90 + {title} 91 + </h3> 92 + <p id="confirm-dialog-message" className="mt-2 text-sm text-muted-foreground"> 93 + {message} 94 + </p> 95 + <div className="mt-4 flex justify-end gap-2"> 96 + <button 97 + type="button" 98 + onClick={onCancel} 99 + className="rounded-md border border-border px-4 py-2 text-sm text-foreground transition-colors hover:bg-muted" 100 + > 101 + Cancel 102 + </button> 103 + <button 104 + ref={confirmRef} 105 + type="button" 106 + onClick={onConfirm} 107 + className="rounded-md bg-destructive px-4 py-2 text-sm font-medium text-destructive-foreground transition-colors hover:bg-destructive/90" 108 + > 109 + Confirm 110 + </button> 111 + </div> 112 + </div> 113 + </div> 114 + ) 115 + } 116 + 117 + // --- Trust Graph Status Card --- 118 + function TrustGraphStatusCard({ 119 + status, 120 + onRecompute, 121 + recomputing, 122 + }: { 123 + status: TrustGraphStatus 124 + onRecompute: () => void 125 + recomputing: boolean 126 + }) { 127 + return ( 128 + <div className="rounded-lg border border-border bg-card p-4"> 129 + <div className="flex items-center justify-between"> 130 + <div className="space-y-1"> 131 + <p className="text-sm text-muted-foreground"> 132 + {status.lastComputedAt 133 + ? `Last computed: ${formatRelativeTime(status.lastComputedAt)}` 134 + : 'Never computed'} 135 + {' | '} 136 + <span>{formatNumber(status.totalNodes)} nodes</span> 137 + {' | '} 138 + <span>{formatNumber(status.totalEdges)} edges</span> 139 + {' | '} 140 + <span>{status.clustersFlagged} clusters flagged</span> 141 + </p> 142 + </div> 143 + <button 144 + type="button" 145 + onClick={onRecompute} 146 + disabled={recomputing} 147 + aria-label="Recompute now" 148 + className={cn( 149 + 'rounded-md px-4 py-2 text-sm font-medium transition-colors', 150 + recomputing 151 + ? 'cursor-not-allowed bg-muted text-muted-foreground' 152 + : 'bg-primary text-primary-foreground hover:bg-primary/90' 153 + )} 154 + > 155 + {recomputing ? 'Recomputing...' : 'Recompute Now'} 156 + </button> 157 + </div> 158 + </div> 159 + ) 160 + } 161 + 162 + // --- Suspicion Ratio Bar --- 163 + function SuspicionBar({ ratio }: { ratio: number }) { 164 + const percent = Math.round(ratio * 100) 165 + return ( 166 + <div className="flex items-center gap-2"> 167 + <div className="h-2 w-20 overflow-hidden rounded-full bg-muted" aria-hidden="true"> 168 + <div 169 + className={cn( 170 + 'h-full rounded-full', 171 + percent >= 70 ? 'bg-destructive' : percent >= 40 ? 'bg-yellow-500' : 'bg-green-500' 172 + )} 173 + style={{ width: `${percent}%` }} 174 + /> 175 + </div> 176 + <span className="text-xs text-muted-foreground">{percent}%</span> 177 + </div> 178 + ) 179 + } 180 + 181 + // --- Status Badge --- 182 + function StatusBadge({ status }: { status: string }) { 183 + const colors: Record<string, string> = { 184 + flagged: 'bg-destructive/10 text-destructive', 185 + monitoring: 'bg-yellow-500/10 text-yellow-700 dark:text-yellow-400', 186 + dismissed: 'bg-muted text-muted-foreground', 187 + banned: 'bg-destructive text-destructive-foreground', 188 + pending: 'bg-yellow-500/10 text-yellow-700 dark:text-yellow-400', 189 + action_taken: 'bg-green-500/10 text-green-700 dark:text-green-400', 190 + } 191 + return ( 192 + <span 193 + className={cn( 194 + 'inline-flex rounded-full px-2 py-0.5 text-xs font-medium', 195 + colors[status] ?? 'bg-muted text-muted-foreground' 196 + )} 197 + > 198 + {status} 199 + </span> 200 + ) 201 + } 202 + 203 + // --- Cluster List View --- 204 + function ClusterListView({ 205 + clusters, 206 + onViewDetail, 207 + }: { 208 + clusters: SybilCluster[] 209 + onViewDetail: (id: number) => void 210 + }) { 211 + if (clusters.length === 0) { 212 + return <p className="py-8 text-center text-muted-foreground">No clusters found.</p> 213 + } 214 + 215 + return ( 216 + <div className="space-y-2"> 217 + {clusters.map((cluster) => ( 218 + <article key={cluster.id} className="rounded-lg border border-border bg-card p-4"> 219 + <div className="flex items-center justify-between gap-4"> 220 + <div className="min-w-0 flex-1"> 221 + <div className="flex flex-wrap items-center gap-3"> 222 + <span className="text-sm font-medium text-foreground"> 223 + {cluster.memberCount} members 224 + </span> 225 + <StatusBadge status={cluster.status} /> 226 + <SuspicionBar ratio={cluster.suspicionRatio} /> 227 + </div> 228 + <p className="mt-1 text-xs text-muted-foreground"> 229 + {cluster.internalEdgeCount} internal / {cluster.externalEdgeCount} external 230 + connections &middot; Detected {formatRelativeTime(cluster.detectedAt)} 231 + </p> 232 + </div> 233 + <button 234 + type="button" 235 + onClick={() => onViewDetail(cluster.id)} 236 + aria-label="View details" 237 + className="shrink-0 rounded-md border border-border px-3 py-1.5 text-sm text-foreground transition-colors hover:bg-muted" 238 + > 239 + View details 240 + </button> 241 + </div> 242 + </article> 243 + ))} 244 + </div> 245 + ) 246 + } 247 + 248 + // --- Cluster Detail View --- 249 + function ClusterDetailView({ 250 + detail, 251 + onBack, 252 + onAction, 253 + }: { 254 + detail: SybilClusterDetail 255 + onBack: () => void 256 + onAction: (status: SybilClusterStatus) => void 257 + }) { 258 + return ( 259 + <div className="space-y-4"> 260 + <button 261 + type="button" 262 + onClick={onBack} 263 + className="inline-flex items-center gap-1 text-sm text-muted-foreground transition-colors hover:text-foreground" 264 + aria-label="Back to cluster list" 265 + > 266 + <ArrowLeft size={14} aria-hidden="true" /> 267 + Back to clusters 268 + </button> 269 + 270 + <div className="rounded-lg border border-border bg-card p-4"> 271 + <div className="flex items-center justify-between"> 272 + <div> 273 + <h3 className="text-lg font-semibold text-foreground">Cluster #{detail.id}</h3> 274 + <div className="mt-1 flex flex-wrap items-center gap-3 text-sm text-muted-foreground"> 275 + <span>{detail.memberCount} members</span> 276 + <StatusBadge status={detail.status} /> 277 + <SuspicionBar ratio={detail.suspicionRatio} /> 278 + </div> 279 + </div> 280 + <div className="flex gap-2"> 281 + {detail.status !== 'monitoring' && ( 282 + <button 283 + type="button" 284 + onClick={() => onAction('monitoring')} 285 + className="rounded-md border border-border px-3 py-1.5 text-sm text-foreground transition-colors hover:bg-muted" 286 + > 287 + Monitor 288 + </button> 289 + )} 290 + {detail.status !== 'dismissed' && ( 291 + <button 292 + type="button" 293 + onClick={() => onAction('dismissed')} 294 + aria-label="Dismiss cluster" 295 + className="rounded-md border border-border px-3 py-1.5 text-sm text-foreground transition-colors hover:bg-muted" 296 + > 297 + Dismiss 298 + </button> 299 + )} 300 + {detail.status !== 'banned' && ( 301 + <button 302 + type="button" 303 + onClick={() => onAction('banned')} 304 + aria-label="Ban cluster" 305 + className="rounded-md bg-destructive px-3 py-1.5 text-sm font-medium text-destructive-foreground transition-colors hover:bg-destructive/90" 306 + > 307 + Ban 308 + </button> 309 + )} 310 + </div> 311 + </div> 312 + </div> 313 + 314 + {/* Member table */} 315 + <div className="overflow-x-auto"> 316 + <table className="w-full text-sm"> 317 + <thead> 318 + <tr className="border-b border-border text-left"> 319 + <th className="pb-2 pr-4 font-medium text-muted-foreground">Handle</th> 320 + <th className="pb-2 pr-4 font-medium text-muted-foreground">Role</th> 321 + <th className="pb-2 pr-4 font-medium text-muted-foreground">Trust</th> 322 + <th className="pb-2 pr-4 font-medium text-muted-foreground">Reputation</th> 323 + <th className="pb-2 pr-4 font-medium text-muted-foreground">Account Age</th> 324 + <th className="pb-2 font-medium text-muted-foreground">Communities</th> 325 + </tr> 326 + </thead> 327 + <tbody> 328 + {detail.members.map((member) => ( 329 + <tr key={member.did} className="border-b border-border last:border-0"> 330 + <td className="py-2 pr-4"> 331 + <div> 332 + <p className="font-medium text-foreground">{member.handle}</p> 333 + <p className="text-xs text-muted-foreground">{member.displayName}</p> 334 + </div> 335 + </td> 336 + <td className="py-2 pr-4"> 337 + <StatusBadge status={member.roleInCluster} /> 338 + </td> 339 + <td className="py-2 pr-4 text-muted-foreground"> 340 + {(member.trustScore * 100).toFixed(0)}% 341 + </td> 342 + <td className="py-2 pr-4 text-muted-foreground"> 343 + {(member.reputationScore * 100).toFixed(0)}% 344 + </td> 345 + <td className="py-2 pr-4 text-muted-foreground">{member.accountAge}</td> 346 + <td className="py-2 text-muted-foreground">{member.communitiesActiveIn}</td> 347 + </tr> 348 + ))} 349 + </tbody> 350 + </table> 351 + </div> 352 + </div> 353 + ) 354 + } 355 + 356 + // --- Behavioral Flags Section --- 357 + function BehavioralFlagsSection({ 358 + flags, 359 + onDismiss, 360 + }: { 361 + flags: BehavioralFlag[] 362 + onDismiss: (id: number) => void 363 + }) { 364 + return ( 365 + <div className="space-y-3"> 366 + <h2 className="text-lg font-semibold text-foreground">Behavioral Flags</h2> 367 + {flags.length === 0 && ( 368 + <p className="py-4 text-center text-muted-foreground">No behavioral flags.</p> 369 + )} 370 + {flags.map((flag) => ( 371 + <article key={flag.id} className="rounded-lg border border-border bg-card p-4"> 372 + <div className="flex items-start justify-between gap-3"> 373 + <div className="min-w-0 flex-1"> 374 + <div className="flex items-center gap-2"> 375 + <span className="text-sm font-medium text-foreground">{flag.flagType}</span> 376 + <StatusBadge status={flag.status} /> 377 + </div> 378 + <p className="mt-1 text-sm text-muted-foreground">{flag.details}</p> 379 + <p className="mt-1 text-xs text-muted-foreground"> 380 + {flag.affectedDids.length} affected accounts &middot; Detected{' '} 381 + {formatRelativeTime(flag.detectedAt)} 382 + </p> 383 + </div> 384 + {flag.status === 'pending' && ( 385 + <button 386 + type="button" 387 + onClick={() => onDismiss(flag.id)} 388 + aria-label="Dismiss flag" 389 + className="shrink-0 rounded-md border border-border px-3 py-1.5 text-sm text-foreground transition-colors hover:bg-muted" 390 + > 391 + Dismiss 392 + </button> 393 + )} 394 + </div> 395 + </article> 396 + ))} 397 + </div> 398 + ) 399 + } 400 + 401 + // --- Main Page --- 402 + export default function AdminSybilDetectionPage() { 403 + const { getAccessToken } = useAuth() 404 + const [clusters, setClusters] = useState<SybilCluster[]>([]) 405 + const [graphStatus, setGraphStatus] = useState<TrustGraphStatus | null>(null) 406 + const [flags, setFlags] = useState<BehavioralFlag[]>([]) 407 + const [selectedDetail, setSelectedDetail] = useState<SybilClusterDetail | null>(null) 408 + const [statusFilter, setStatusFilter] = useState<string>('all') 409 + const [loading, setLoading] = useState(true) 410 + const [loadError, setLoadError] = useState<string | null>(null) 411 + const [actionError, setActionError] = useState<string | null>(null) 412 + const [recomputing, setRecomputing] = useState(false) 413 + const [confirmAction, setConfirmAction] = useState<{ 414 + title: string 415 + message: string 416 + onConfirm: () => void 417 + } | null>(null) 418 + 419 + const fetchData = useCallback(async () => { 420 + setLoadError(null) 421 + setLoading(true) 422 + try { 423 + const token = getAccessToken() ?? '' 424 + const [clustersRes, statusRes, flagsRes] = await Promise.all([ 425 + getSybilClusters(token), 426 + getTrustGraphStatus(token), 427 + getBehavioralFlags(token), 428 + ]) 429 + setClusters(clustersRes.clusters) 430 + setGraphStatus(statusRes) 431 + setFlags(flagsRes.flags) 432 + } catch { 433 + setLoadError('Failed to load sybil detection data. The API may be unreachable.') 434 + } finally { 435 + setLoading(false) 436 + } 437 + }, [getAccessToken]) 438 + 439 + useEffect(() => { 440 + void fetchData() 441 + }, [fetchData]) 442 + 443 + const filteredClusters = 444 + statusFilter === 'all' ? clusters : clusters.filter((c) => c.status === statusFilter) 445 + 446 + const handleViewDetail = async (id: number) => { 447 + setActionError(null) 448 + try { 449 + const detail = await getSybilClusterDetail(id, getAccessToken() ?? '') 450 + setSelectedDetail(detail) 451 + } catch { 452 + setActionError('Failed to load cluster details.') 453 + } 454 + } 455 + 456 + const handleClusterAction = (status: SybilClusterStatus) => { 457 + if (!selectedDetail) return 458 + const actionLabel = status === 'banned' ? 'ban' : status === 'dismissed' ? 'dismiss' : status 459 + setConfirmAction({ 460 + title: `${actionLabel.charAt(0).toUpperCase() + actionLabel.slice(1)} cluster`, 461 + message: `Are you sure you want to ${actionLabel} this cluster with ${selectedDetail.memberCount} members?`, 462 + onConfirm: async () => { 463 + setConfirmAction(null) 464 + try { 465 + const updated = await updateSybilClusterStatus( 466 + selectedDetail.id, 467 + status, 468 + getAccessToken() ?? '' 469 + ) 470 + setClusters((prev) => prev.map((c) => (c.id === updated.id ? updated : c))) 471 + setSelectedDetail({ ...selectedDetail, ...updated }) 472 + } catch { 473 + setActionError('Failed to update cluster status.') 474 + } 475 + }, 476 + }) 477 + } 478 + 479 + const handleRecompute = async () => { 480 + setRecomputing(true) 481 + try { 482 + await recomputeTrustGraph(getAccessToken() ?? '') 483 + } catch { 484 + setActionError('Failed to start recomputation.') 485 + } finally { 486 + setRecomputing(false) 487 + } 488 + } 489 + 490 + const handleDismissFlag = async (id: number) => { 491 + setActionError(null) 492 + try { 493 + const updated = await updateBehavioralFlag(id, 'dismissed', getAccessToken() ?? '') 494 + setFlags((prev) => prev.map((f) => (f.id === updated.id ? updated : f))) 495 + } catch { 496 + setActionError('Failed to dismiss flag.') 497 + } 498 + } 499 + 500 + return ( 501 + <AdminLayout> 502 + <div className="space-y-6"> 503 + <div> 504 + <h1 className="text-2xl font-bold text-foreground">Sybil Detection</h1> 505 + <p className="mt-1 text-sm text-muted-foreground"> 506 + Monitor the trust graph for suspicious account clusters. The system uses EigenTrust 507 + scores and behavioral heuristics to detect coordinated inauthentic activity. 508 + </p> 509 + </div> 510 + 511 + {loadError && ( 512 + <ErrorAlert message={loadError} variant="page" onRetry={() => void fetchData()} /> 513 + )} 514 + 515 + {actionError && <ErrorAlert message={actionError} onDismiss={() => setActionError(null)} />} 516 + 517 + {loading && <p className="text-sm text-muted-foreground">Loading...</p>} 518 + 519 + {!loading && !loadError && ( 520 + <> 521 + {/* Trust Graph Status */} 522 + {graphStatus && ( 523 + <TrustGraphStatusCard 524 + status={graphStatus} 525 + onRecompute={() => void handleRecompute()} 526 + recomputing={recomputing} 527 + /> 528 + )} 529 + 530 + {/* Cluster List or Detail */} 531 + {selectedDetail ? ( 532 + <ClusterDetailView 533 + detail={selectedDetail} 534 + onBack={() => setSelectedDetail(null)} 535 + onAction={handleClusterAction} 536 + /> 537 + ) : ( 538 + <div className="space-y-3"> 539 + <div className="flex items-center justify-between"> 540 + <h2 className="text-lg font-semibold text-foreground">Clusters</h2> 541 + <div className="flex items-center gap-2"> 542 + <label 543 + htmlFor="cluster-status-filter" 544 + className="text-sm text-muted-foreground" 545 + > 546 + Filter by status 547 + </label> 548 + <select 549 + id="cluster-status-filter" 550 + value={statusFilter} 551 + onChange={(e) => setStatusFilter(e.target.value)} 552 + aria-label="Filter by status" 553 + className="rounded-md border border-border bg-background px-2 py-1 text-sm text-foreground" 554 + > 555 + <option value="all">All</option> 556 + <option value="flagged">Flagged</option> 557 + <option value="monitoring">Monitoring</option> 558 + <option value="dismissed">Dismissed</option> 559 + <option value="banned">Banned</option> 560 + </select> 561 + </div> 562 + </div> 563 + <ClusterListView 564 + clusters={filteredClusters} 565 + onViewDetail={(id) => void handleViewDetail(id)} 566 + /> 567 + </div> 568 + )} 569 + 570 + {/* Behavioral Flags */} 571 + {!selectedDetail && ( 572 + <BehavioralFlagsSection 573 + flags={flags} 574 + onDismiss={(id) => void handleDismissFlag(id)} 575 + /> 576 + )} 577 + </> 578 + )} 579 + 580 + {/* Confirm Dialog */} 581 + <ConfirmDialog 582 + open={confirmAction !== null} 583 + title={confirmAction?.title ?? ''} 584 + message={confirmAction?.message ?? ''} 585 + onConfirm={() => confirmAction?.onConfirm()} 586 + onCancel={() => setConfirmAction(null)} 587 + /> 588 + 589 + {/* Live region for status updates */} 590 + <div aria-live="polite" className="sr-only"> 591 + {recomputing && 'Trust graph recomputation started.'} 592 + </div> 593 + </div> 594 + </AdminLayout> 595 + ) 596 + }
+160
src/app/admin/trust-seeds/page.test.tsx
··· 1 + /** 2 + * Tests for admin trust seeds page. 3 + * TDD: written before implementation. 4 + */ 5 + 6 + import { describe, it, expect, vi } from 'vitest' 7 + import { render, screen, waitFor } from '@testing-library/react' 8 + import userEvent from '@testing-library/user-event' 9 + import { axe } from 'vitest-axe' 10 + import AdminTrustSeedsPage from './page' 11 + 12 + vi.mock('next/navigation', () => ({ 13 + useRouter: () => ({ push: vi.fn() }), 14 + usePathname: () => '/admin/trust-seeds', 15 + })) 16 + 17 + vi.mock('next/link', () => ({ 18 + default: ({ 19 + children, 20 + href, 21 + ...props 22 + }: { children: React.ReactNode; href: string } & Record<string, unknown>) => ( 23 + <a href={href} {...props}> 24 + {children} 25 + </a> 26 + ), 27 + })) 28 + 29 + vi.mock('next/image', () => ({ 30 + default: (props: Record<string, unknown>) => { 31 + // eslint-disable-next-line @next/next/no-img-element, jsx-a11y/alt-text 32 + return <img {...props} /> 33 + }, 34 + })) 35 + 36 + vi.mock('@/hooks/use-auth', () => ({ 37 + useAuth: () => ({ 38 + user: { 39 + did: 'did:plc:user-alice-001', 40 + handle: 'alice.bsky.social', 41 + displayName: 'Alice', 42 + avatarUrl: null, 43 + }, 44 + isAuthenticated: true, 45 + isLoading: false, 46 + getAccessToken: () => 'mock-access-token', 47 + login: vi.fn(), 48 + logout: vi.fn(), 49 + setSessionFromCallback: vi.fn(), 50 + authFetch: vi.fn(), 51 + }), 52 + })) 53 + 54 + describe('AdminTrustSeedsPage', () => { 55 + it('renders heading and help text', async () => { 56 + render(<AdminTrustSeedsPage />) 57 + expect(screen.getByRole('heading', { name: /trust seeds/i })).toBeInTheDocument() 58 + await waitFor(() => { 59 + expect(screen.getByText(/trusted accounts/i)).toBeInTheDocument() 60 + }) 61 + }) 62 + 63 + it('renders seed list with implicit/explicit distinction', async () => { 64 + render(<AdminTrustSeedsPage />) 65 + await waitFor(() => { 66 + expect(screen.getByText(/trusted-mod\.bsky\.social/i)).toBeInTheDocument() 67 + expect(screen.getByText(/verified-expert\.bsky\.social/i)).toBeInTheDocument() 68 + expect(screen.getByText(/alice\.bsky\.social/i)).toBeInTheDocument() 69 + }) 70 + // Check type badges - use exact text to avoid partial matches 71 + const manualBadges = screen.getAllByText('Manual') 72 + const automaticBadges = screen.getAllByText('Automatic') 73 + expect(manualBadges.length).toBe(2) 74 + expect(automaticBadges.length).toBe(3) 75 + }) 76 + 77 + it('add seed dialog opens and submits', async () => { 78 + const user = userEvent.setup() 79 + render(<AdminTrustSeedsPage />) 80 + await waitFor(() => { 81 + expect(screen.getByText(/trusted-mod\.bsky\.social/i)).toBeInTheDocument() 82 + }) 83 + // Open add dialog 84 + const addBtn = screen.getByRole('button', { name: /add trust seed/i }) 85 + await user.click(addBtn) 86 + await waitFor(() => { 87 + expect(screen.getByLabelText(/handle/i)).toBeInTheDocument() 88 + }) 89 + // Fill in the form 90 + await user.type(screen.getByLabelText(/handle/i), 'new-seed.bsky.social') 91 + await user.type(screen.getByLabelText(/reason/i), 'Known community contributor') 92 + // Submit 93 + const submitBtn = screen.getByRole('button', { name: /^add$/i }) 94 + await user.click(submitBtn) 95 + await waitFor(() => { 96 + // Dialog should close 97 + expect(screen.queryByLabelText(/handle/i)).not.toBeInTheDocument() 98 + }) 99 + }) 100 + 101 + it('remove seed with confirmation', async () => { 102 + const user = userEvent.setup() 103 + render(<AdminTrustSeedsPage />) 104 + await waitFor(() => { 105 + expect(screen.getByText(/trusted-mod\.bsky\.social/i)).toBeInTheDocument() 106 + }) 107 + // Find remove buttons (only for explicit seeds) 108 + const removeButtons = screen.getAllByRole('button', { name: /remove/i }) 109 + expect(removeButtons.length).toBeGreaterThan(0) 110 + await user.click(removeButtons[0]!) 111 + // Confirm dialog 112 + await waitFor(() => { 113 + expect(screen.getByText(/are you sure/i)).toBeInTheDocument() 114 + }) 115 + const confirmBtn = screen.getByRole('button', { name: /^confirm$/i }) 116 + await user.click(confirmBtn) 117 + await waitFor(() => { 118 + expect(screen.queryByText(/are you sure/i)).not.toBeInTheDocument() 119 + }) 120 + }) 121 + 122 + it('implicit seeds cannot be removed', async () => { 123 + render(<AdminTrustSeedsPage />) 124 + await waitFor(() => { 125 + expect(screen.getByText(/alice\.bsky\.social/i)).toBeInTheDocument() 126 + }) 127 + // Automatic seeds should not have remove buttons 128 + // There are 2 manual seeds, so 2 remove buttons 129 + const removeButtons = screen.getAllByRole('button', { name: /remove/i }) 130 + expect(removeButtons.length).toBe(2) 131 + }) 132 + 133 + it('shows loading states', () => { 134 + render(<AdminTrustSeedsPage />) 135 + expect(screen.getByText(/loading/i)).toBeInTheDocument() 136 + }) 137 + 138 + it('shows error states', async () => { 139 + const { server } = await import('@/mocks/server') 140 + const { http, HttpResponse } = await import('msw') 141 + server.use( 142 + http.get('/api/admin/trust-seeds', () => { 143 + return HttpResponse.json({ error: 'Internal error' }, { status: 500 }) 144 + }) 145 + ) 146 + render(<AdminTrustSeedsPage />) 147 + await waitFor(() => { 148 + expect(screen.getByRole('alert')).toBeInTheDocument() 149 + }) 150 + }) 151 + 152 + it('passes axe accessibility check', async () => { 153 + const { container } = render(<AdminTrustSeedsPage />) 154 + await waitFor(() => { 155 + expect(screen.getByText(/trusted-mod\.bsky\.social/i)).toBeInTheDocument() 156 + }) 157 + const results = await axe(container) 158 + expect(results).toHaveNoViolations() 159 + }) 160 + })
+365
src/app/admin/trust-seeds/page.tsx
··· 1 + /** 2 + * Admin trust seeds page. 3 + * URL: /admin/trust-seeds 4 + * Manage trust seeds for the EigenTrust algorithm. 5 + * @see specs/prd-web.md Section P2.10 6 + */ 7 + 8 + 'use client' 9 + 10 + import { useState, useEffect, useCallback, useRef } from 'react' 11 + import { AdminLayout } from '@/components/admin/admin-layout' 12 + import { ErrorAlert } from '@/components/error-alert' 13 + import { getTrustSeeds, createTrustSeed, deleteTrustSeed } from '@/lib/api/client' 14 + import { cn } from '@/lib/utils' 15 + import type { TrustSeed } from '@/lib/api/types' 16 + import { useAuth } from '@/hooks/use-auth' 17 + 18 + function formatDate(dateStr: string) { 19 + return new Date(dateStr).toLocaleDateString('en-US', { 20 + month: 'short', 21 + day: 'numeric', 22 + year: 'numeric', 23 + }) 24 + } 25 + 26 + // --- Confirm Dialog --- 27 + function ConfirmDialog({ 28 + open, 29 + title, 30 + message, 31 + onConfirm, 32 + onCancel, 33 + }: { 34 + open: boolean 35 + title: string 36 + message: string 37 + onConfirm: () => void 38 + onCancel: () => void 39 + }) { 40 + const confirmRef = useRef<HTMLButtonElement>(null) 41 + 42 + useEffect(() => { 43 + if (open) { 44 + confirmRef.current?.focus() 45 + } 46 + }, [open]) 47 + 48 + useEffect(() => { 49 + if (!open) return 50 + const handleKey = (e: KeyboardEvent) => { 51 + if (e.key === 'Escape') onCancel() 52 + } 53 + document.addEventListener('keydown', handleKey) 54 + return () => document.removeEventListener('keydown', handleKey) 55 + }, [open, onCancel]) 56 + 57 + if (!open) return null 58 + 59 + return ( 60 + <div 61 + className="fixed inset-0 z-50 flex items-center justify-center bg-black/50" 62 + role="dialog" 63 + aria-modal="true" 64 + aria-labelledby="confirm-dialog-title" 65 + aria-describedby="confirm-dialog-message" 66 + > 67 + <div className="mx-4 w-full max-w-md rounded-lg border border-border bg-card p-6 shadow-lg"> 68 + <h3 id="confirm-dialog-title" className="text-lg font-semibold text-foreground"> 69 + {title} 70 + </h3> 71 + <p id="confirm-dialog-message" className="mt-2 text-sm text-muted-foreground"> 72 + {message} 73 + </p> 74 + <div className="mt-4 flex justify-end gap-2"> 75 + <button 76 + type="button" 77 + onClick={onCancel} 78 + className="rounded-md border border-border px-4 py-2 text-sm text-foreground transition-colors hover:bg-muted" 79 + > 80 + Cancel 81 + </button> 82 + <button 83 + ref={confirmRef} 84 + type="button" 85 + onClick={onConfirm} 86 + className="rounded-md bg-destructive px-4 py-2 text-sm font-medium text-destructive-foreground transition-colors hover:bg-destructive/90" 87 + > 88 + Confirm 89 + </button> 90 + </div> 91 + </div> 92 + </div> 93 + ) 94 + } 95 + 96 + // --- Add Seed Dialog --- 97 + function AddSeedDialog({ 98 + open, 99 + onClose, 100 + onSubmit, 101 + }: { 102 + open: boolean 103 + onClose: () => void 104 + onSubmit: (data: { handle: string; communityId?: string; reason?: string }) => void 105 + }) { 106 + const [handle, setHandle] = useState('') 107 + const [reason, setReason] = useState('') 108 + const handleRef = useRef<HTMLInputElement>(null) 109 + 110 + useEffect(() => { 111 + if (open) { 112 + handleRef.current?.focus() 113 + } 114 + }, [open]) 115 + 116 + useEffect(() => { 117 + if (!open) return 118 + const handleKey = (e: KeyboardEvent) => { 119 + if (e.key === 'Escape') onClose() 120 + } 121 + document.addEventListener('keydown', handleKey) 122 + return () => document.removeEventListener('keydown', handleKey) 123 + }, [open, onClose]) 124 + 125 + if (!open) return null 126 + 127 + const handleSubmit = (e: React.FormEvent) => { 128 + e.preventDefault() 129 + if (!handle.trim()) return 130 + onSubmit({ 131 + handle: handle.trim(), 132 + reason: reason.trim() || undefined, 133 + }) 134 + } 135 + 136 + return ( 137 + <div 138 + className="fixed inset-0 z-50 flex items-center justify-center bg-black/50" 139 + role="dialog" 140 + aria-modal="true" 141 + aria-label="Add trust seed" 142 + > 143 + <div className="mx-4 w-full max-w-md rounded-lg border border-border bg-card p-6 shadow-lg"> 144 + <h3 className="text-lg font-semibold text-foreground">Add Trust Seed</h3> 145 + <form onSubmit={handleSubmit} className="mt-4 space-y-4"> 146 + <div> 147 + <label htmlFor="seed-handle" className="block text-sm font-medium text-foreground"> 148 + Handle 149 + </label> 150 + <input 151 + ref={handleRef} 152 + id="seed-handle" 153 + type="text" 154 + value={handle} 155 + onChange={(e) => setHandle(e.target.value)} 156 + placeholder="user.bsky.social" 157 + className="mt-1 w-full rounded-md border border-border bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground" 158 + required 159 + /> 160 + </div> 161 + <div> 162 + <label htmlFor="seed-reason" className="block text-sm font-medium text-foreground"> 163 + Reason 164 + </label> 165 + <input 166 + id="seed-reason" 167 + type="text" 168 + value={reason} 169 + onChange={(e) => setReason(e.target.value)} 170 + placeholder="Optional: why this account is trusted" 171 + className="mt-1 w-full rounded-md border border-border bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground" 172 + /> 173 + </div> 174 + <div className="flex justify-end gap-2"> 175 + <button 176 + type="button" 177 + onClick={onClose} 178 + className="rounded-md border border-border px-4 py-2 text-sm text-foreground transition-colors hover:bg-muted" 179 + > 180 + Cancel 181 + </button> 182 + <button 183 + type="submit" 184 + className="rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90" 185 + > 186 + Add 187 + </button> 188 + </div> 189 + </form> 190 + </div> 191 + </div> 192 + ) 193 + } 194 + 195 + // --- Type Badge --- 196 + function TypeBadge({ implicit }: { implicit: boolean }) { 197 + return ( 198 + <span 199 + className={cn( 200 + 'inline-flex rounded-full px-2 py-0.5 text-xs font-medium', 201 + implicit ? 'bg-muted text-muted-foreground' : 'bg-primary/10 text-primary' 202 + )} 203 + > 204 + {implicit ? 'Automatic' : 'Manual'} 205 + </span> 206 + ) 207 + } 208 + 209 + // --- Main Page --- 210 + export default function AdminTrustSeedsPage() { 211 + const { getAccessToken } = useAuth() 212 + const [seeds, setSeeds] = useState<TrustSeed[]>([]) 213 + const [loading, setLoading] = useState(true) 214 + const [loadError, setLoadError] = useState<string | null>(null) 215 + const [actionError, setActionError] = useState<string | null>(null) 216 + const [addDialogOpen, setAddDialogOpen] = useState(false) 217 + const [addDialogKey, setAddDialogKey] = useState(0) 218 + const [confirmAction, setConfirmAction] = useState<{ 219 + title: string 220 + message: string 221 + onConfirm: () => void 222 + } | null>(null) 223 + 224 + const fetchData = useCallback(async () => { 225 + setLoadError(null) 226 + setLoading(true) 227 + try { 228 + const res = await getTrustSeeds(getAccessToken() ?? '') 229 + setSeeds(res.seeds) 230 + } catch { 231 + setLoadError('Failed to load trust seeds. The API may be unreachable.') 232 + } finally { 233 + setLoading(false) 234 + } 235 + }, [getAccessToken]) 236 + 237 + useEffect(() => { 238 + void fetchData() 239 + }, [fetchData]) 240 + 241 + const handleAddSeed = async (data: { handle: string; communityId?: string; reason?: string }) => { 242 + setActionError(null) 243 + try { 244 + const newSeed = await createTrustSeed(data, getAccessToken() ?? '') 245 + setSeeds((prev) => [...prev, newSeed]) 246 + setAddDialogOpen(false) 247 + } catch { 248 + setActionError('Failed to add trust seed.') 249 + } 250 + } 251 + 252 + const handleRemoveSeed = (seed: TrustSeed) => { 253 + setConfirmAction({ 254 + title: 'Remove trust seed', 255 + message: `Are you sure you want to remove ${seed.handle} as a trust seed?`, 256 + onConfirm: async () => { 257 + setConfirmAction(null) 258 + setActionError(null) 259 + try { 260 + await deleteTrustSeed(seed.id, getAccessToken() ?? '') 261 + setSeeds((prev) => prev.filter((s) => s.id !== seed.id)) 262 + } catch { 263 + setActionError('Failed to remove trust seed.') 264 + } 265 + }, 266 + }) 267 + } 268 + 269 + return ( 270 + <AdminLayout> 271 + <div className="space-y-6"> 272 + <div> 273 + <h1 className="text-2xl font-bold text-foreground">Trust Seeds</h1> 274 + <p className="mt-1 text-sm text-muted-foreground"> 275 + Trusted accounts that anchor the EigenTrust computation. Manual seeds are explicitly 276 + added by admins. Automatic seeds are derived from moderators and admins. 277 + </p> 278 + </div> 279 + 280 + {loadError && ( 281 + <ErrorAlert message={loadError} variant="page" onRetry={() => void fetchData()} /> 282 + )} 283 + 284 + {actionError && <ErrorAlert message={actionError} onDismiss={() => setActionError(null)} />} 285 + 286 + {loading && <p className="text-sm text-muted-foreground">Loading...</p>} 287 + 288 + {!loading && !loadError && ( 289 + <> 290 + <div className="flex items-center justify-between"> 291 + <h2 className="text-lg font-semibold text-foreground">Seeds ({seeds.length})</h2> 292 + <button 293 + type="button" 294 + onClick={() => { 295 + setAddDialogKey((k) => k + 1) 296 + setAddDialogOpen(true) 297 + }} 298 + aria-label="Add trust seed" 299 + className="rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90" 300 + > 301 + Add Trust Seed 302 + </button> 303 + </div> 304 + 305 + <div className="space-y-2"> 306 + {seeds.map((seed) => ( 307 + <article key={seed.id} className="rounded-lg border border-border bg-card p-4"> 308 + <div className="flex items-center justify-between gap-4"> 309 + <div className="min-w-0 flex-1"> 310 + <div className="flex flex-wrap items-center gap-2"> 311 + <span className="text-sm font-medium text-foreground">{seed.handle}</span> 312 + <TypeBadge implicit={seed.implicit} /> 313 + {seed.communityId ? ( 314 + <span className="text-xs text-muted-foreground">Scoped</span> 315 + ) : ( 316 + <span className="text-xs text-muted-foreground">Global</span> 317 + )} 318 + </div> 319 + <p className="mt-0.5 text-xs text-muted-foreground"> 320 + {seed.displayName} 321 + {seed.reason && <> &middot; {seed.reason}</>} 322 + {' &middot; Added '} 323 + {formatDate(seed.createdAt)} 324 + </p> 325 + </div> 326 + {!seed.implicit && ( 327 + <button 328 + type="button" 329 + onClick={() => handleRemoveSeed(seed)} 330 + aria-label="Remove" 331 + className="shrink-0 rounded-md border border-border px-3 py-1.5 text-sm text-destructive transition-colors hover:bg-destructive/10" 332 + > 333 + Remove 334 + </button> 335 + )} 336 + </div> 337 + </article> 338 + ))} 339 + {seeds.length === 0 && ( 340 + <p className="py-8 text-center text-muted-foreground">No trust seeds configured.</p> 341 + )} 342 + </div> 343 + </> 344 + )} 345 + 346 + {/* Add Seed Dialog */} 347 + <AddSeedDialog 348 + key={addDialogKey} 349 + open={addDialogOpen} 350 + onClose={() => setAddDialogOpen(false)} 351 + onSubmit={(data) => void handleAddSeed(data)} 352 + /> 353 + 354 + {/* Confirm Dialog */} 355 + <ConfirmDialog 356 + open={confirmAction !== null} 357 + title={confirmAction?.title ?? ''} 358 + message={confirmAction?.message ?? ''} 359 + onConfirm={() => confirmAction?.onConfirm()} 360 + onCancel={() => setConfirmAction(null)} 361 + /> 362 + </div> 363 + </AdminLayout> 364 + ) 365 + }
+4
src/components/admin/admin-layout.tsx
··· 18 18 PuzzlePiece, 19 19 ClipboardText, 20 20 ArrowLeft, 21 + ShieldWarning, 22 + SealCheck, 21 23 } from '@phosphor-icons/react' 22 24 import { cn } from '@/lib/utils' 23 25 ··· 29 31 { href: '/admin', label: 'Dashboard', icon: ChartBar }, 30 32 { href: '/admin/categories', label: 'Categories', icon: FolderSimple }, 31 33 { href: '/admin/moderation', label: 'Moderation', icon: ShieldCheck }, 34 + { href: '/admin/sybil-detection', label: 'Sybil Detection', icon: ShieldWarning }, 35 + { href: '/admin/trust-seeds', label: 'Trust Seeds', icon: SealCheck }, 32 36 { href: '/admin/settings', label: 'Settings', icon: Gear }, 33 37 { href: '/admin/content-ratings', label: 'Content Ratings', icon: Tag }, 34 38 { href: '/admin/onboarding', label: 'Onboarding', icon: ClipboardText },
+154
src/lib/api/client.ts
··· 47 47 CommunityProfile, 48 48 UpdateCommunityProfileInput, 49 49 UploadResponse, 50 + SybilClustersResponse, 51 + SybilClusterDetail, 52 + SybilCluster, 53 + TrustSeedsResponse, 54 + TrustSeed, 55 + CreateTrustSeedInput, 56 + PdsTrustFactorsResponse, 57 + PdsTrustFactor, 58 + TrustGraphStatus, 59 + BehavioralFlagsResponse, 60 + BehavioralFlag, 50 61 } from './types' 51 62 52 63 const API_URL = ··· 899 910 throw new ApiError(response.status, `API ${response.status}: ${body}`) 900 911 } 901 912 return response.json() as Promise<UploadResponse> 913 + } 914 + 915 + // --- Sybil Detection endpoints --- 916 + 917 + export function getSybilClusters( 918 + accessToken: string, 919 + params: { status?: string } = {}, 920 + options?: FetchOptions 921 + ): Promise<SybilClustersResponse> { 922 + const query = buildQuery({ status: params.status }) 923 + return apiFetch<SybilClustersResponse>(`/api/admin/sybil-clusters${query}`, { 924 + ...options, 925 + headers: { ...options?.headers, Authorization: `Bearer ${accessToken}` }, 926 + }) 927 + } 928 + 929 + export function getSybilClusterDetail( 930 + id: number, 931 + accessToken: string, 932 + options?: FetchOptions 933 + ): Promise<SybilClusterDetail> { 934 + return apiFetch<SybilClusterDetail>(`/api/admin/sybil-clusters/${id}`, { 935 + ...options, 936 + headers: { ...options?.headers, Authorization: `Bearer ${accessToken}` }, 937 + }) 938 + } 939 + 940 + export function updateSybilClusterStatus( 941 + id: number, 942 + status: string, 943 + accessToken: string, 944 + options?: FetchOptions 945 + ): Promise<SybilCluster> { 946 + return apiFetch<SybilCluster>(`/api/admin/sybil-clusters/${id}`, { 947 + ...options, 948 + method: 'PUT', 949 + headers: { ...options?.headers, Authorization: `Bearer ${accessToken}` }, 950 + body: { status }, 951 + }) 952 + } 953 + 954 + export function getTrustSeeds( 955 + accessToken: string, 956 + options?: FetchOptions 957 + ): Promise<TrustSeedsResponse> { 958 + return apiFetch<TrustSeedsResponse>('/api/admin/trust-seeds', { 959 + ...options, 960 + headers: { ...options?.headers, Authorization: `Bearer ${accessToken}` }, 961 + }) 962 + } 963 + 964 + export function createTrustSeed( 965 + input: CreateTrustSeedInput, 966 + accessToken: string, 967 + options?: FetchOptions 968 + ): Promise<TrustSeed> { 969 + return apiFetch<TrustSeed>('/api/admin/trust-seeds', { 970 + ...options, 971 + method: 'POST', 972 + headers: { ...options?.headers, Authorization: `Bearer ${accessToken}` }, 973 + body: input, 974 + }) 975 + } 976 + 977 + export function deleteTrustSeed( 978 + id: number, 979 + accessToken: string, 980 + options?: FetchOptions 981 + ): Promise<void> { 982 + return apiFetch<void>(`/api/admin/trust-seeds/${id}`, { 983 + ...options, 984 + method: 'DELETE', 985 + headers: { ...options?.headers, Authorization: `Bearer ${accessToken}` }, 986 + }) 987 + } 988 + 989 + export function getPdsTrustFactors( 990 + accessToken: string, 991 + options?: FetchOptions 992 + ): Promise<PdsTrustFactorsResponse> { 993 + return apiFetch<PdsTrustFactorsResponse>('/api/admin/pds-trust', { 994 + ...options, 995 + headers: { ...options?.headers, Authorization: `Bearer ${accessToken}` }, 996 + }) 997 + } 998 + 999 + export function updatePdsTrustFactor( 1000 + pdsHost: string, 1001 + trustFactor: number, 1002 + accessToken: string, 1003 + options?: FetchOptions 1004 + ): Promise<PdsTrustFactor> { 1005 + return apiFetch<PdsTrustFactor>('/api/admin/pds-trust', { 1006 + ...options, 1007 + method: 'PUT', 1008 + headers: { ...options?.headers, Authorization: `Bearer ${accessToken}` }, 1009 + body: { pdsHost, trustFactor }, 1010 + }) 1011 + } 1012 + 1013 + export function getTrustGraphStatus( 1014 + accessToken: string, 1015 + options?: FetchOptions 1016 + ): Promise<TrustGraphStatus> { 1017 + return apiFetch<TrustGraphStatus>('/api/admin/trust-graph/status', { 1018 + ...options, 1019 + headers: { ...options?.headers, Authorization: `Bearer ${accessToken}` }, 1020 + }) 1021 + } 1022 + 1023 + export function recomputeTrustGraph( 1024 + accessToken: string, 1025 + options?: FetchOptions 1026 + ): Promise<{ message: string }> { 1027 + return apiFetch<{ message: string }>('/api/admin/trust-graph/recompute', { 1028 + ...options, 1029 + method: 'POST', 1030 + headers: { ...options?.headers, Authorization: `Bearer ${accessToken}` }, 1031 + }) 1032 + } 1033 + 1034 + export function getBehavioralFlags( 1035 + accessToken: string, 1036 + options?: FetchOptions 1037 + ): Promise<BehavioralFlagsResponse> { 1038 + return apiFetch<BehavioralFlagsResponse>('/api/admin/behavioral-flags', { 1039 + ...options, 1040 + headers: { ...options?.headers, Authorization: `Bearer ${accessToken}` }, 1041 + }) 1042 + } 1043 + 1044 + export function updateBehavioralFlag( 1045 + id: number, 1046 + status: string, 1047 + accessToken: string, 1048 + options?: FetchOptions 1049 + ): Promise<BehavioralFlag> { 1050 + return apiFetch<BehavioralFlag>(`/api/admin/behavioral-flags/${id}`, { 1051 + ...options, 1052 + method: 'PUT', 1053 + headers: { ...options?.headers, Authorization: `Bearer ${accessToken}` }, 1054 + body: { status }, 1055 + }) 902 1056 } 903 1057 904 1058 export { ApiError }
+94
src/lib/api/types.ts
··· 603 603 url: string 604 604 } 605 605 606 + // --- Sybil Detection --- 607 + 608 + export type SybilClusterStatus = 'flagged' | 'dismissed' | 'monitoring' | 'banned' 609 + 610 + export interface SybilCluster { 611 + id: number 612 + clusterHash: string 613 + memberCount: number 614 + internalEdgeCount: number 615 + externalEdgeCount: number 616 + suspicionRatio: number 617 + status: SybilClusterStatus 618 + detectedAt: string 619 + reviewedBy: string | null 620 + reviewedAt: string | null 621 + } 622 + 623 + export interface ClusterMember { 624 + did: string 625 + handle: string 626 + displayName: string 627 + roleInCluster: 'core' | 'peripheral' 628 + trustScore: number 629 + reputationScore: number 630 + accountAge: string 631 + communitiesActiveIn: number 632 + } 633 + 634 + export interface SybilClusterDetail extends SybilCluster { 635 + members: ClusterMember[] 636 + } 637 + 638 + export interface SybilClustersResponse { 639 + clusters: SybilCluster[] 640 + } 641 + 642 + export interface TrustSeed { 643 + id: number 644 + did: string 645 + handle: string 646 + displayName: string 647 + communityId: string | null 648 + reason: string | null 649 + implicit: boolean 650 + createdAt: string 651 + } 652 + 653 + export interface TrustSeedsResponse { 654 + seeds: TrustSeed[] 655 + } 656 + 657 + /** The API accepts a handle and resolves it to a DID server-side. */ 658 + export interface CreateTrustSeedInput { 659 + handle: string 660 + communityId?: string 661 + reason?: string 662 + } 663 + 664 + export interface PdsTrustFactor { 665 + pdsHost: string 666 + trustFactor: number 667 + isDefault: boolean 668 + updatedAt: string 669 + } 670 + 671 + export interface PdsTrustFactorsResponse { 672 + providers: PdsTrustFactor[] 673 + } 674 + 675 + export interface TrustGraphStatus { 676 + lastComputedAt: string | null 677 + totalNodes: number 678 + totalEdges: number 679 + computationDurationMs: number 680 + clustersFlagged: number 681 + nextScheduledAt: string 682 + } 683 + 684 + export type BehavioralFlagType = 'burst_voting' | 'content_similarity' | 'low_diversity' 685 + export type BehavioralFlagStatus = 'pending' | 'dismissed' | 'action_taken' 686 + 687 + export interface BehavioralFlag { 688 + id: number 689 + flagType: BehavioralFlagType 690 + affectedDids: string[] 691 + details: string 692 + detectedAt: string 693 + status: BehavioralFlagStatus 694 + } 695 + 696 + export interface BehavioralFlagsResponse { 697 + flags: BehavioralFlag[] 698 + } 699 + 606 700 // --- Shared --- 607 701 608 702 export type MaturityRating = 'safe' | 'mature' | 'adult'
+238
src/mocks/data.ts
··· 29 29 UserProfile, 30 30 OnboardingField, 31 31 MyReport, 32 + SybilCluster, 33 + SybilClusterDetail, 34 + TrustSeed, 35 + PdsTrustFactor, 36 + TrustGraphStatus, 37 + BehavioralFlag, 32 38 } from '@/lib/api/types' 33 39 34 40 const COMMUNITY_DID = 'did:plc:test-community-123' ··· 1082 1088 bio: 'Community admin and AT Protocol enthusiast.', 1083 1089 }, 1084 1090 } 1091 + 1092 + // --- Sybil Detection --- 1093 + 1094 + const TWO_HOURS_AGO = '2026-02-14T10:00:00.000Z' 1095 + 1096 + export const mockSybilClusters: SybilCluster[] = [ 1097 + { 1098 + id: 1, 1099 + clusterHash: 'abc123def456', 1100 + memberCount: 8, 1101 + internalEdgeCount: 24, 1102 + externalEdgeCount: 3, 1103 + suspicionRatio: 0.89, 1104 + status: 'flagged', 1105 + detectedAt: TWO_HOURS_AGO, 1106 + reviewedBy: null, 1107 + reviewedAt: null, 1108 + }, 1109 + { 1110 + id: 2, 1111 + clusterHash: 'ghi789jkl012', 1112 + memberCount: 5, 1113 + internalEdgeCount: 12, 1114 + externalEdgeCount: 7, 1115 + suspicionRatio: 0.62, 1116 + status: 'monitoring', 1117 + detectedAt: YESTERDAY, 1118 + reviewedBy: 'did:plc:user-alice-001', 1119 + reviewedAt: NOW, 1120 + }, 1121 + { 1122 + id: 3, 1123 + clusterHash: 'mno345pqr678', 1124 + memberCount: 3, 1125 + internalEdgeCount: 4, 1126 + externalEdgeCount: 8, 1127 + suspicionRatio: 0.33, 1128 + status: 'dismissed', 1129 + detectedAt: LAST_WEEK, 1130 + reviewedBy: 'did:plc:user-alice-001', 1131 + reviewedAt: YESTERDAY, 1132 + }, 1133 + ] 1134 + 1135 + export const mockSybilClusterDetail: SybilClusterDetail = { 1136 + ...mockSybilClusters[0]!, 1137 + members: [ 1138 + { 1139 + did: 'did:plc:sybil-001', 1140 + handle: 'sybil1.bsky.social', 1141 + displayName: 'Sybil Account 1', 1142 + roleInCluster: 'core', 1143 + trustScore: 0.12, 1144 + reputationScore: 0.15, 1145 + accountAge: '3 days', 1146 + communitiesActiveIn: 1, 1147 + }, 1148 + { 1149 + did: 'did:plc:sybil-002', 1150 + handle: 'sybil2.bsky.social', 1151 + displayName: 'Sybil Account 2', 1152 + roleInCluster: 'core', 1153 + trustScore: 0.14, 1154 + reputationScore: 0.18, 1155 + accountAge: '3 days', 1156 + communitiesActiveIn: 1, 1157 + }, 1158 + { 1159 + did: 'did:plc:sybil-003', 1160 + handle: 'sybil3.example.com', 1161 + displayName: 'Sybil Account 3', 1162 + roleInCluster: 'peripheral', 1163 + trustScore: 0.31, 1164 + reputationScore: 0.28, 1165 + accountAge: '7 days', 1166 + communitiesActiveIn: 2, 1167 + }, 1168 + { 1169 + did: 'did:plc:sybil-004', 1170 + handle: 'sybil4.bsky.social', 1171 + displayName: 'Sybil Account 4', 1172 + roleInCluster: 'core', 1173 + trustScore: 0.1, 1174 + reputationScore: 0.12, 1175 + accountAge: '2 days', 1176 + communitiesActiveIn: 1, 1177 + }, 1178 + { 1179 + did: 'did:plc:sybil-005', 1180 + handle: 'sybil5.bsky.social', 1181 + displayName: 'Sybil Account 5', 1182 + roleInCluster: 'peripheral', 1183 + trustScore: 0.25, 1184 + reputationScore: 0.22, 1185 + accountAge: '5 days', 1186 + communitiesActiveIn: 1, 1187 + }, 1188 + { 1189 + did: 'did:plc:sybil-006', 1190 + handle: 'sybil6.example.com', 1191 + displayName: 'Sybil Account 6', 1192 + roleInCluster: 'core', 1193 + trustScore: 0.11, 1194 + reputationScore: 0.14, 1195 + accountAge: '3 days', 1196 + communitiesActiveIn: 1, 1197 + }, 1198 + { 1199 + did: 'did:plc:sybil-007', 1200 + handle: 'sybil7.bsky.social', 1201 + displayName: 'Sybil Account 7', 1202 + roleInCluster: 'peripheral', 1203 + trustScore: 0.29, 1204 + reputationScore: 0.26, 1205 + accountAge: '6 days', 1206 + communitiesActiveIn: 2, 1207 + }, 1208 + { 1209 + did: 'did:plc:sybil-008', 1210 + handle: 'sybil8.bsky.social', 1211 + displayName: 'Sybil Account 8', 1212 + roleInCluster: 'core', 1213 + trustScore: 0.13, 1214 + reputationScore: 0.16, 1215 + accountAge: '3 days', 1216 + communitiesActiveIn: 1, 1217 + }, 1218 + ], 1219 + } 1220 + 1221 + export const mockTrustSeeds: TrustSeed[] = [ 1222 + { 1223 + id: 1, 1224 + did: 'did:plc:seed-001', 1225 + handle: 'trusted-mod.bsky.social', 1226 + displayName: 'Trusted Moderator', 1227 + communityId: null, 1228 + reason: 'Founding community moderator', 1229 + implicit: false, 1230 + createdAt: LAST_WEEK, 1231 + }, 1232 + { 1233 + id: 2, 1234 + did: 'did:plc:seed-002', 1235 + handle: 'verified-expert.bsky.social', 1236 + displayName: 'Verified Expert', 1237 + communityId: COMMUNITY_DID, 1238 + reason: 'Subject matter expert with verified credentials', 1239 + implicit: false, 1240 + createdAt: TWO_DAYS_AGO, 1241 + }, 1242 + { 1243 + id: 3, 1244 + did: 'did:plc:user-alice-001', 1245 + handle: 'alice.bsky.social', 1246 + displayName: 'Alice', 1247 + communityId: null, 1248 + reason: null, 1249 + implicit: true, 1250 + createdAt: LAST_WEEK, 1251 + }, 1252 + { 1253 + id: 4, 1254 + did: 'did:plc:user-bob-002', 1255 + handle: 'bob.bsky.social', 1256 + displayName: 'Bob', 1257 + communityId: null, 1258 + reason: null, 1259 + implicit: true, 1260 + createdAt: LAST_WEEK, 1261 + }, 1262 + { 1263 + id: 5, 1264 + did: 'did:plc:user-carol-003', 1265 + handle: 'carol.example.com', 1266 + displayName: 'Carol', 1267 + communityId: COMMUNITY_DID, 1268 + reason: null, 1269 + implicit: true, 1270 + createdAt: TWO_DAYS_AGO, 1271 + }, 1272 + ] 1273 + 1274 + export const mockPdsTrustFactors: PdsTrustFactor[] = [ 1275 + { 1276 + pdsHost: 'bsky.social', 1277 + trustFactor: 1.0, 1278 + isDefault: true, 1279 + updatedAt: LAST_WEEK, 1280 + }, 1281 + { 1282 + pdsHost: 'northsky.app', 1283 + trustFactor: 1.0, 1284 + isDefault: true, 1285 + updatedAt: LAST_WEEK, 1286 + }, 1287 + { 1288 + pdsHost: 'custom-pds.example', 1289 + trustFactor: 0.7, 1290 + isDefault: false, 1291 + updatedAt: TWO_DAYS_AGO, 1292 + }, 1293 + ] 1294 + 1295 + export const mockTrustGraphStatus: TrustGraphStatus = { 1296 + lastComputedAt: TWO_HOURS_AGO, 1297 + totalNodes: 1500, 1298 + totalEdges: 4200, 1299 + computationDurationMs: 3200, 1300 + clustersFlagged: 2, 1301 + nextScheduledAt: '2026-02-14T16:00:00.000Z', 1302 + } 1303 + 1304 + export const mockBehavioralFlags: BehavioralFlag[] = [ 1305 + { 1306 + id: 1, 1307 + flagType: 'burst_voting', 1308 + affectedDids: ['did:plc:sybil-001', 'did:plc:sybil-002', 'did:plc:sybil-004'], 1309 + details: '3 accounts cast 47 votes within a 2-minute window on the same set of 5 topics.', 1310 + detectedAt: TWO_HOURS_AGO, 1311 + status: 'pending', 1312 + }, 1313 + { 1314 + id: 2, 1315 + flagType: 'content_similarity', 1316 + affectedDids: ['did:plc:sybil-005', 'did:plc:sybil-006'], 1317 + details: 1318 + '2 accounts posted near-identical replies across 3 different topics with 94% text similarity.', 1319 + detectedAt: YESTERDAY, 1320 + status: 'pending', 1321 + }, 1322 + ]
+169
src/mocks/handlers.ts
··· 30 30 mockUserProfiles, 31 31 mockPublicSettings, 32 32 mockCommunityProfile, 33 + mockSybilClusters, 34 + mockSybilClusterDetail, 35 + mockTrustSeeds, 36 + mockPdsTrustFactors, 37 + mockTrustGraphStatus, 38 + mockBehavioralFlags, 33 39 } from './data' 34 40 35 41 const API_URL = '' ··· 793 799 appealStatus: 'pending', 794 800 status: 'pending', 795 801 }) 802 + }), 803 + 804 + // --- Sybil Detection endpoints --- 805 + 806 + // GET /api/admin/sybil-clusters 807 + http.get(`${API_URL}/api/admin/sybil-clusters`, ({ request }) => { 808 + const auth = request.headers.get('Authorization') 809 + if (!auth?.startsWith('Bearer ')) { 810 + return HttpResponse.json({ error: 'Unauthorized' }, { status: 401 }) 811 + } 812 + const url = new URL(request.url) 813 + const status = url.searchParams.get('status') 814 + const clusters = status 815 + ? mockSybilClusters.filter((c) => c.status === status) 816 + : mockSybilClusters 817 + return HttpResponse.json({ clusters }) 818 + }), 819 + 820 + // GET /api/admin/sybil-clusters/:id 821 + http.get(`${API_URL}/api/admin/sybil-clusters/:id`, ({ request, params }) => { 822 + const auth = request.headers.get('Authorization') 823 + if (!auth?.startsWith('Bearer ')) { 824 + return HttpResponse.json({ error: 'Unauthorized' }, { status: 401 }) 825 + } 826 + const id = Number(params['id']) 827 + if (id === mockSybilClusterDetail.id) { 828 + return HttpResponse.json(mockSybilClusterDetail) 829 + } 830 + const cluster = mockSybilClusters.find((c) => c.id === id) 831 + if (!cluster) { 832 + return HttpResponse.json({ error: 'Cluster not found' }, { status: 404 }) 833 + } 834 + return HttpResponse.json({ ...cluster, members: [] }) 835 + }), 836 + 837 + // PUT /api/admin/sybil-clusters/:id 838 + http.put(`${API_URL}/api/admin/sybil-clusters/:id`, async ({ request, params }) => { 839 + const auth = request.headers.get('Authorization') 840 + if (!auth?.startsWith('Bearer ')) { 841 + return HttpResponse.json({ error: 'Unauthorized' }, { status: 401 }) 842 + } 843 + const id = Number(params['id']) 844 + const cluster = mockSybilClusters.find((c) => c.id === id) 845 + if (!cluster) { 846 + return HttpResponse.json({ error: 'Cluster not found' }, { status: 404 }) 847 + } 848 + const body = (await request.json()) as Record<string, unknown> 849 + return HttpResponse.json({ 850 + ...cluster, 851 + ...body, 852 + reviewedBy: 'did:plc:user-alice-001', 853 + reviewedAt: new Date().toISOString(), 854 + }) 855 + }), 856 + 857 + // GET /api/admin/trust-seeds 858 + http.get(`${API_URL}/api/admin/trust-seeds`, ({ request }) => { 859 + const auth = request.headers.get('Authorization') 860 + if (!auth?.startsWith('Bearer ')) { 861 + return HttpResponse.json({ error: 'Unauthorized' }, { status: 401 }) 862 + } 863 + return HttpResponse.json({ seeds: mockTrustSeeds }) 864 + }), 865 + 866 + // POST /api/admin/trust-seeds 867 + http.post(`${API_URL}/api/admin/trust-seeds`, async ({ request }) => { 868 + const auth = request.headers.get('Authorization') 869 + if (!auth?.startsWith('Bearer ')) { 870 + return HttpResponse.json({ error: 'Unauthorized' }, { status: 401 }) 871 + } 872 + const body = (await request.json()) as { 873 + handle?: string 874 + communityId?: string 875 + reason?: string 876 + } 877 + const newSeed = { 878 + id: Date.now(), 879 + did: `did:plc:new-seed-${Date.now()}`, 880 + handle: body.handle ?? 'unknown.bsky.social', 881 + displayName: body.handle ?? 'Unknown', 882 + communityId: body.communityId ?? null, 883 + reason: body.reason ?? null, 884 + implicit: false, 885 + createdAt: new Date().toISOString(), 886 + } 887 + return HttpResponse.json(newSeed, { status: 201 }) 888 + }), 889 + 890 + // DELETE /api/admin/trust-seeds/:id 891 + http.delete(`${API_URL}/api/admin/trust-seeds/:id`, ({ request }) => { 892 + const auth = request.headers.get('Authorization') 893 + if (!auth?.startsWith('Bearer ')) { 894 + return HttpResponse.json({ error: 'Unauthorized' }, { status: 401 }) 895 + } 896 + return new HttpResponse(null, { status: 204 }) 897 + }), 898 + 899 + // GET /api/admin/pds-trust 900 + http.get(`${API_URL}/api/admin/pds-trust`, ({ request }) => { 901 + const auth = request.headers.get('Authorization') 902 + if (!auth?.startsWith('Bearer ')) { 903 + return HttpResponse.json({ error: 'Unauthorized' }, { status: 401 }) 904 + } 905 + return HttpResponse.json({ providers: mockPdsTrustFactors }) 906 + }), 907 + 908 + // PUT /api/admin/pds-trust 909 + http.put(`${API_URL}/api/admin/pds-trust`, async ({ request }) => { 910 + const auth = request.headers.get('Authorization') 911 + if (!auth?.startsWith('Bearer ')) { 912 + return HttpResponse.json({ error: 'Unauthorized' }, { status: 401 }) 913 + } 914 + const body = (await request.json()) as { pdsHost?: string; trustFactor?: number } 915 + const pdsHost = body.pdsHost ?? '' 916 + const existing = mockPdsTrustFactors.find((p) => p.pdsHost === pdsHost) 917 + return HttpResponse.json({ 918 + pdsHost, 919 + trustFactor: body.trustFactor ?? existing?.trustFactor ?? 1.0, 920 + isDefault: false, 921 + updatedAt: new Date().toISOString(), 922 + }) 923 + }), 924 + 925 + // GET /api/admin/trust-graph/status 926 + http.get(`${API_URL}/api/admin/trust-graph/status`, ({ request }) => { 927 + const auth = request.headers.get('Authorization') 928 + if (!auth?.startsWith('Bearer ')) { 929 + return HttpResponse.json({ error: 'Unauthorized' }, { status: 401 }) 930 + } 931 + return HttpResponse.json(mockTrustGraphStatus) 932 + }), 933 + 934 + // POST /api/admin/trust-graph/recompute 935 + http.post(`${API_URL}/api/admin/trust-graph/recompute`, ({ request }) => { 936 + const auth = request.headers.get('Authorization') 937 + if (!auth?.startsWith('Bearer ')) { 938 + return HttpResponse.json({ error: 'Unauthorized' }, { status: 401 }) 939 + } 940 + return HttpResponse.json({ message: 'Trust graph recomputation started' }) 941 + }), 942 + 943 + // GET /api/admin/behavioral-flags 944 + http.get(`${API_URL}/api/admin/behavioral-flags`, ({ request }) => { 945 + const auth = request.headers.get('Authorization') 946 + if (!auth?.startsWith('Bearer ')) { 947 + return HttpResponse.json({ error: 'Unauthorized' }, { status: 401 }) 948 + } 949 + return HttpResponse.json({ flags: mockBehavioralFlags }) 950 + }), 951 + 952 + // PUT /api/admin/behavioral-flags/:id 953 + http.put(`${API_URL}/api/admin/behavioral-flags/:id`, async ({ request, params }) => { 954 + const auth = request.headers.get('Authorization') 955 + if (!auth?.startsWith('Bearer ')) { 956 + return HttpResponse.json({ error: 'Unauthorized' }, { status: 401 }) 957 + } 958 + const id = Number(params['id']) 959 + const flag = mockBehavioralFlags.find((f) => f.id === id) 960 + if (!flag) { 961 + return HttpResponse.json({ error: 'Flag not found' }, { status: 404 }) 962 + } 963 + const body = (await request.json()) as Record<string, unknown> 964 + return HttpResponse.json({ ...flag, ...body }) 796 965 }), 797 966 ]