Testing implementation for private data in ATProto with ATPKeyserver and ATCute tools
2
fork

Configure Feed

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

simplify stuff, remove bullshit, find better alternatives

+812 -1040
+79 -40
bun.lock
··· 4 4 "": { 5 5 "name": "watproto", 6 6 "devDependencies": { 7 - "@atproto/lex-cli": "^0.9.6", 7 + "@atcute/atproto": "^3.1.8", 8 + "@atcute/bluesky": "^3.2.9", 9 + "@atcute/lex-cli": "^2.3.1", 10 + "@atcute/lexicons": "^1.2.2", 8 11 "concurrently": "^9.2.1", 9 12 "prettier": "^3.6.2", 10 13 "typescript": "^5.9.3", ··· 13 16 "packages/client": { 14 17 "name": "@watproto/client", 15 18 "dependencies": { 16 - "@elysiajs/eden": "1.4.4", 19 + "@atcute/client": "^4.0.5", 20 + "@atproto/identity": "^0.4.9", 21 + "@atproto/oauth-client-node": "^0.3.10", 17 22 "@react-router/fs-routes": "^7.9.4", 18 23 "@react-router/node": "^7.9.2", 19 24 "@react-router/serve": "^7.9.2", ··· 23 28 "react": "^19.1.1", 24 29 "react-dom": "^19.1.1", 25 30 "react-router": "^7.9.2", 31 + "sonner": "^2.0.7", 26 32 "tiny-invariant": "^1.3.3", 27 33 }, 28 34 "devDependencies": { ··· 33 39 "@types/node": "22", 34 40 "@types/react": "^19.1.13", 35 41 "@types/react-dom": "^19.1.9", 36 - "elysia": "1.4.13", 37 42 "eslint": "^9.36.0", 38 43 "eslint-config-flat-gitignore": "^2.1.0", 39 44 "eslint-plugin-react-hooks": "^7.0.1", ··· 49 54 "name": "@watproto/server", 50 55 "version": "0.0.1", 51 56 "dependencies": { 52 - "@atproto/api": "^0.17.3", 53 - "@atproto/identity": "^0.4.9", 54 - "@atproto/oauth-client-node": "^0.3.9", 57 + "@atcute/xrpc-server": "^0.1.3", 55 58 "@elysiajs/cors": "1.4.0", 56 59 "@elysiajs/openapi": "1.4.11", 57 60 "elysia": "1.4.13", ··· 68 71 }, 69 72 }, 70 73 "packages": { 74 + "@atcute/atproto": ["@atcute/atproto@3.1.8", "", { "dependencies": { "@atcute/lexicons": "^1.2.2" } }, "sha512-Miu+S7RSgAYbmQWtHJKfSFUN5Kliqoo4YH0rILPmBtfmlZieORJgXNj9oO/Uive0/ulWkiRse07ATIcK8JxMnw=="], 75 + 76 + "@atcute/bluesky": ["@atcute/bluesky@3.2.9", "", { "dependencies": { "@atcute/atproto": "^3.1.8", "@atcute/lexicons": "^1.2.2" } }, "sha512-69+mAnnH/uyMoT3/jHLBNILHa3+dm8utDKbm/2xqSPMLvRK47Wo5COlpchu8Xq+NGisHqukhHYT8NYdQFfSJhA=="], 77 + 78 + "@atcute/cbor": ["@atcute/cbor@2.2.7", "", { "dependencies": { "@atcute/cid": "^2.2.5", "@atcute/multibase": "^1.1.6", "@atcute/uint8array": "^1.0.5" } }, "sha512-/mwAF0gnokOphceZqFq3uzMGdd8sbw5y6bxF8CRutRkCCUcpjjpJc5fkLwhxyGgOveF3mZuHE6p7t/+IAqb7Aw=="], 79 + 80 + "@atcute/cid": ["@atcute/cid@2.2.6", "", { "dependencies": { "@atcute/multibase": "^1.1.6", "@atcute/uint8array": "^1.0.5" } }, "sha512-bTAHHbJ24p+E//V4KCS4xdmd39o211jJswvqQOevj7vk+5IYcgDLx1ryZWZ1sEPOo9x875li/kj5gpKL14RDwQ=="], 81 + 82 + "@atcute/client": ["@atcute/client@4.0.5", "", { "dependencies": { "@atcute/identity": "^1.1.1", "@atcute/lexicons": "^1.2.2" } }, "sha512-R8Qen8goGmEkynYGg2m6XFlVmz0GTDvQ+9w+4QqOob+XMk8/WDpF4aImev7WKEde/rV2gjcqW7zM8E6W9NShDA=="], 83 + 84 + "@atcute/crypto": ["@atcute/crypto@2.2.6", "", { "dependencies": { "@atcute/multibase": "^1.1.6", "@atcute/uint8array": "^1.0.5", "@noble/secp256k1": "^3.0.0" } }, "sha512-vkuexF+kmrKE1/Uqzub99Qi4QpnxA2jbu60E6PTgL4XypELQ6rb59MB/J1VbY2gs0kd3ET7+L3+NWpKD5nXyfA=="], 85 + 86 + "@atcute/identity": ["@atcute/identity@1.1.1", "", { "dependencies": { "@atcute/lexicons": "^1.2.2", "@badrap/valita": "^0.4.6" } }, "sha512-zax42n693VEhnC+5tndvO2KLDTMkHOz8UExwmklvJv7R9VujfEwiSWhcv6Jgwb3ellaG8wjiQ1lMOIjLLvwh0Q=="], 87 + 88 + "@atcute/identity-resolver": ["@atcute/identity-resolver@1.1.4", "", { "dependencies": { "@atcute/lexicons": "^1.2.2", "@atcute/util-fetch": "^1.0.3", "@badrap/valita": "^0.4.6" }, "peerDependencies": { "@atcute/identity": "^1.0.0" } }, "sha512-/SVh8vf2cXFJenmBnGeYF2aY3WGQm3cJeew5NWTlkqoy3LvJ5wkvKq9PWu4Tv653VF40rPOp6LOdVr9Fa+q5rA=="], 89 + 90 + "@atcute/lex-cli": ["@atcute/lex-cli@2.3.1", "", { "dependencies": { "@atcute/lexicon-doc": "^1.1.4", "@badrap/valita": "^0.4.6", "@optique/core": "^0.6.1", "@optique/run": "^0.6.1", "picocolors": "^1.1.1", "prettier": "^3.6.2" }, "bin": { "lex-cli": "cli.mjs" } }, "sha512-HrHD91CFSFd/p0UFe3akFA1HXiboQwd5LbYiU0srKdLxGX+NLTX/EdCdhbLV6M7LsXdmxk7PB6BMcprsX4rbvg=="], 91 + 92 + "@atcute/lexicon-doc": ["@atcute/lexicon-doc@1.1.4", "", { "dependencies": { "@badrap/valita": "^0.4.6" } }, "sha512-OL0fsXtbnN/KwCq/L3nWGvOCdSHV0NWTatgLUIPt+T9AhcziFNaXAbbjvVHdflr3ZaLh3ksleHK0J789UBhlWQ=="], 93 + 94 + "@atcute/lexicons": ["@atcute/lexicons@1.2.2", "", { "dependencies": { "@standard-schema/spec": "^1.0.0", "esm-env": "^1.2.2" } }, "sha512-bgEhJq5Z70/0TbK5sx+tAkrR8FsCODNiL2gUEvS5PuJfPxmFmRYNWaMGehxSPaXWpU2+Oa9ckceHiYbrItDTkA=="], 95 + 96 + "@atcute/multibase": ["@atcute/multibase@1.1.6", "", { "dependencies": { "@atcute/uint8array": "^1.0.5" } }, "sha512-HBxuCgYLKPPxETV0Rot4VP9e24vKl8JdzGCZOVsDaOXJgbRZoRIF67Lp0H/OgnJeH/Xpva8Z5ReoTNJE5dn3kg=="], 97 + 98 + "@atcute/uint8array": ["@atcute/uint8array@1.0.5", "", {}, "sha512-XLWWxoR2HNl2qU+FCr0rp1APwJXci7HnzbOQLxK55OaMNBXZ19+xNC5ii4QCsThsDxa4JS/JTzuiQLziITWf2Q=="], 99 + 100 + "@atcute/util-fetch": ["@atcute/util-fetch@1.0.3", "", { "dependencies": { "@badrap/valita": "^0.4.6" } }, "sha512-f8zzTb/xlKIwv2OQ31DhShPUNCmIIleX6p7qIXwWwEUjX6x8skUtpdISSjnImq01LXpltGV5y8yhV4/Mlb7CRQ=="], 101 + 102 + "@atcute/xrpc-server": ["@atcute/xrpc-server@0.1.3", "", { "dependencies": { "@atcute/cbor": "^2.2.7", "@atcute/crypto": "^2.2.5", "@atcute/identity": "^1.1.1", "@atcute/identity-resolver": "^1.1.4", "@atcute/lexicons": "^1.2.2", "@atcute/multibase": "^1.1.6", "@atcute/uint8array": "^1.0.5", "@badrap/valita": "^0.4.6", "nanoid": "^5.1.5" } }, "sha512-AMig6MuAL5VfXRZVsQqQXKCXnZgpjTc6UM6RggvyE1qVT8y9tZPFXdP5tt/p6Jf+h4cAw+XMu2uyrGpUmnTSyQ=="], 103 + 71 104 "@atproto-labs/did-resolver": ["@atproto-labs/did-resolver@0.2.2", "", { "dependencies": { "@atproto-labs/fetch": "0.2.3", "@atproto-labs/pipe": "0.1.1", "@atproto-labs/simple-store": "0.3.0", "@atproto-labs/simple-store-memory": "0.1.4", "@atproto/did": "0.2.1", "zod": "^3.23.8" } }, "sha512-ca2B7xR43tVoQ8XxBvha58DXwIH8cIyKQl6lpOKGkPUrJuFoO4iCLlDiSDi2Ueh+yE1rMDPP/qveHdajgDX3WQ=="], 72 105 73 106 "@atproto-labs/fetch": ["@atproto-labs/fetch@0.2.3", "", { "dependencies": { "@atproto-labs/pipe": "0.1.1" } }, "sha512-NZtbJOCbxKUFRFKMpamT38PUQMY0hX0p7TG5AEYOPhZKZEP7dHZ1K2s1aB8MdVH0qxmqX7nQleNrrvLf09Zfdw=="], ··· 86 119 87 120 "@atproto-labs/simple-store-memory": ["@atproto-labs/simple-store-memory@0.1.4", "", { "dependencies": { "@atproto-labs/simple-store": "0.3.0", "lru-cache": "^10.2.0" } }, "sha512-3mKY4dP8I7yKPFj9VKpYyCRzGJOi5CEpOLPlRhoJyLmgs3J4RzDrjn323Oakjz2Aj2JzRU/AIvWRAZVhpYNJHw=="], 88 121 89 - "@atproto/api": ["@atproto/api@0.17.5", "", { "dependencies": { "@atproto/common-web": "^0.4.3", "@atproto/lexicon": "^0.5.1", "@atproto/syntax": "^0.4.1", "@atproto/xrpc": "^0.7.5", "await-lock": "^2.2.2", "multiformats": "^9.9.0", "tlds": "^1.234.0", "zod": "^3.23.8" } }, "sha512-AMm/XFfVJPpPznY0Dk4fvv6alVMrPFtPTyLk8U70wPjvJf4P52AGHXFUjWwoy6mvPHSJ+L1+3LWddzb4gdIMng=="], 90 - 91 122 "@atproto/common-web": ["@atproto/common-web@0.4.3", "", { "dependencies": { "graphemer": "^1.4.0", "multiformats": "^9.9.0", "uint8arrays": "3.0.0", "zod": "^3.23.8" } }, "sha512-nRDINmSe4VycJzPo6fP/hEltBcULFxt9Kw7fQk6405FyAWZiTluYHlXOnU7GkQfeUK44OENG1qFTBcmCJ7e8pg=="], 92 123 93 124 "@atproto/crypto": ["@atproto/crypto@0.4.4", "", { "dependencies": { "@noble/curves": "^1.7.0", "@noble/hashes": "^1.6.1", "uint8arrays": "3.0.0" } }, "sha512-Yq9+crJ7WQl7sxStVpHgie5Z51R05etaK9DLWYG/7bR5T4bhdcIgF6IfklLShtZwLYdVVj+K15s0BqW9a8PSDA=="], ··· 101 132 "@atproto/jwk-jose": ["@atproto/jwk-jose@0.1.11", "", { "dependencies": { "@atproto/jwk": "0.6.0", "jose": "^5.2.0" } }, "sha512-i4Fnr2sTBYmMmHXl7NJh8GrCH+tDQEVWrcDMDnV5DjJfkgT17wIqvojIw9SNbSL4Uf0OtfEv6AgG0A+mgh8b5Q=="], 102 133 103 134 "@atproto/jwk-webcrypto": ["@atproto/jwk-webcrypto@0.2.0", "", { "dependencies": { "@atproto/jwk": "0.6.0", "@atproto/jwk-jose": "0.1.11", "zod": "^3.23.8" } }, "sha512-UmgRrrEAkWvxwhlwe30UmDOdTEFidlIzBC7C3cCbeJMcBN1x8B3KH+crXrsTqfWQBG58mXgt8wgSK3Kxs2LhFg=="], 104 - 105 - "@atproto/lex-cli": ["@atproto/lex-cli@0.9.6", "", { "dependencies": { "@atproto/lexicon": "^0.5.1", "@atproto/syntax": "^0.4.1", "chalk": "^4.1.2", "commander": "^9.4.0", "prettier": "^3.2.5", "ts-morph": "^24.0.0", "yesno": "^0.4.0", "zod": "^3.23.8" }, "bin": { "lex": "dist/index.js" } }, "sha512-EedEKmURoSP735YwSDHsFrLOhZ4P2it8goCHv5ApWi/R9DFpOKOpmYfIXJ9MAprK8cw+yBnjDJbzpLJy7UXlTg=="], 106 135 107 136 "@atproto/lexicon": ["@atproto/lexicon@0.5.1", "", { "dependencies": { "@atproto/common-web": "^0.4.3", "@atproto/syntax": "^0.4.1", "iso-datestring-validator": "^2.2.2", "multiformats": "^9.9.0", "zod": "^3.23.8" } }, "sha512-y8AEtYmfgVl4fqFxqXAeGvhesiGkxiy3CWoJIfsFDDdTlZUC8DFnZrYhcqkIop3OlCkkljvpSJi1hbeC1tbi8A=="], 108 137 ··· 172 201 173 202 "@babel/types": ["@babel/types@7.28.5", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA=="], 174 203 204 + "@badrap/valita": ["@badrap/valita@0.4.6", "", {}, "sha512-4kdqcjyxo/8RQ8ayjms47HCWZIF5981oE5nIenbfThKDxWXtEHKipAOWlflpPJzZx9y/JWYQkp18Awr7VuepFg=="], 205 + 175 206 "@borewit/text-codec": ["@borewit/text-codec@0.1.1", "", {}, "sha512-5L/uBxmjaCIX5h8Z+uu+kA9BQLkc/Wl06UGR5ajNRxu+/XjonB5i8JpgFMrPj3LXTCPA0pv8yxUvbUi+QthGGA=="], 176 207 177 208 "@elysiajs/cors": ["@elysiajs/cors@1.4.0", "", { "peerDependencies": { "elysia": ">= 1.4.0" } }, "sha512-pb0SCzBfFbFSYA/U40HHO7R+YrcXBJXOWgL20eSViK33ol1e20ru2/KUaZYo5IMUn63yaTJI/bQERuQ+77ND8g=="], 178 - 179 - "@elysiajs/eden": ["@elysiajs/eden@1.4.4", "", { "peerDependencies": { "elysia": ">= 1.4.0-exp.0" } }, "sha512-/LVqflmgUcCiXb8rz1iRq9Rx3SWfIV/EkoNqDFGMx+TvOyo8QHAygFXAVQz7RHs+jk6n6mEgpI6KlKBANoErsQ=="], 180 209 181 210 "@elysiajs/openapi": ["@elysiajs/openapi@1.4.11", "", { "peerDependencies": { "elysia": ">= 1.4.0" } }, "sha512-d75bMxYJpN6qSDi/z9L1S7SLk1S/8Px+cTb3W2lrYzU8uQ5E0kXdy1oOMJEfTyVsz3OA19NP9KNxE7ztSbLBLg=="], 182 211 ··· 278 307 279 308 "@noble/hashes": ["@noble/hashes@1.8.0", "", {}, "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A=="], 280 309 310 + "@noble/secp256k1": ["@noble/secp256k1@3.0.0", "", {}, "sha512-NJBaR352KyIvj3t6sgT/+7xrNyF9Xk9QlLSIqUGVUYlsnDTAUqY8LOmwpcgEx4AMJXRITQ5XEVHD+mMaPfr3mg=="], 311 + 281 312 "@nodelib/fs.scandir": ["@nodelib/fs.scandir@2.1.5", "", { "dependencies": { "@nodelib/fs.stat": "2.0.5", "run-parallel": "^1.1.9" } }, "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g=="], 282 313 283 314 "@nodelib/fs.stat": ["@nodelib/fs.stat@2.0.5", "", {}, "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A=="], ··· 289 320 "@npmcli/package-json": ["@npmcli/package-json@4.0.1", "", { "dependencies": { "@npmcli/git": "^4.1.0", "glob": "^10.2.2", "hosted-git-info": "^6.1.1", "json-parse-even-better-errors": "^3.0.0", "normalize-package-data": "^5.0.0", "proc-log": "^3.0.0", "semver": "^7.5.3" } }, "sha512-lRCEGdHZomFsURroh522YvA/2cVb9oPIJrjHanCJZkiasz1BzcnLr3tBJhlV7S86MBJBuAQ33is2D60YitZL2Q=="], 290 321 291 322 "@npmcli/promise-spawn": ["@npmcli/promise-spawn@6.0.2", "", { "dependencies": { "which": "^3.0.0" } }, "sha512-gGq0NJkIGSwdbUt4yhdF8ZrmkGKVz9vAdVzpOfnom+V8PLSmSOVhZwbNvZZS1EYcJN5hzzKBxmmVVAInM6HQLg=="], 323 + 324 + "@optique/core": ["@optique/core@0.6.2", "", {}, "sha512-HTxIHJ8xLOSZotiU6Zc5BCJv+SJ8DMYmuiQM+7tjF7RolJn/pdZNe7M78G3+DgXL9lIf82l8aGcilmgVYRQnGQ=="], 325 + 326 + "@optique/run": ["@optique/run@0.6.2", "", { "dependencies": { "@optique/core": "0.6.2" } }, "sha512-ERksB5bHozwEUVlTPToIc8UjZZBOgLeBhFZYh2lgldUbNDt7LItzgcErsPq5au5i5IBmmyCti4+2A3x+MRI4Xw=="], 292 327 293 328 "@pkgjs/parseargs": ["@pkgjs/parseargs@0.11.0", "", {}, "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg=="], 294 329 ··· 350 385 351 386 "@sinclair/typebox": ["@sinclair/typebox@0.34.41", "", {}, "sha512-6gS8pZzSXdyRHTIqoqSVknxolr1kzfy4/CeDnrzsVz8TTIWUbOBr6gnzOmTYJ3eXQNh4IYHIGi5aIL7sOZ2G/g=="], 352 387 388 + "@standard-schema/spec": ["@standard-schema/spec@1.0.0", "", {}, "sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA=="], 389 + 353 390 "@tailwindcss/node": ["@tailwindcss/node@4.1.16", "", { "dependencies": { "@jridgewell/remapping": "^2.3.4", "enhanced-resolve": "^5.18.3", "jiti": "^2.6.1", "lightningcss": "1.30.2", "magic-string": "^0.30.19", "source-map-js": "^1.2.1", "tailwindcss": "4.1.16" } }, "sha512-BX5iaSsloNuvKNHRN3k2RcCuTEgASTo77mofW0vmeHkfrDWaoFAFvNHpEgtu0eqyypcyiBkDWzSMxJhp3AUVcw=="], 354 391 355 392 "@tailwindcss/oxide": ["@tailwindcss/oxide@4.1.16", "", { "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "4.1.16", "@tailwindcss/oxide-darwin-arm64": "4.1.16", "@tailwindcss/oxide-darwin-x64": "4.1.16", "@tailwindcss/oxide-freebsd-x64": "4.1.16", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.16", "@tailwindcss/oxide-linux-arm64-gnu": "4.1.16", "@tailwindcss/oxide-linux-arm64-musl": "4.1.16", "@tailwindcss/oxide-linux-x64-gnu": "4.1.16", "@tailwindcss/oxide-linux-x64-musl": "4.1.16", "@tailwindcss/oxide-wasm32-wasi": "4.1.16", "@tailwindcss/oxide-win32-arm64-msvc": "4.1.16", "@tailwindcss/oxide-win32-x64-msvc": "4.1.16" } }, "sha512-2OSv52FRuhdlgyOQqgtQHuCgXnS8nFSYRp2tJ+4WZXKgTxqPy7SMSls8c3mPT5pkZ17SBToGM5LHEJBO7miEdg=="], ··· 383 420 "@tokenizer/inflate": ["@tokenizer/inflate@0.2.7", "", { "dependencies": { "debug": "^4.4.0", "fflate": "^0.8.2", "token-types": "^6.0.0" } }, "sha512-MADQgmZT1eKjp06jpI2yozxaU9uVs4GzzgSL+uEq7bVcJ9V1ZXQkeGNql1fsSI0gMy1vhvNTNbUqrx+pZfJVmg=="], 384 421 385 422 "@tokenizer/token": ["@tokenizer/token@0.3.0", "", {}, "sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A=="], 386 - 387 - "@ts-morph/common": ["@ts-morph/common@0.25.0", "", { "dependencies": { "minimatch": "^9.0.4", "path-browserify": "^1.0.1", "tinyglobby": "^0.2.9" } }, "sha512-kMnZz+vGGHi4GoHnLmMhGNjm44kGtKUXGnOvrKmMwAuvNjM/PgKVGfUnL7IDvK7Jb2QQ82jq3Zmp04Gy+r3Dkg=="], 388 423 389 424 "@types/bun": ["@types/bun@1.3.1", "", { "dependencies": { "bun-types": "1.3.1" } }, "sha512-4jNMk2/K9YJtfqwoAa28c8wK+T7nvJFOjxI4h/7sORWcypRNxBpr+TPNaCfVWq70tLCJsqoFwcf0oI0JU/fvMQ=="], 390 425 ··· 440 475 441 476 "array-flatten": ["array-flatten@1.1.1", "", {}, "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg=="], 442 477 443 - "await-lock": ["await-lock@2.2.2", "", {}, "sha512-aDczADvlvTGajTDjcjpJMqRkOF6Qdz3YbPZm/PyW6tKPkx2hlYBzxMhEywM/tU72HrVZjgl5VCdRuMlA7pZ8Gw=="], 444 - 445 478 "babel-dead-code-elimination": ["babel-dead-code-elimination@1.0.10", "", { "dependencies": { "@babel/core": "^7.23.7", "@babel/parser": "^7.23.6", "@babel/traverse": "^7.23.7", "@babel/types": "^7.23.6" } }, "sha512-DV5bdJZTzZ0zn0DC24v3jD7Mnidh6xhKa4GfKCbq3sfW8kaWhDdZjP3i81geA8T33tdYqWKw4D3fVv0CwEgKVA=="], 446 479 447 480 "balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], ··· 498 531 499 532 "clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="], 500 533 501 - "code-block-writer": ["code-block-writer@13.0.3", "", {}, "sha512-Oofo0pq3IKnsFtuHqSF7TqBfr71aeyZDVJ0HpmqB7FBM2qEigL0iPONSCZSO9pE9dZTAxANe5XHG9Uy0YMv8cg=="], 502 - 503 534 "color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="], 504 535 505 536 "color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="], 506 537 507 - "commander": ["commander@9.5.0", "", {}, "sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ=="], 508 - 509 538 "compressible": ["compressible@2.0.18", "", { "dependencies": { "mime-db": ">= 1.43.0 < 2" } }, "sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg=="], 510 539 511 540 "compression": ["compression@1.8.1", "", { "dependencies": { "bytes": "3.1.2", "compressible": "~2.0.18", "debug": "2.6.9", "negotiator": "~0.6.4", "on-headers": "~1.1.0", "safe-buffer": "5.2.1", "vary": "~1.1.2" } }, "sha512-9mAqGPHLakhCLeNyxPkK4xVo746zQ/czLH1Ky+vkitMnWfWZps8r0qXuwhwizagCRttsL4lfG4pIOvaWLpAP0w=="], ··· 613 642 "eslint-scope": ["eslint-scope@8.4.0", "", { "dependencies": { "esrecurse": "^4.3.0", "estraverse": "^5.2.0" } }, "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg=="], 614 643 615 644 "eslint-visitor-keys": ["eslint-visitor-keys@4.2.1", "", {}, "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ=="], 645 + 646 + "esm-env": ["esm-env@1.2.2", "", {}, "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA=="], 616 647 617 648 "espree": ["espree@10.4.0", "", { "dependencies": { "acorn": "^8.15.0", "acorn-jsx": "^5.3.2", "eslint-visitor-keys": "^4.2.1" } }, "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ=="], 618 649 ··· 744 775 745 776 "interpret": ["interpret@1.4.0", "", {}, "sha512-agE4QfB2Lkp9uICn7BAqoscw4SZP9kTE2hxiFI3jBPmXJfdqiahTbUuKGsMoN2GtqL9AxhYioAcVvgsb1HvRbA=="], 746 777 747 - "ipaddr.js": ["ipaddr.js@1.9.1", "", {}, "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g=="], 778 + "ipaddr.js": ["ipaddr.js@2.2.0", "", {}, "sha512-Ag3wB2o37wslZS19hZqorUnrnzSkpOVy+IiiDEiTqNubEYpYuHWIf6K4psgN2ZWKExS4xhVCrRVfb/wfW8fWJA=="], 748 779 749 780 "is-arrayish": ["is-arrayish@0.2.1", "", {}, "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg=="], 750 781 ··· 874 905 875 906 "multiformats": ["multiformats@9.9.0", "", {}, "sha512-HoMUjhH9T8DDBNT+6xzkrd9ga/XiBI4xLr58LJACwK6G3HTOPeMz4nB4KJs33L2BelrIJa7P0VuNaVF3hMYfjg=="], 876 907 877 - "nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="], 908 + "nanoid": ["nanoid@5.1.6", "", { "bin": { "nanoid": "bin/nanoid.js" } }, "sha512-c7+7RQ+dMB5dPwwCp4ee1/iV/q2P6aK1mTZcfr1BTuVlyW9hJYiMPybJCcnBlQtuSmTIWNeazm/zqNoZSSElBg=="], 878 909 879 910 "napi-build-utils": ["napi-build-utils@2.0.0", "", {}, "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA=="], 880 911 ··· 928 959 929 960 "parseurl": ["parseurl@1.3.3", "", {}, "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ=="], 930 961 931 - "path-browserify": ["path-browserify@1.0.1", "", {}, "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g=="], 932 - 933 962 "path-exists": ["path-exists@4.0.0", "", {}, "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w=="], 934 963 935 964 "path-is-absolute": ["path-is-absolute@1.0.1", "", {}, "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg=="], ··· 1056 1085 1057 1086 "simple-get": ["simple-get@4.0.1", "", { "dependencies": { "decompress-response": "^6.0.0", "once": "^1.3.1", "simple-concat": "^1.0.0" } }, "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA=="], 1058 1087 1088 + "sonner": ["sonner@2.0.7", "", { "peerDependencies": { "react": "^18.0.0 || ^19.0.0 || ^19.0.0-rc", "react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc" } }, "sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w=="], 1089 + 1059 1090 "source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="], 1060 1091 1061 1092 "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], ··· 1106 1137 1107 1138 "tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="], 1108 1139 1109 - "tlds": ["tlds@1.261.0", "", { "bin": { "tlds": "bin.js" } }, "sha512-QXqwfEl9ddlGBaRFXIvNKK6OhipSiLXuRuLJX5DErz0o0Q0rYxulWLdFryTkV5PkdZct5iMInwYEGe/eR++1AA=="], 1110 - 1111 1140 "to-regex-range": ["to-regex-range@5.0.1", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="], 1112 1141 1113 1142 "toidentifier": ["toidentifier@1.0.1", "", {}, "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA=="], ··· 1117 1146 "tree-kill": ["tree-kill@1.2.2", "", { "bin": { "tree-kill": "cli.js" } }, "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A=="], 1118 1147 1119 1148 "ts-api-utils": ["ts-api-utils@2.1.0", "", { "peerDependencies": { "typescript": ">=4.8.4" } }, "sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ=="], 1120 - 1121 - "ts-morph": ["ts-morph@24.0.0", "", { "dependencies": { "@ts-morph/common": "~0.25.0", "code-block-writer": "^13.0.3" } }, "sha512-2OAOg/Ob5yx9Et7ZX4CvTCc0UFoZHwLEJ+dpDPSUi5TgwwlTlX47w+iFRrEwzUZwYACjq83cgjS/Da50Ga37uw=="], 1122 1149 1123 1150 "tsconfck": ["tsconfck@3.1.6", "", { "peerDependencies": { "typescript": "^5.0.0" }, "optionalPeers": ["typescript"], "bin": { "tsconfck": "bin/tsconfck.js" } }, "sha512-ks6Vjr/jEw0P1gmOVwutM3B7fWxoWBL2KRDb1JfqGVawBmO5UsvmWOQFGHBPl5yxYz4eERr19E6L7NMv+Fej4w=="], 1124 1151 ··· 1186 1213 1187 1214 "yargs-parser": ["yargs-parser@21.1.1", "", {}, "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw=="], 1188 1215 1189 - "yesno": ["yesno@0.4.0", "", {}, "sha512-tdBxmHvbXPBKYIg81bMCB7bVeDmHkRzk5rVJyYYXurwKkHq/MCd8rz4HSJUP7hW0H2NlXiq8IFiWvYKEHhlotA=="], 1190 - 1191 1216 "yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="], 1192 1217 1193 - "zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], 1218 + "zod": ["zod@4.1.12", "", {}, "sha512-JInaHOamG8pt5+Ey8kGmdcAcg3OL9reK8ltczgHTAwNhMys/6ThXHityHxVV2p3fkw/c+MAvBHFVYHFZDmjMCQ=="], 1194 1219 1195 1220 "zod-validation-error": ["zod-validation-error@4.0.2", "", { "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" } }, "sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ=="], 1196 1221 1197 - "@atproto-labs/fetch-node/ipaddr.js": ["ipaddr.js@2.2.0", "", {}, "sha512-Ag3wB2o37wslZS19hZqorUnrnzSkpOVy+IiiDEiTqNubEYpYuHWIf6K4psgN2ZWKExS4xhVCrRVfb/wfW8fWJA=="], 1222 + "@atproto-labs/did-resolver/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], 1223 + 1224 + "@atproto-labs/handle-resolver/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], 1198 1225 1199 1226 "@atproto-labs/simple-store-memory/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="], 1200 1227 1228 + "@atproto/common-web/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], 1229 + 1230 + "@atproto/did/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], 1231 + 1232 + "@atproto/jwk/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], 1233 + 1234 + "@atproto/jwk-webcrypto/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], 1235 + 1236 + "@atproto/lexicon/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], 1237 + 1238 + "@atproto/oauth-client/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], 1239 + 1240 + "@atproto/oauth-types/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], 1241 + 1242 + "@atproto/xrpc/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], 1243 + 1201 1244 "@babel/core/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], 1202 1245 1203 1246 "@babel/helper-compilation-targets/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], ··· 1238 1281 1239 1282 "@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], 1240 1283 1241 - "@ts-morph/common/minimatch": ["minimatch@9.0.5", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="], 1242 - 1243 1284 "@typescript-eslint/eslint-plugin/ignore": ["ignore@7.0.5", "", {}, "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg=="], 1244 1285 1245 1286 "@typescript-eslint/typescript-estree/minimatch": ["minimatch@9.0.5", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="], ··· 1258 1299 1259 1300 "dotenv-expand/dotenv": ["dotenv@16.6.1", "", {}, "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow=="], 1260 1301 1261 - "eslint-plugin-react-hooks/zod": ["zod@4.1.12", "", {}, "sha512-JInaHOamG8pt5+Ey8kGmdcAcg3OL9reK8ltczgHTAwNhMys/6ThXHityHxVV2p3fkw/c+MAvBHFVYHFZDmjMCQ=="], 1262 - 1263 1302 "express/cookie": ["cookie@0.7.1", "", {}, "sha512-6DnInpx7SJ2AK3+CTUE/ZM0vWTUboZCegxhC2xiIydHR9jNuTAASBrfEpHhiGOZw/nX51bHt6YQl8jsGo4y/0w=="], 1264 1303 1265 1304 "express/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="], ··· 1276 1315 1277 1316 "hosted-git-info/lru-cache": ["lru-cache@7.18.3", "", {}, "sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA=="], 1278 1317 1279 - "kysely-codegen/zod": ["zod@4.1.12", "", {}, "sha512-JInaHOamG8pt5+Ey8kGmdcAcg3OL9reK8ltczgHTAwNhMys/6ThXHityHxVV2p3fkw/c+MAvBHFVYHFZDmjMCQ=="], 1280 - 1281 1318 "kysely-ctl/pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="], 1282 1319 1283 1320 "micromatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="], ··· 1296 1333 1297 1334 "pkg-types/pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="], 1298 1335 1336 + "postcss/nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="], 1337 + 1338 + "proxy-addr/ipaddr.js": ["ipaddr.js@1.9.1", "", {}, "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g=="], 1339 + 1299 1340 "rc/strip-json-comments": ["strip-json-comments@2.0.1", "", {}, "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ=="], 1300 1341 1301 1342 "send/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="], ··· 1313 1354 "@isaacs/cliui/wrap-ansi/ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="], 1314 1355 1315 1356 "@react-router/fs-routes/minimatch/brace-expansion": ["brace-expansion@2.0.2", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="], 1316 - 1317 - "@ts-morph/common/minimatch/brace-expansion": ["brace-expansion@2.0.2", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="], 1318 1357 1319 1358 "@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["brace-expansion@2.0.2", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="], 1320 1359
+12
lex.config.js
··· 1 + import { defineLexiconConfig } from '@atcute/lex-cli' 2 + 3 + const LEX_ENV = process.env.LEX_ENV 4 + 5 + export default defineLexiconConfig({ 6 + files: ['lexicons/**/*.json'], 7 + outdir: 8 + LEX_ENV === 'server' 9 + ? 'packages/server/src/xrpc/' 10 + : 'packages/client/app/xrpc/', 11 + imports: ['@atcute/atproto', '@atcute/bluesky'] 12 + })
+6 -1
lexicons.json
··· 1 1 { 2 2 "lexicons": [ 3 - "com.atproto.label.defs" 3 + "com.atproto.label.defs", 4 + "com.atproto.repo.getRecord", 5 + "com.atproto.repo.listRecords", 6 + "com.atproto.repo.createRecord", 7 + "com.atproto.repo.putRecord", 8 + "com.atproto.repo.deleteRecord" 4 9 ] 5 10 }
+10 -1
lexicons/app/wafrn/content/createPost.json
··· 35 35 }, 36 36 "output": { 37 37 "encoding": "application/json", 38 - "schema": { "type": "ref", "ref": "app.wafrn.content.post" } 38 + "schema": { 39 + "type": "object", 40 + "required": ["post"], 41 + "properties": { 42 + "post": { 43 + "type": "ref", 44 + "ref": "app.wafrn.content.post" 45 + } 46 + } 47 + } 39 48 } 40 49 } 41 50 }
-192
lexicons/com/atproto/label/defs.json
··· 1 - { 2 - "lexicon": 1, 3 - "id": "com.atproto.label.defs", 4 - "defs": { 5 - "label": { 6 - "type": "object", 7 - "required": [ 8 - "src", 9 - "uri", 10 - "val", 11 - "cts" 12 - ], 13 - "properties": { 14 - "cid": { 15 - "type": "string", 16 - "format": "cid", 17 - "description": "Optionally, CID specifying the specific version of 'uri' resource this label applies to." 18 - }, 19 - "cts": { 20 - "type": "string", 21 - "format": "datetime", 22 - "description": "Timestamp when this label was created." 23 - }, 24 - "exp": { 25 - "type": "string", 26 - "format": "datetime", 27 - "description": "Timestamp at which this label expires (no longer applies)." 28 - }, 29 - "neg": { 30 - "type": "boolean", 31 - "description": "If true, this is a negation label, overwriting a previous label." 32 - }, 33 - "sig": { 34 - "type": "bytes", 35 - "description": "Signature of dag-cbor encoded label." 36 - }, 37 - "src": { 38 - "type": "string", 39 - "format": "did", 40 - "description": "DID of the actor who created this label." 41 - }, 42 - "uri": { 43 - "type": "string", 44 - "format": "uri", 45 - "description": "AT URI of the record, repository (account), or other resource that this label applies to." 46 - }, 47 - "val": { 48 - "type": "string", 49 - "maxLength": 128, 50 - "description": "The short string name of the value or type of this label." 51 - }, 52 - "ver": { 53 - "type": "integer", 54 - "description": "The AT Protocol version of the label object." 55 - } 56 - }, 57 - "description": "Metadata tag on an atproto resource (eg, repo or record)." 58 - }, 59 - "selfLabel": { 60 - "type": "object", 61 - "required": [ 62 - "val" 63 - ], 64 - "properties": { 65 - "val": { 66 - "type": "string", 67 - "maxLength": 128, 68 - "description": "The short string name of the value or type of this label." 69 - } 70 - }, 71 - "description": "Metadata tag on an atproto record, published by the author within the record. Note that schemas should use #selfLabels, not #selfLabel." 72 - }, 73 - "labelValue": { 74 - "type": "string", 75 - "knownValues": [ 76 - "!hide", 77 - "!no-promote", 78 - "!warn", 79 - "!no-unauthenticated", 80 - "dmca-violation", 81 - "doxxing", 82 - "porn", 83 - "sexual", 84 - "nudity", 85 - "nsfl", 86 - "gore" 87 - ] 88 - }, 89 - "selfLabels": { 90 - "type": "object", 91 - "required": [ 92 - "values" 93 - ], 94 - "properties": { 95 - "values": { 96 - "type": "array", 97 - "items": { 98 - "ref": "#selfLabel", 99 - "type": "ref" 100 - }, 101 - "maxLength": 10 102 - } 103 - }, 104 - "description": "Metadata tags on an atproto record, published by the author within the record." 105 - }, 106 - "labelValueDefinition": { 107 - "type": "object", 108 - "required": [ 109 - "identifier", 110 - "severity", 111 - "blurs", 112 - "locales" 113 - ], 114 - "properties": { 115 - "blurs": { 116 - "type": "string", 117 - "description": "What should this label hide in the UI, if applied? 'content' hides all of the target; 'media' hides the images/video/audio; 'none' hides nothing.", 118 - "knownValues": [ 119 - "content", 120 - "media", 121 - "none" 122 - ] 123 - }, 124 - "locales": { 125 - "type": "array", 126 - "items": { 127 - "ref": "#labelValueDefinitionStrings", 128 - "type": "ref" 129 - } 130 - }, 131 - "severity": { 132 - "type": "string", 133 - "description": "How should a client visually convey this label? 'inform' means neutral and informational; 'alert' means negative and warning; 'none' means show nothing.", 134 - "knownValues": [ 135 - "inform", 136 - "alert", 137 - "none" 138 - ] 139 - }, 140 - "adultOnly": { 141 - "type": "boolean", 142 - "description": "Does the user need to have adult content enabled in order to configure this label?" 143 - }, 144 - "identifier": { 145 - "type": "string", 146 - "maxLength": 100, 147 - "description": "The value of the label being defined. Must only include lowercase ascii and the '-' character ([a-z-]+).", 148 - "maxGraphemes": 100 149 - }, 150 - "defaultSetting": { 151 - "type": "string", 152 - "default": "warn", 153 - "description": "The default setting for this label.", 154 - "knownValues": [ 155 - "ignore", 156 - "warn", 157 - "hide" 158 - ] 159 - } 160 - }, 161 - "description": "Declares a label value and its expected interpretations and behaviors." 162 - }, 163 - "labelValueDefinitionStrings": { 164 - "type": "object", 165 - "required": [ 166 - "lang", 167 - "name", 168 - "description" 169 - ], 170 - "properties": { 171 - "lang": { 172 - "type": "string", 173 - "format": "language", 174 - "description": "The code of the language these strings are written in." 175 - }, 176 - "name": { 177 - "type": "string", 178 - "maxLength": 640, 179 - "description": "A short human-readable name for the label.", 180 - "maxGraphemes": 64 181 - }, 182 - "description": { 183 - "type": "string", 184 - "maxLength": 100000, 185 - "description": "A longer description of what the label means and why it might be applied.", 186 - "maxGraphemes": 10000 187 - } 188 - }, 189 - "description": "Strings which describe the label in the UI, localized into a specific language." 190 - } 191 - } 192 - }
+7 -2
package.json
··· 14 14 "typecheck": "bun run --workspaces typecheck", 15 15 "typecheck:server": "bun run --filter @watproto/server typecheck", 16 16 "typecheck:client": "bun run --filter @watproto/client typecheck", 17 - "lex-server": "bunx lex gen-server ./packages/server/src/xrpc ./lexicons/app/wafrn/**/*.json" 17 + "lex": "bun run lex:client && bun run lex:server", 18 + "lex:client": "LEX_ENV=client bunx --bun lex-cli generate -c lex.config.js", 19 + "lex:server": "LEX_ENV=server bunx --bun lex-cli generate -c lex.config.js" 18 20 }, 19 21 "devDependencies": { 20 - "@atproto/lex-cli": "^0.9.6", 22 + "@atcute/atproto": "^3.1.8", 23 + "@atcute/bluesky": "^3.2.9", 24 + "@atcute/lex-cli": "^2.3.1", 25 + "@atcute/lexicons": "^1.2.2", 21 26 "concurrently": "^9.2.1", 22 27 "prettier": "^3.6.2", 23 28 "typescript": "^5.9.3"
+22
packages/client/app/components/FlashMessages.tsx
··· 1 + import { useRootData } from '@www/lib/useRootData' 2 + import { useEffect } from 'react' 3 + import { toast, Toaster } from 'sonner' 4 + 5 + const NOTIFICATION_TIME = 2000 // show flash messages only for 2s 6 + 7 + export default function FlashMessages() { 8 + const { message } = useRootData() 9 + useEffect(() => { 10 + if (message) { 11 + const toastFn = toast[message.type] 12 + if (toastFn) { 13 + toastFn(message.message, { 14 + duration: NOTIFICATION_TIME, 15 + position: 'top-center' 16 + }) 17 + } 18 + } 19 + }, [message]) 20 + 21 + return <Toaster /> 22 + }
+15 -9
packages/client/app/components/UserMenu.tsx
··· 1 - import { buildOauthLoginUrl } from '@www/lib/oauth' 2 1 import type { RootData } from '@www/lib/useRootData' 3 2 import clsx from 'clsx' 4 - import { Link } from 'react-router' 3 + import { useFetcher } from 'react-router' 4 + import { Link, Form } from 'react-router' 5 5 6 6 export default function UserMenu({ user }: { user?: RootData['user'] }) { 7 7 const avatar = user?.profile?.avatar 8 8 const handle = user?.identity.handle 9 + const fetcher = useFetcher() 9 10 10 - function signUp() { 11 - const loginUrl = buildOauthLoginUrl(window.location.origin) 12 - window.location.replace(loginUrl) 11 + function logout() { 12 + fetcher.submit( 13 + { action: 'logout' }, 14 + { 15 + action: '/login', 16 + method: 'POST' 17 + } 18 + ) 13 19 } 14 20 15 21 return user ? ( ··· 43 49 <a>Profile</a> 44 50 </li> 45 51 <li> 46 - <a>Logout</a> 52 + <button onClick={logout}>Logout</button> 47 53 </li> 48 54 </ul> 49 55 </div> ··· 53 59 <Link to="/login" className="btn btn-ghost"> 54 60 Login 55 61 </Link> 56 - <button onClick={signUp} className="btn btn-accent"> 57 - Join us! 58 - </button> 62 + <Form action="/login" method="POST"> 63 + <button className="btn btn-accent">Join us!</button> 64 + </Form> 59 65 </div> 60 66 ) 61 67 }
-22
packages/client/app/lib/api.ts
··· 1 - import { treaty } from '@elysiajs/eden' 2 - import { env } from './env' 3 - import { StatusError } from './https' 4 - import type { app } from '@api/index' 5 - 6 - export const apiClient = treaty<typeof app>(env.API_URL) 7 - 8 - export const sessionClient = (cookie: string) => 9 - treaty<typeof app>(env.API_URL, { headers: { Cookie: cookie } }) 10 - 11 - export function wrapApiResponse<T>({ 12 - data, 13 - error 14 - }: { 15 - data: T 16 - error: null | { status: number; value: string } 17 - }) { 18 - if (error) { 19 - throw new StatusError(error.status, error.value) 20 - } 21 - return data 22 - }
+40
packages/client/app/lib/db.server.ts
··· 1 + import { Database } from 'bun:sqlite' 2 + import { existsSync, mkdirSync } from 'node:fs' 3 + import { dirname } from 'node:path' 4 + import { env } from './env.server' 5 + 6 + const dbPath = env.DB_PATH 7 + 8 + // Ensure database directory exists 9 + const dbDir = dirname(dbPath) 10 + if (!existsSync(dbDir)) { 11 + mkdirSync(dbDir, { recursive: true }) 12 + } 13 + 14 + // Initialize SQLite database 15 + const db = new Database(dbPath) 16 + 17 + // enable WAL mode: https://bun.com/docs/runtime/sqlite#wal-mode 18 + db.run('PRAGMA journal_mode = WAL;') 19 + 20 + // Check if we need to migrate the schema 21 + const tablesExist = db 22 + .query( 23 + `SELECT name FROM sqlite_master WHERE type='table' AND name='oauth_sessions'` 24 + ) 25 + .get() as { name: string } | null 26 + 27 + if (!tablesExist) { 28 + db.run(` 29 + CREATE TABLE oauth_sessions ( 30 + key TEXT PRIMARY KEY, 31 + session TEXT NOT NULL 32 + ); 33 + CREATE TABLE oauth_states ( 34 + key TEXT PRIMARY KEY, 35 + state TEXT NOT NULL 36 + ); 37 + `) 38 + } 39 + 40 + export default db
+26
packages/client/app/lib/env.server.ts
··· 1 + import invariant from 'tiny-invariant' 2 + 3 + const CLIENT_URL = process.env.CLIENT_URL 4 + 5 + const API_URL = process.env.API_URL as string 6 + invariant(API_URL, 'API_URL must be defined') 7 + 8 + const DEFAULT_PDS_URL = process.env.DEFAULT_PDS_URL as string 9 + invariant(DEFAULT_PDS_URL, 'DEFAULT_PDS_URL must be defined') 10 + 11 + const DB_PATH = (process.env.DB_PATH as string) || './data/state.db' 12 + const IS_PROD = process.env.NODE_ENV === 'production' 13 + const PORT = Number(process.env.PORT || 5173) 14 + 15 + const AUTH_SECRET = process.env.AUTH_SECRET as string 16 + invariant(AUTH_SECRET, 'AUTH_SECRET must be defined') 17 + 18 + export const env = { 19 + CLIENT_URL, 20 + API_URL, 21 + DEFAULT_PDS_URL, 22 + DB_PATH, 23 + IS_PROD, 24 + PORT, 25 + AUTH_SECRET 26 + }
-12
packages/client/app/lib/env.ts
··· 1 - import invariant from 'tiny-invariant' 2 - 3 - const API_URL = import.meta.env.VITE_API_URL as string 4 - invariant(API_URL, 'VITE_API_URL must be defined') 5 - 6 - const DEFAULT_PDS_URL = import.meta.env.VITE_DEFAULT_PDS_URL as string 7 - invariant(DEFAULT_PDS_URL, 'VITE_DEFAULT_PDS_URL must be defined') 8 - 9 - export const env = { 10 - API_URL, 11 - DEFAULT_PDS_URL 12 - }
+8
packages/client/app/lib/idResolver.server.ts
··· 1 + import { IdResolver, MemoryCache } from '@atproto/identity' 2 + 3 + const HOUR = 60e3 * 60 4 + const DAY = HOUR * 24 5 + 6 + export const idResolver = new IdResolver({ 7 + didCache: new MemoryCache(HOUR, DAY) 8 + })
+35
packages/client/app/lib/oauth.server.ts
··· 1 + import { env } from './env.server' 2 + import { oauthClient } from './oauthClient.server' 3 + import { commitSession, getSession } from './session.server' 4 + 5 + export async function buildOauthLoginUrl( 6 + redirect_uri: string, 7 + handle?: string 8 + ) { 9 + const url = await oauthClient.authorize(handle || env.DEFAULT_PDS_URL, { 10 + state: redirect_uri 11 + }) 12 + return url.toString() 13 + } 14 + 15 + export async function logout(request: Request) { 16 + const cookie = request.headers.get('Cookie') 17 + const session = await getSession(cookie) 18 + const did = session.get('did') 19 + if (did) { 20 + try { 21 + await oauthClient.revoke(did) 22 + } catch (err) { 23 + console.error(err) 24 + } 25 + } 26 + // using an empty session with a flash to replace the old one 27 + // effectively the same as calling `destroySession` 28 + // according to this: https://sergiodxa.com/tutorials/destroy-user-session-and-while-setting-a-flash-message-in-remix 29 + const newSession = await getSession() 30 + newSession.flash('toast', { 31 + type: 'info', 32 + message: 'You have been logged out' 33 + }) 34 + return commitSession(newSession) 35 + }
-11
packages/client/app/lib/oauth.ts
··· 1 - import { env } from './env' 2 - 3 - export function buildOauthLoginUrl(redirect_uri: string, handle?: string) { 4 - const url = new URL(redirect_uri) 5 - url.pathname = '/login' 6 - const params = new URLSearchParams({ 7 - handle: handle || env.DEFAULT_PDS_URL, 8 - redirect_uri: url.toString() 9 - }) 10 - return `${env.API_URL}/oauth/login?${params.toString()}` 11 - }
+49
packages/client/app/lib/oauthStore.server.ts
··· 1 + import type { 2 + NodeSavedSession, 3 + NodeSavedSessionStore, 4 + NodeSavedState, 5 + NodeSavedStateStore 6 + } from '@atproto/oauth-client-node' 7 + import db from './db.server' 8 + 9 + export class StateStore implements NodeSavedStateStore { 10 + async get(key: string): Promise<NodeSavedState | undefined> { 11 + const result = db 12 + .query< 13 + { state: string }, 14 + string 15 + >(`SELECT state FROM oauth_states WHERE key = ?`) 16 + .get(key) 17 + if (!result) return 18 + return JSON.parse(result.state) as NodeSavedState 19 + } 20 + async set(key: string, val: NodeSavedState) { 21 + db.query<void, [string, string]>( 22 + 'INSERT INTO oauth_states VALUES (?, ?)' 23 + ).run(key, JSON.stringify(val)) 24 + } 25 + async del(key: string) { 26 + db.query<void, string>('DELETE FROM oauth_states WHERE key = ?').run(key) 27 + } 28 + } 29 + 30 + export class SessionStore implements NodeSavedSessionStore { 31 + async get(key: string): Promise<NodeSavedSession | undefined> { 32 + const result = db 33 + .query< 34 + { session: string }, 35 + string 36 + >(`SELECT session FROM oauth_sessions WHERE key = ?`) 37 + .get(key) 38 + if (!result) return 39 + return JSON.parse(result.session) as NodeSavedSession 40 + } 41 + async set(key: string, val: NodeSavedSession) { 42 + db.query<void, [string, string]>( 43 + 'INSERT INTO oauth_sessions VALUES (?, ?)' 44 + ).run(key, JSON.stringify(val)) 45 + } 46 + async del(key: string) { 47 + db.query<void, string>('DELETE FROM oauth_sessions WHERE key = ?').run(key) 48 + } 49 + }
+37
packages/client/app/lib/session.server.ts
··· 1 + import { createCookieSessionStorage } from 'react-router' 2 + import { env } from './env.server' 3 + import type { Did } from '@atcute/lexicons' 4 + 5 + type SessionData = { 6 + did: Did 7 + } 8 + type SessionFlash = { 9 + toast: { 10 + type: 'error' | 'success' | 'info' | 'warning' 11 + message: string 12 + } 13 + } 14 + 15 + const { getSession, commitSession, destroySession } = 16 + createCookieSessionStorage<SessionData, SessionFlash>({ 17 + cookie: { 18 + name: '__session', 19 + httpOnly: true, 20 + sameSite: 'lax', 21 + secure: true, 22 + secrets: [env.AUTH_SECRET] 23 + } 24 + }) 25 + 26 + export { getSession, commitSession, destroySession } 27 + 28 + export async function getFlashMessage(request: Request) { 29 + const cookie = request.headers.get('Cookie') 30 + const session = await getSession(cookie) 31 + const message = session.get('toast') 32 + 33 + return { 34 + message, 35 + newCookie: await commitSession(session) 36 + } 37 + }
+33 -5
packages/client/app/lib/user.ts
··· 1 - import { sessionClient, wrapApiResponse } from './api' 1 + import { idResolver } from './idResolver.server' 2 + import { StatusError } from './https' 3 + import { getSessionAgent, type XRPCLient } from '@www/lib/xprcClient' 4 + import type { Did } from '@atcute/lexicons' 2 5 3 6 export async function getCurrentUser(request: Request) { 4 7 try { 5 - const cookie = request.headers.get('Cookie') || '' 6 - const response = await sessionClient(cookie).oauth.me.get() 7 - return wrapApiResponse(response) 8 + const { client, did } = await getSessionAgent(request) 9 + const identity = await idResolver.did.resolveAtprotoData(did) 10 + const profile = await getProfile(client, did) 11 + return { profile, identity } 8 12 } catch (error) { 9 - console.error('🚫 API error during auth check: \n', error) 13 + const isStatusError = 14 + error instanceof StatusError && [401, 403].includes(error.statusCode ?? 0) 15 + const isErrorResponse = 16 + error instanceof Response && [401, 403].includes(error.status) 17 + const isUnauthorized = isStatusError || isErrorResponse 18 + if (!isUnauthorized) { 19 + console.error('🚫 API error during auth check: \n', error) 20 + } 10 21 return null 11 22 } 12 23 } 24 + 25 + export async function getProfile(agent: XRPCLient, did: Did) { 26 + // if no 'app.bsky.actor.profile' record is found on the user's PDS, this method will not return an error 27 + // instead it will return an empty skeleton of a profile record with "handle" set to "handle.invalid" 28 + // const profile = await agent.getProfile( 29 + // didOrHandle ? { actor: didOrHandle } : undefined 30 + // ) 31 + const profile = await agent.get('app.bsky.actor.getProfile', { 32 + params: { actor: did } 33 + }) 34 + if (!profile.ok) { 35 + throw new Error('Failed to fetch profile') 36 + } 37 + 38 + const data = profile.data.handle === 'handle.invalid' ? null : profile.data 39 + return data 40 + }
+26
packages/client/app/lib/xprcClient.ts
··· 1 + import { Client } from '@atcute/client' 2 + import { oauthClient } from '@www/lib/oauthClient.server' 3 + import { getSession } from '@www/lib/session.server' 4 + 5 + // magic import: this loads bluesky calls into the current atcute client 6 + import type {} from '@atcute/bluesky' 7 + 8 + export type XRPCLient = Awaited<ReturnType<typeof getSessionAgent>>['client'] 9 + 10 + export async function getSessionAgent(request: Request) { 11 + const sessionStore = await getSession(request.headers.get('Cookie')) 12 + const did = sessionStore.get('did') 13 + if (did) { 14 + try { 15 + const session = await oauthClient.restore(did) 16 + const client = new Client({ handler: session.fetchHandler }) 17 + return { client, did } 18 + } catch (err) { 19 + throw new Response(`Cannot restore session: ${String(err)}`, { 20 + status: 500 21 + }) 22 + } 23 + } else { 24 + throw new Response('No session found', { status: 401 }) 25 + } 26 + }
+19 -5
packages/client/app/root.tsx
··· 1 1 import { 2 + data, 2 3 isRouteErrorResponse, 3 4 Links, 4 5 Meta, ··· 11 12 import './app.css' 12 13 import { getCurrentUser } from './lib/user' 13 14 import Header from './components/Header' 15 + import { Link } from 'react-router' 16 + import { getFlashMessage } from './lib/session.server' 17 + import FlashMessages from './components/FlashMessages' 14 18 15 19 export const links: Route.LinksFunction = () => [ 16 20 { rel: 'preconnect', href: 'https://fonts.googleapis.com' }, ··· 26 30 ] 27 31 28 32 export async function loader({ request }: Route.LoaderArgs) { 33 + const { message, newCookie } = await getFlashMessage(request) 29 34 const user = await getCurrentUser(request) 30 - return { user } 35 + return data( 36 + { user, message }, 37 + { 38 + headers: { 'Set-Cookie': newCookie } 39 + } 40 + ) 31 41 } 32 42 33 43 export function Layout({ children }: { children: React.ReactNode }) { ··· 51 61 export default function App() { 52 62 return ( 53 63 <> 64 + <FlashMessages /> 54 65 <Header /> 55 66 <Outlet /> 56 67 </> ··· 74 85 } 75 86 76 87 return ( 77 - <main className="pt-16 p-4 container mx-auto"> 78 - <h1>{message}</h1> 79 - <p>{details}</p> 88 + <main className="mt-16 p-4 container mx-auto bg-error-content rounded-md"> 89 + <h1 className="text-lg text-error mb-2">{message}</h1> 90 + <p className="text-error mb-2">{details}</p> 80 91 {stack && ( 81 - <pre className="w-full p-4 overflow-x-auto"> 92 + <pre className="w-full p-4 overflow-x-auto border border-base-200"> 82 93 <code>{stack}</code> 83 94 </pre> 84 95 )} 96 + <Link className="btn btn-error mt-2" to="/"> 97 + Go home 98 + </Link> 85 99 </main> 86 100 ) 87 101 }
+12 -6
packages/client/app/routes/login.tsx
··· 1 - import { buildOauthLoginUrl } from '@www/lib/oauth' 2 1 import type { Route } from './+types/login' 3 - import { redirect, useSearchParams } from 'react-router' 2 + import { data, Form, redirect, useSearchParams } from 'react-router' 3 + import { buildOauthLoginUrl, logout } from '@www/lib/oauth.server' 4 4 import { useRootData } from '@www/lib/useRootData' 5 5 import { useNavigate } from 'react-router' 6 6 import { useEffect, useRef } from 'react' 7 7 8 8 export async function action({ request }: Route.ActionArgs) { 9 9 const formData = await request.formData() 10 - const handle = String(formData.get('handle')) 10 + const action = formData.get('action') 11 + if (action === 'logout') { 12 + const cookie = await logout(request) 13 + return data({ success: true }, { headers: { 'Set-Cookie': cookie } }) 14 + } 15 + 16 + const handle = String(formData.get('handle') ?? '') 11 17 const origin = new URL(request.url).origin 12 - const loginUrl = buildOauthLoginUrl(origin, handle) 18 + const loginUrl = await buildOauthLoginUrl(origin, handle) 13 19 return redirect(loginUrl) 14 20 } 15 21 ··· 29 35 }, [loginSuccess, navigate]) 30 36 31 37 return ( 32 - <form method="POST" className="w-xs mx-auto mt-8"> 38 + <Form method="POST" className="w-xs mx-auto mt-8"> 33 39 <fieldset className="fieldset bg-base-200 p-4 rounded-box border border-base-300"> 34 40 <legend className="fieldset-legend"> 35 41 Login with your ATProto account ··· 56 62 57 63 <button className="btn btn-primary mt-4">Login</button> 58 64 </fieldset> 59 - </form> 65 + </Form> 60 66 ) 61 67 }
+51
packages/client/app/routes/oauth.$.tsx
··· 1 + import { oauthClient } from '@www/lib/oauthClient.server' 2 + import type { Route } from './+types/oauth.$' 3 + import keys from '@www/jwks.json' 4 + import { commitSession, getSession } from '@www/lib/session.server' 5 + import { redirect } from 'react-router' 6 + 7 + const publicKeys = keys.map((k) => { 8 + const { kty, alg, kid, crv, x, y } = k 9 + return { kty, alg, kid, crv, x, y } 10 + }) 11 + 12 + export async function loader({ params, request }: Route.LoaderArgs) { 13 + const path = params['*'] 14 + if (path === 'jwks.json') { 15 + return publicKeys 16 + } 17 + if (path === 'client-metadata.json') { 18 + return oauthClient.clientMetadata 19 + } 20 + if (path === 'callback') { 21 + const sessionStore = await getSession(request.headers.get('Cookie')) 22 + try { 23 + const params = new URL(request.url).searchParams 24 + const { session, state } = await oauthClient.callback(params) 25 + sessionStore.set('did', session.did) 26 + sessionStore.flash('toast', { 27 + type: 'success', 28 + message: 'You are now logged in' 29 + }) 30 + const redirectUrl = new URL(state ?? '/', request.url).toString() 31 + return redirect(redirectUrl, { 32 + headers: { 33 + 'Set-Cookie': await commitSession(sessionStore) 34 + } 35 + }) 36 + } catch (err) { 37 + console.error(err) 38 + sessionStore.flash('toast', { 39 + type: 'error', 40 + message: 'Invalid credentials' 41 + }) 42 + return redirect('/login', { 43 + headers: { 44 + 'Set-Cookie': await commitSession(sessionStore) 45 + } 46 + }) 47 + } 48 + } 49 + 50 + return new Response('Not found', { status: 404 }) 51 + }
+2
packages/client/app/xrpc/index.ts
··· 1 + export * as AppWafrnContentCreatePost from "./types/app/wafrn/content/createPost.js"; 2 + export * as AppWafrnContentPost from "./types/app/wafrn/content/post.js";
+57
packages/client/app/xrpc/types/app/wafrn/content/createPost.ts
··· 1 + import type {} from "@atcute/lexicons"; 2 + import * as v from "@atcute/lexicons/validations"; 3 + import type {} from "@atcute/lexicons/ambient"; 4 + import * as AppWafrnContentPost from "./post.js"; 5 + import * as ComAtprotoLabelDefs from "@atcute/atproto/types/label/defs"; 6 + 7 + const _mainSchema = /*#__PURE__*/ v.procedure("app.wafrn.content.createPost", { 8 + params: null, 9 + input: { 10 + type: "lex", 11 + schema: /*#__PURE__*/ v.object({ 12 + contentMarkdown: /*#__PURE__*/ v.string(), 13 + contentWarning: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.string()), 14 + get labels() { 15 + return /*#__PURE__*/ v.optional( 16 + /*#__PURE__*/ v.variant([ComAtprotoLabelDefs.selfLabelsSchema]), 17 + ); 18 + }, 19 + tags: /*#__PURE__*/ v.optional( 20 + /*#__PURE__*/ v.array(/*#__PURE__*/ v.string()), 21 + ), 22 + /** 23 + * @default "public" 24 + */ 25 + visibility: /*#__PURE__*/ v.optional( 26 + /*#__PURE__*/ v.string< 27 + "followers" | "mentioned" | "public" | (string & {}) 28 + >(), 29 + "public", 30 + ), 31 + }), 32 + }, 33 + output: { 34 + type: "lex", 35 + schema: /*#__PURE__*/ v.object({ 36 + get post() { 37 + return AppWafrnContentPost.mainSchema; 38 + }, 39 + }), 40 + }, 41 + }); 42 + 43 + type main$schematype = typeof _mainSchema; 44 + 45 + export interface mainSchema extends main$schematype {} 46 + 47 + export const mainSchema = _mainSchema as mainSchema; 48 + 49 + export interface $params {} 50 + export interface $input extends v.InferXRPCBodyInput<mainSchema["input"]> {} 51 + export interface $output extends v.InferXRPCBodyInput<mainSchema["output"]> {} 52 + 53 + declare module "@atcute/lexicons/ambient" { 54 + interface XRPCProcedures { 55 + "app.wafrn.content.createPost": mainSchema; 56 + } 57 + }
+79
packages/client/app/xrpc/types/app/wafrn/content/post.ts
··· 1 + import type {} from "@atcute/lexicons"; 2 + import * as v from "@atcute/lexicons/validations"; 3 + import type {} from "@atcute/lexicons/ambient"; 4 + import * as ComAtprotoLabelDefs from "@atcute/atproto/types/label/defs"; 5 + 6 + const _mainSchema = /*#__PURE__*/ v.record( 7 + /*#__PURE__*/ v.tidString(), 8 + /*#__PURE__*/ v.object({ 9 + $type: /*#__PURE__*/ v.literal("app.wafrn.content.post"), 10 + get content() { 11 + return /*#__PURE__*/ v.variant([ 12 + postContentSchema, 13 + postEncryptedContentSchema, 14 + ]); 15 + }, 16 + /** 17 + * @default "public" 18 + */ 19 + visibility: /*#__PURE__*/ v.optional( 20 + /*#__PURE__*/ v.string< 21 + "followers" | "mentioned" | "public" | (string & {}) 22 + >(), 23 + "public", 24 + ), 25 + }), 26 + ); 27 + const _postContentSchema = /*#__PURE__*/ v.object({ 28 + $type: /*#__PURE__*/ v.optional( 29 + /*#__PURE__*/ v.literal("app.wafrn.content.post#postContent"), 30 + ), 31 + contentHTML: /*#__PURE__*/ v.string(), 32 + contentMarkdown: /*#__PURE__*/ v.string(), 33 + contentWarning: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.string()), 34 + createdAt: /*#__PURE__*/ v.datetimeString(), 35 + get labels() { 36 + return /*#__PURE__*/ v.optional( 37 + /*#__PURE__*/ v.variant([ComAtprotoLabelDefs.selfLabelsSchema]), 38 + ); 39 + }, 40 + tags: /*#__PURE__*/ v.optional( 41 + /*#__PURE__*/ v.array(/*#__PURE__*/ v.string()), 42 + ), 43 + updatedAt: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.datetimeString()), 44 + }); 45 + const _postEncryptedContentSchema = /*#__PURE__*/ v.object({ 46 + $type: /*#__PURE__*/ v.optional( 47 + /*#__PURE__*/ v.literal("app.wafrn.content.post#postEncryptedContent"), 48 + ), 49 + encryptedContent: /*#__PURE__*/ v.string(), 50 + /** 51 + * @minimum 0 52 + */ 53 + keyVersion: /*#__PURE__*/ v.integer(), 54 + }); 55 + 56 + type main$schematype = typeof _mainSchema; 57 + type postContent$schematype = typeof _postContentSchema; 58 + type postEncryptedContent$schematype = typeof _postEncryptedContentSchema; 59 + 60 + export interface mainSchema extends main$schematype {} 61 + export interface postContentSchema extends postContent$schematype {} 62 + export interface postEncryptedContentSchema 63 + extends postEncryptedContent$schematype {} 64 + 65 + export const mainSchema = _mainSchema as mainSchema; 66 + export const postContentSchema = _postContentSchema as postContentSchema; 67 + export const postEncryptedContentSchema = 68 + _postEncryptedContentSchema as postEncryptedContentSchema; 69 + 70 + export interface Main extends v.InferInput<typeof mainSchema> {} 71 + export interface PostContent extends v.InferInput<typeof postContentSchema> {} 72 + export interface PostEncryptedContent 73 + extends v.InferInput<typeof postEncryptedContentSchema> {} 74 + 75 + declare module "@atcute/lexicons/ambient" { 76 + interface Records { 77 + "app.wafrn.content.post": mainSchema; 78 + } 79 + }
+4 -2
packages/client/package.json
··· 10 10 "typecheck": "bunx --bun react-router typegen && tsc" 11 11 }, 12 12 "dependencies": { 13 - "@elysiajs/eden": "1.4.4", 13 + "@atcute/client": "^4.0.5", 14 + "@atproto/identity": "^0.4.9", 15 + "@atproto/oauth-client-node": "^0.3.10", 14 16 "@react-router/fs-routes": "^7.9.4", 15 17 "@react-router/node": "^7.9.2", 16 18 "@react-router/serve": "^7.9.2", ··· 20 22 "react": "^19.1.1", 21 23 "react-dom": "^19.1.1", 22 24 "react-router": "^7.9.2", 25 + "sonner": "^2.0.7", 23 26 "tiny-invariant": "^1.3.3" 24 27 }, 25 28 "devDependencies": { ··· 30 33 "@types/node": "22", 31 34 "@types/react": "^19.1.13", 32 35 "@types/react-dom": "^19.1.9", 33 - "elysia": "1.4.13", 34 36 "eslint": "^9.36.0", 35 37 "eslint-config-flat-gitignore": "^2.1.0", 36 38 "eslint-plugin-react-hooks": "^7.0.1",
+3 -3
packages/client/react-router.config.ts
··· 1 - import type { Config } from "@react-router/dev/config"; 1 + import type { Config } from '@react-router/dev/config' 2 2 3 3 export default { 4 4 // Config options... 5 5 // Server-side render by default, to enable SPA mode set this to `false` 6 - ssr: true, 7 - } satisfies Config; 6 + ssr: true 7 + } satisfies Config
+1
packages/client/vite.config.ts
··· 6 6 export default defineConfig({ 7 7 plugins: [tailwindcss(), reactRouter(), tsconfigPaths()], 8 8 server: { 9 + port: Number(import.meta.env.PORT || 5173), 9 10 host: '127.0.0.1' 10 11 } 11 12 })
+1 -3
packages/server/package.json
··· 12 12 "typecheck": "bunx --bun tsc --noEmit" 13 13 }, 14 14 "dependencies": { 15 - "@atproto/api": "^0.17.3", 16 - "@atproto/identity": "^0.4.9", 17 - "@atproto/oauth-client-node": "^0.3.9", 15 + "@atcute/xrpc-server": "^0.1.3", 18 16 "@elysiajs/cors": "1.4.0", 19 17 "@elysiajs/openapi": "1.4.11", 20 18 "elysia": "1.4.13",
-21
packages/server/src/db/migrations/001-oauth-state.ts
··· 1 - import type { Kysely } from 'kysely' 2 - 3 - // `any` is required here since migrations should be frozen in time. alternatively, keep a "snapshot" db interface. 4 - export async function up(db: Kysely<any>): Promise<void> { 5 - await db.schema 6 - .createTable('oauth_sessions') 7 - .addColumn('key', 'text', (col) => col.primaryKey()) 8 - .addColumn('session', 'text', (col) => col.notNull()) 9 - .execute() 10 - await db.schema 11 - .createTable('oauth_states') 12 - .addColumn('key', 'text', (col) => col.primaryKey()) 13 - .addColumn('state', 'text', (col) => col.notNull()) 14 - .execute() 15 - } 16 - 17 - // `any` is required here since migrations should be frozen in time. alternatively, keep a "snapshot" db interface. 18 - export async function down(db: Kysely<any>): Promise<void> { 19 - await db.schema.dropTable('oauth_states').execute() 20 - await db.schema.dropTable('oauth_sessions').execute() 21 - }
-39
packages/server/src/db/migrations/1761604662090_app_session.ts
··· 1 - import { sql, type Kysely } from 'kysely' 2 - 3 - // `any` is required here since migrations should be frozen in time. alternatively, keep a "snapshot" db interface. 4 - export async function up(db: Kysely<any>): Promise<void> { 5 - await db.schema 6 - .createTable('app_sessions') 7 - .addColumn('id', 'text', (col) => col.primaryKey().notNull()) 8 - .addColumn('account_did', 'text', (col) => 9 - col.references('accounts.did').onDelete('cascade').notNull() 10 - ) 11 - .addColumn('data', 'text', (col) => col.notNull()) 12 - .addColumn('created_at', 'integer', (col) => 13 - col.notNull().defaultTo(sql`(unixepoch() * 1000)`) 14 - ) 15 - .addColumn('expires_at', 'integer', (col) => 16 - col.notNull().defaultTo(sql`((unixepoch() + 7 * 24 * 60 * 60) * 1000)`) 17 - ) 18 - .execute() 19 - 20 - // Add index for faster lookups and cleanup 21 - await db.schema 22 - .createIndex('app_sessions_expires_at_index') 23 - .on('app_sessions') 24 - .column('expires_at') 25 - .execute() 26 - 27 - await db.schema 28 - .createIndex('app_sessions_account_did_index') 29 - .on('app_sessions') 30 - .column('account_did') 31 - .execute() 32 - } 33 - 34 - // `any` is required here since migrations should be frozen in time. alternatively, keep a "snapshot" db interface. 35 - export async function down(db: Kysely<any>): Promise<void> { 36 - await db.schema.dropIndex('app_sessions_expires_at_index').execute() 37 - await db.schema.dropIndex('app_sessions_account_did_index').execute() 38 - await db.schema.dropTable('app_sessions').execute() 39 - }
-45
packages/server/src/db/schema.d.ts
··· 1 - /** 2 - * This file was generated by kysely-codegen. 3 - * Please do not edit it manually. 4 - */ 5 - 6 - import type { ColumnType } from "kysely"; 7 - 8 - export type Generated<T> = T extends ColumnType<infer S, infer I, infer U> 9 - ? ColumnType<S, I | undefined, U> 10 - : ColumnType<T, T | undefined, T>; 11 - 12 - export interface Accounts { 13 - active: Generated<number>; 14 - did: string; 15 - first_seen_at: Generated<number>; 16 - handle: string; 17 - last_login_at: Generated<number>; 18 - role: Generated<string>; 19 - status: string | null; 20 - } 21 - 22 - export interface AppSessions { 23 - account_did: string; 24 - created_at: Generated<number>; 25 - data: string; 26 - expires_at: Generated<number>; 27 - id: string; 28 - } 29 - 30 - export interface OauthSessions { 31 - key: string | null; 32 - session: string; 33 - } 34 - 35 - export interface OauthStates { 36 - key: string | null; 37 - state: string; 38 - } 39 - 40 - export interface DB { 41 - accounts: Accounts; 42 - app_sessions: AppSessions; 43 - oauth_sessions: OauthSessions; 44 - oauth_states: OauthStates; 45 - }
+3 -17
packages/server/src/index.ts
··· 5 5 import cors from '@elysiajs/cors' 6 6 import { migrateDBToLatest } from './db/db' 7 7 import env from './lib/env' 8 - import oauthRoutes from './oauth/routes' 9 - import { cleanupExpiredSessions } from './lib/session' 8 + import { xrpcServer } from './lib/xrpcServer' 10 9 11 10 // Ensure latest migration is in database 12 11 await migrateDBToLatest() 13 12 14 - // Cleanup expired sessions on startup 15 - const deletedCount = await cleanupExpiredSessions() 16 - if (deletedCount > 0) { 17 - console.log(`🧹 Cleaned up ${deletedCount} expired session(s)`) 18 - } 19 - 20 13 export const app = new Elysia() 21 - .use( 22 - cors({ 23 - origin: '*', 24 - credentials: true, 25 - allowedHeaders: ['Content-Type', 'Authorization'], 26 - methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'] 27 - }) 28 - ) 14 + .use(cors()) 29 15 .use( 30 16 openapi({ 31 17 references: fromTypes('src/index.ts', { ··· 33 19 }) 34 20 }) 35 21 ) 36 - .use(oauthRoutes) 37 22 .get('/', () => ({ 38 23 version: pkg.version, 39 24 name: pkg.name 40 25 })) 26 + .mount(xrpcServer.fetch) 41 27 .listen(env.PORT) 42 28 43 29 console.log(
-23
packages/server/src/lib/env.ts
··· 6 6 const DATABASE_URL = process.env.DATABASE_URL || ':memory:' 7 7 invariant(DATABASE_URL, 'process.env.DATABASE_URL is required') 8 8 9 - const AUTH_SECRET = process.env.AUTH_SECRET 10 - invariant(AUTH_SECRET, 'process.env.AUTH_SECRET is required') 11 - 12 - // BASE_URL can be undefined 13 - const BASE_URL = process.env.BASE_URL 14 - 15 - const ADMIN_EMAIL = process.env.ADMIN_EMAIL || 'admin@example.com' 16 - invariant(ADMIN_EMAIL, 'process.env.ADMIN_EMAIL is required') 17 - 18 - const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD || 'adminpassword' 19 - invariant(ADMIN_PASSWORD, 'process.env.ADMIN_PASSWORD is required') 20 - 21 - const ALLOWED_REDIRECT_ORIGINS = process.env.ALLOWED_REDIRECT_ORIGINS 22 - invariant( 23 - ALLOWED_REDIRECT_ORIGINS, 24 - 'process.env.ALLOWED_REDIRECT_ORIGINS is required' 25 - ) 26 - 27 9 const IS_PROD = process.env.NODE_ENV === 'production' 28 10 29 11 export default { 30 12 PORT, 31 13 DATABASE_URL, 32 - AUTH_SECRET, 33 - BASE_URL, 34 - ADMIN_EMAIL, 35 - ADMIN_PASSWORD, 36 - ALLOWED_REDIRECT_ORIGINS, 37 14 IS_PROD 38 15 }
-51
packages/server/src/lib/idresolver.ts
··· 1 - import { IdResolver, MemoryCache } from '@atproto/identity' 2 - 3 - const HOUR = 60e3 * 60 4 - const DAY = HOUR * 24 5 - 6 - export const idResolver = new IdResolver({ 7 - didCache: new MemoryCache(HOUR, DAY), 8 - }) 9 - 10 - // export function createIdResolver() { 11 - // return new IdResolver({ 12 - // didCache: new MemoryCache(HOUR, DAY), 13 - // }) 14 - // } 15 - 16 - // export type BidirectionalResolver = ReturnType< 17 - // typeof createBidirectionalResolver 18 - // > 19 - 20 - // export function createBidirectionalResolver(resolver: IdResolver) { 21 - // return { 22 - // async resolveHandleToDid(handle: string) { 23 - // const resolvedDid = await resolver.handle.resolve(handle) 24 - // if (!resolvedDid) { 25 - // return undefined 26 - // } 27 - // const didDoc = await resolver.did.resolveAtprotoData(resolvedDid) 28 - // return didDoc 29 - // }, 30 - 31 - // async resolveDidToHandle(did: string) { 32 - // const didDoc = await resolver.did.resolveAtprotoData(did) 33 - // const resolvedHandle = await resolver.handle.resolve(didDoc.handle) 34 - // if (resolvedHandle === did) { 35 - // return didDoc.handle 36 - // } 37 - // return did 38 - // }, 39 - 40 - // async resolveDidsToHandles(dids: string[]) { 41 - // const didHandleMap: Record<string, string> = {} 42 - // const resolves = await Promise.all( 43 - // dids.map((did) => this.resolveDidToHandle(did).catch((_) => did)), 44 - // ) 45 - // for (let i = 0; i < dids.length; i++) { 46 - // didHandleMap[dids[i]] = resolves[i] 47 - // } 48 - // return didHandleMap 49 - // }, 50 - // } 51 - // }
-51
packages/server/src/lib/middleware.ts
··· 1 - import { Elysia } from 'elysia' 2 - import { getSession, refreshSession, type SessionData } from './session' 3 - 4 - /** 5 - * Auth plugin with Macro for declarative authentication 6 - * 7 - * Usage: 8 - * const app = new Elysia() 9 - * .use(authMacro) 10 - * .get('/profile', ({ session }) => session.account, { 11 - * auth: true 12 - * }) 13 - * .get('/admin', ({ session }) => 'admin area', { 14 - * auth: 'admin' 15 - * }) 16 - */ 17 - export const authMacro = new Elysia({ name: 'auth' }).macro({ 18 - auth: (enabled: boolean | 'admin') => ({ 19 - async resolve({ cookie, status }) { 20 - if (!enabled) { 21 - return 22 - } 23 - 24 - const sessionId = cookie.session?.value as string 25 - 26 - if (!sessionId) { 27 - return status(401, 'Unauthorized: No session cookie') 28 - } 29 - 30 - const session = await getSession(sessionId) 31 - 32 - if (!session) { 33 - return status(401, 'Unauthorized: Invalid or expired session') 34 - } 35 - 36 - // Check if account is active 37 - if (!session.account.active) { 38 - return status(403, 'Forbidden: Account is inactive') 39 - } 40 - 41 - if (enabled === 'admin' && session.account.role !== 'admin') { 42 - return status(403, 'Forbidden: Admin role required') 43 - } 44 - 45 - // Refresh session if needed (sliding window) 46 - await refreshSession(sessionId) 47 - 48 - return { session } 49 - } 50 - }) 51 - })
-18
packages/server/src/lib/profile.ts
··· 1 - import { Agent } from '@atproto/api' 2 - import type { ProfileViewDetailed } from '@atproto/api/dist/client/types/app/bsky/actor/defs' 3 - 4 - export function parseProfile(profile: ProfileViewDetailed) { 5 - return profile.handle !== 'handle.invalid' ? null : profile 6 - } 7 - 8 - export async function getProfile(agent: Agent, didOrHandle: string) { 9 - // if no 'app.bsky.actor.profile' record is found on the user's PDS, this method will not return an error 10 - // instead it will return an empty skeleton of a profile record with "handle" set to "handle.invalid" 11 - const profile = await agent.getProfile({ actor: didOrHandle }) 12 - if (!profile.success) { 13 - throw new Error('Failed to fetch profile') 14 - } 15 - 16 - const data = profile.data.handle === 'handle.invalid' ? null : profile.data 17 - return data 18 - }
-63
packages/server/src/lib/redirect-validator.ts
··· 1 - import env from '@api/lib/env' 2 - 3 - /** 4 - * Validates that a redirect URI is allowed 5 - * Prevents open redirect vulnerabilities by checking against whitelist 6 - * 7 - * @param uri - The redirect URI to validate 8 - * @returns true if the URI's origin is in the whitelist 9 - */ 10 - export function isAllowedRedirectUri(uri: string): boolean { 11 - try { 12 - const url = new URL(uri) 13 - const allowedOrigins = env.ALLOWED_REDIRECT_ORIGINS.split(',').map((o) => 14 - o.trim() 15 - ) 16 - return allowedOrigins.includes(url.origin) 17 - } catch { 18 - // Invalid URL format 19 - return false 20 - } 21 - } 22 - 23 - const DEFAULT_LOCAL_REDIRECT_URI = 'http://127.0.0.1:5173' 24 - 25 - /** 26 - * Gets the default redirect URI for the environment 27 - * Uses the first allowed origin in the whitelist 28 - * 29 - * @returns The default redirect URI 30 - */ 31 - export function getDefaultRedirectUri(): string { 32 - const allowed = env.ALLOWED_REDIRECT_ORIGINS.split(',').map((o) => o.trim()) 33 - return allowed[0] || DEFAULT_LOCAL_REDIRECT_URI 34 - } 35 - 36 - export function parseRedirectState(state: string | null) { 37 - let defaultUri = getDefaultRedirectUri() 38 - if (!state) { 39 - return defaultUri 40 - } 41 - 42 - try { 43 - const stateData = JSON.parse(state) as { 44 - returnTo: string 45 - timestamp: number 46 - } 47 - if (!stateData.returnTo) { 48 - return defaultUri 49 - } 50 - 51 - // Re-validate even though we validated on login 52 - // (defense in depth - in case state was tampered with) 53 - if (!isAllowedRedirectUri(stateData.returnTo)) { 54 - console.warn('State contained invalid redirect_uri:', stateData.returnTo) 55 - return defaultUri 56 - } 57 - 58 - return stateData.returnTo 59 - } catch (error) { 60 - console.error('Failed to parse OAuth state:', error) 61 - return defaultUri 62 - } 63 - }
-181
packages/server/src/lib/session.ts
··· 1 - import { db } from '@api/db/db' 2 - 3 - // Session configuration 4 - const SESSION_DURATION = 7 * 24 * 60 * 60 * 1000 // 7 days in milliseconds 5 - const REFRESH_THRESHOLD = 0.5 // Refresh if >50% time elapsed 6 - 7 - export interface SessionData { 8 - id: string 9 - account: { 10 - did: string 11 - handle: string 12 - role: string 13 - active: boolean 14 - } 15 - created_at: number 16 - expires_at: number 17 - } 18 - 19 - /** 20 - * Create a new app session for a user 21 - * @param accountDid - The user's DID 22 - * @param data - Optional additional data to store (will be merged with account data) 23 - * @returns Session UUID 24 - */ 25 - export async function createSession( 26 - accountDid: string, 27 - data?: object 28 - ): Promise<string> { 29 - const sessionId = crypto.randomUUID() 30 - const now = Date.now() 31 - const expiresAt = now + SESSION_DURATION 32 - 33 - // Get account data 34 - const account = await db 35 - .selectFrom('accounts') 36 - .select(['did', 'handle', 'role', 'active']) 37 - .where('did', '=', accountDid) 38 - .executeTakeFirst() 39 - 40 - if (!account) { 41 - throw new Error('Account not found') 42 - } 43 - 44 - // Store session data as JSON 45 - const sessionData = { 46 - did: account.did, 47 - handle: account.handle, 48 - role: account.role, 49 - active: Boolean(account.active), 50 - ...data 51 - } 52 - 53 - await db 54 - .insertInto('app_sessions') 55 - .values({ 56 - id: sessionId, 57 - account_did: accountDid, 58 - data: JSON.stringify(sessionData), 59 - created_at: now, 60 - expires_at: expiresAt 61 - }) 62 - .execute() 63 - 64 - return sessionId 65 - } 66 - 67 - /** 68 - * Get session data with fresh account information 69 - * @param sessionId - Session UUID 70 - * @returns Session data with account info, or null if not found/expired 71 - */ 72 - export async function getSession( 73 - sessionId: string 74 - ): Promise<SessionData | null> { 75 - const now = Date.now() 76 - 77 - // Get session joined with account to ensure fresh data 78 - const result = await db 79 - .selectFrom('app_sessions') 80 - .innerJoin('accounts', 'accounts.did', 'app_sessions.account_did') 81 - .select([ 82 - 'app_sessions.id', 83 - 'app_sessions.created_at', 84 - 'app_sessions.expires_at', 85 - 'accounts.did', 86 - 'accounts.handle', 87 - 'accounts.role', 88 - 'accounts.active' 89 - ]) 90 - .where('app_sessions.id', '=', sessionId) 91 - .where('app_sessions.expires_at', '>', now) 92 - .executeTakeFirst() 93 - 94 - if (!result) { 95 - return null 96 - } 97 - 98 - return { 99 - id: result.id, 100 - account: { 101 - did: result.did, 102 - handle: result.handle, 103 - role: result.role, 104 - active: Boolean(result.active) 105 - }, 106 - created_at: result.created_at, 107 - expires_at: result.expires_at 108 - } 109 - } 110 - 111 - /** 112 - * Refresh session expiration (sliding window) 113 - * Only refreshes if more than REFRESH_THRESHOLD of time has elapsed 114 - * @param sessionId - Session UUID 115 - */ 116 - export async function refreshSession(sessionId: string): Promise<void> { 117 - const session = await db 118 - .selectFrom('app_sessions') 119 - .select(['created_at', 'expires_at']) 120 - .where('id', '=', sessionId) 121 - .executeTakeFirst() 122 - 123 - if (!session) { 124 - return 125 - } 126 - 127 - const now = Date.now() 128 - const elapsed = now - session.created_at 129 - const total = session.expires_at - session.created_at 130 - 131 - // Only refresh if more than threshold has elapsed 132 - if (elapsed / total > REFRESH_THRESHOLD) { 133 - const newExpiresAt = now + SESSION_DURATION 134 - 135 - await db 136 - .updateTable('app_sessions') 137 - .set({ expires_at: newExpiresAt }) 138 - .where('id', '=', sessionId) 139 - .execute() 140 - } 141 - } 142 - 143 - /** 144 - * Delete a single session (logout) 145 - * @param sessionId - Session UUID 146 - */ 147 - export async function deleteSession(sessionId: string): Promise<void> { 148 - await db.deleteFrom('app_sessions').where('id', '=', sessionId).execute() 149 - } 150 - 151 - /** 152 - * Delete all sessions for a user (revoke all) 153 - * @param accountDid - User's DID 154 - * @returns Number of sessions deleted 155 - */ 156 - export async function deleteAllUserSessions( 157 - accountDid: string 158 - ): Promise<number> { 159 - const result = await db 160 - .deleteFrom('app_sessions') 161 - .where('account_did', '=', accountDid) 162 - .executeTakeFirst() 163 - 164 - return Number(result.numDeletedRows) 165 - } 166 - 167 - /** 168 - * Clean up expired sessions 169 - * Should be run periodically (cron job or on startup) 170 - * @returns Number of sessions deleted 171 - */ 172 - export async function cleanupExpiredSessions(): Promise<number> { 173 - const now = Date.now() 174 - 175 - const result = await db 176 - .deleteFrom('app_sessions') 177 - .where('expires_at', '<', now) 178 - .executeTakeFirst() 179 - 180 - return Number(result.numDeletedRows) 181 - }
+23
packages/server/src/lib/xrpcServer.ts
··· 1 + import { XRPCRouter, json } from '@atcute/xrpc-server' 2 + import { cors } from '@atcute/xrpc-server/middlewares/cors' 3 + import { AppWafrnContentCreatePost } from '../xrpc/index.js' 4 + 5 + const xrpcServer = new XRPCRouter({ middlewares: [cors()] }) 6 + 7 + xrpcServer.add(AppWafrnContentCreatePost.mainSchema, { 8 + async handler({ input }) { 9 + return json({ 10 + post: { 11 + visibility: 'public', 12 + $type: 'app.wafrn.content.post', 13 + content: { 14 + $type: 'app.wafrn.content.post#postEncryptedContent', 15 + encryptedContent: '', 16 + keyVersion: 1 17 + } 18 + } 19 + }) 20 + } 21 + }) 22 + 23 + export { xrpcServer }
+13 -10
packages/server/src/oauth/client.ts packages/client/app/lib/oauthClient.server.ts
··· 1 1 import { JoseKey, NodeOAuthClient } from '@atproto/oauth-client-node' 2 - import type { Db } from '@api/db/db' 3 - import { SessionStore, StateStore } from './storage' 4 - import keys from '@api/../jwks.json' 5 - import env from '@api/lib/env' 2 + import { env } from './env.server' 3 + import keys from '@www/jwks.json' 4 + import { SessionStore, StateStore } from './oauthStore.server' 5 + import pkg from '@www/../package.json' 6 6 7 7 const keyset = await Promise.all( 8 8 keys.map((k) => JoseKey.fromImportable(JSON.stringify(k), k.kid)) 9 9 ) 10 10 11 - export const createOAuthClient = (db: Db, baseUrl?: string) => { 11 + const createOAuthClient = (baseUrl?: string) => { 12 12 const scope = 'atproto transition:generic' as const 13 13 14 14 if (env.IS_PROD && !baseUrl) { ··· 20 20 redirect_uri: `${url}/oauth/callback`, 21 21 scope 22 22 }) 23 + 23 24 return new NodeOAuthClient({ 24 25 clientMetadata: { 25 - client_name: 'ATProto Elysia App', 26 + client_name: pkg.name, 26 27 client_id: baseUrl 27 - ? `${url}/client-metadata.json` 28 + ? `${url}/oauth/client-metadata.json` 28 29 : `http://localhost?${localParams.toString()}`, 29 30 client_uri: url, 30 31 redirect_uris: [`${url}/oauth/callback`], ··· 35 36 token_endpoint_auth_method: 'private_key_jwt', 36 37 token_endpoint_auth_signing_alg: 'ES256', 37 38 dpop_bound_access_tokens: true, 38 - jwks_uri: `${url}/jwks.json` 39 + jwks_uri: `${url}/oauth/jwks.json` 39 40 }, 40 - stateStore: new StateStore(db), 41 - sessionStore: new SessionStore(db), 41 + stateStore: new StateStore(), 42 + sessionStore: new SessionStore(), 42 43 keyset 43 44 }) 44 45 } 46 + 47 + export const oauthClient = createOAuthClient(env.CLIENT_URL)
-145
packages/server/src/oauth/routes.ts
··· 1 - import Elysia, { redirect, status } from 'elysia' 2 - import { createOAuthClient } from './client' 3 - import { Agent } from '@atproto/api' 4 - import { db } from '@api/db/db' 5 - import env from '@api/lib/env' 6 - import keys from '@api/../jwks.json' 7 - import { createOrUpdateAccount } from '@api/lib/account' 8 - import { 9 - createSession, 10 - deleteSession, 11 - type SessionData 12 - } from '@api/lib/session' 13 - import { authMacro } from '@api/lib/middleware' 14 - import { getProfile } from '@api/lib/profile' 15 - import { idResolver } from '@api/lib/idresolver' 16 - import { 17 - isAllowedRedirectUri, 18 - getDefaultRedirectUri, 19 - parseRedirectState 20 - } from '@api/lib/redirect-validator' 21 - 22 - const publicKeys = keys.map((k) => { 23 - const { kty, alg, kid, crv, x, y } = k 24 - return { kty, alg, kid, crv, x, y } 25 - }) 26 - 27 - const oauthClient = createOAuthClient(db, env.BASE_URL) 28 - const oauthRoutes = new Elysia() 29 - .use(authMacro) 30 - .get('/jwks.json', () => publicKeys) 31 - .get('/client-metadata.json', () => oauthClient.clientMetadata) 32 - .get( 33 - '/oauth/me', 34 - async ({ session }) => { 35 - const oauthSession = await oauthClient.restore(session.account.did) 36 - if (!oauthSession) { 37 - return status(401, 'OAuth session not found') 38 - } 39 - 40 - const agent = new Agent(oauthSession) 41 - const identity = await idResolver.did.resolveAtprotoData(oauthSession.did) 42 - const profile = await getProfile(agent, session.account.did) 43 - 44 - return { profile, identity: identity } 45 - }, 46 - { 47 - auth: true 48 - } 49 - ) 50 - .get('/oauth/login', async (c) => { 51 - const handle = c.query.handle 52 - const redirectUri = c.query.redirect_uri 53 - 54 - if (!handle) { 55 - return status(400, 'Missing handle parameter') 56 - } 57 - 58 - // Validate redirect_uri if provided, otherwise use default 59 - const returnTo = redirectUri || getDefaultRedirectUri() 60 - 61 - if (!isAllowedRedirectUri(returnTo)) { 62 - return status( 63 - 400, 64 - 'Invalid redirect_uri. Must be one of: ' + env.ALLOWED_REDIRECT_ORIGINS 65 - ) 66 - } 67 - 68 - // Embed redirect URI in OAuth state 69 - const state = JSON.stringify({ 70 - returnTo, 71 - timestamp: Date.now() 72 - }) 73 - 74 - const url = await oauthClient.authorize(handle, { state }) 75 - 76 - return redirect(url.toString()) 77 - }) 78 - .get('/oauth/callback', async (c) => { 79 - try { 80 - const params = new URLSearchParams(c.query) 81 - const { session, state } = await oauthClient.callback(params) 82 - 83 - const agent = new Agent(session) 84 - const account = await agent.com.atproto.server.getSession() 85 - 86 - // Create or update account record 87 - await createOrUpdateAccount({ 88 - ...account.data, 89 - active: Number(account.data.active) 90 - }) 91 - 92 - // Create app session and set cookie with session UUID 93 - const sessionId = await createSession(session.did) 94 - c.cookie.session.set({ 95 - httpOnly: true, 96 - secure: true, 97 - secrets: env.AUTH_SECRET, 98 - sameSite: 'lax', 99 - value: sessionId, 100 - maxAge: 7 * 24 * 60 * 60 // 7 days in seconds 101 - }) 102 - 103 - // Parse state to get redirect URI 104 - const returnTo = parseRedirectState(state) 105 - 106 - // Redirect back to client with success 107 - return redirect(returnTo) 108 - } catch (error) { 109 - console.error('OAuth callback error:', error) 110 - 111 - // On error, try to redirect back to client with error 112 - const errorRedirect = parseRedirectState(c.query.state) 113 - const url = new URL(errorRedirect) 114 - url.searchParams.set('error', 'auth_failed') 115 - 116 - // Redirect with error query param 117 - return redirect(url.toString()) 118 - } 119 - }) 120 - .post( 121 - '/oauth/logout', 122 - async ({ session, cookie }: { session: SessionData; cookie: any }) => { 123 - try { 124 - // Try to sign out OAuth session (revokes tokens at PDS) 125 - const oauthSession = await oauthClient.restore(session.account.did) 126 - if (oauthSession) { 127 - await oauthSession.signOut() 128 - } 129 - } catch (error) { 130 - // Log but don't fail - app session cleanup is more important 131 - console.warn('OAuth signOut failed:', error) 132 - } 133 - 134 - // Always delete app session and cookie 135 - await deleteSession(session.id) 136 - cookie.session.remove() 137 - 138 - return { success: true } 139 - }, 140 - { 141 - auth: true 142 - } 143 - ) 144 - 145 - export default oauthRoutes
-61
packages/server/src/oauth/storage.ts
··· 1 - import type { 2 - NodeSavedSession, 3 - NodeSavedSessionStore, 4 - NodeSavedState, 5 - NodeSavedStateStore 6 - } from '@atproto/oauth-client-node' 7 - import type { Db } from '@api/db/db' 8 - 9 - export class StateStore implements NodeSavedStateStore { 10 - constructor(private db: Db) {} 11 - 12 - async get(key: string): Promise<NodeSavedState | undefined> { 13 - const result = await this.db 14 - .selectFrom('oauth_states') 15 - .selectAll() 16 - .where('key', '=', key) 17 - .executeTakeFirst() 18 - if (!result) return 19 - return JSON.parse(result.state) as NodeSavedState 20 - } 21 - 22 - async set(key: string, val: NodeSavedState) { 23 - const state = JSON.stringify(val) 24 - await this.db 25 - .insertInto('oauth_states') 26 - .values({ key, state }) 27 - .onConflict((oc) => oc.doUpdateSet({ state })) 28 - .execute() 29 - } 30 - 31 - async del(key: string) { 32 - await this.db.deleteFrom('oauth_states').where('key', '=', key).execute() 33 - } 34 - } 35 - 36 - export class SessionStore implements NodeSavedSessionStore { 37 - constructor(private db: Db) {} 38 - 39 - async get(key: string): Promise<NodeSavedSession | undefined> { 40 - const result = await this.db 41 - .selectFrom('oauth_sessions') 42 - .selectAll() 43 - .where('key', '=', key) 44 - .executeTakeFirst() 45 - if (!result) return 46 - return JSON.parse(result.session) as NodeSavedSession 47 - } 48 - 49 - async set(key: string, val: NodeSavedSession) { 50 - const session = JSON.stringify(val) 51 - await this.db 52 - .insertInto('oauth_sessions') 53 - .values({ key, session }) 54 - .onConflict((oc) => oc.doUpdateSet({ session })) 55 - .execute() 56 - } 57 - 58 - async del(key: string) { 59 - await this.db.deleteFrom('oauth_sessions').where('key', '=', key).execute() 60 - } 61 - }
+2
packages/server/src/xrpc/index.ts
··· 1 + export * as AppWafrnContentCreatePost from "./types/app/wafrn/content/createPost.js"; 2 + export * as AppWafrnContentPost from "./types/app/wafrn/content/post.js";
+57
packages/server/src/xrpc/types/app/wafrn/content/createPost.ts
··· 1 + import type {} from "@atcute/lexicons"; 2 + import * as v from "@atcute/lexicons/validations"; 3 + import type {} from "@atcute/lexicons/ambient"; 4 + import * as AppWafrnContentPost from "./post.js"; 5 + import * as ComAtprotoLabelDefs from "@atcute/atproto/types/label/defs"; 6 + 7 + const _mainSchema = /*#__PURE__*/ v.procedure("app.wafrn.content.createPost", { 8 + params: null, 9 + input: { 10 + type: "lex", 11 + schema: /*#__PURE__*/ v.object({ 12 + contentMarkdown: /*#__PURE__*/ v.string(), 13 + contentWarning: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.string()), 14 + get labels() { 15 + return /*#__PURE__*/ v.optional( 16 + /*#__PURE__*/ v.variant([ComAtprotoLabelDefs.selfLabelsSchema]), 17 + ); 18 + }, 19 + tags: /*#__PURE__*/ v.optional( 20 + /*#__PURE__*/ v.array(/*#__PURE__*/ v.string()), 21 + ), 22 + /** 23 + * @default "public" 24 + */ 25 + visibility: /*#__PURE__*/ v.optional( 26 + /*#__PURE__*/ v.string< 27 + "followers" | "mentioned" | "public" | (string & {}) 28 + >(), 29 + "public", 30 + ), 31 + }), 32 + }, 33 + output: { 34 + type: "lex", 35 + schema: /*#__PURE__*/ v.object({ 36 + get post() { 37 + return AppWafrnContentPost.mainSchema; 38 + }, 39 + }), 40 + }, 41 + }); 42 + 43 + type main$schematype = typeof _mainSchema; 44 + 45 + export interface mainSchema extends main$schematype {} 46 + 47 + export const mainSchema = _mainSchema as mainSchema; 48 + 49 + export interface $params {} 50 + export interface $input extends v.InferXRPCBodyInput<mainSchema["input"]> {} 51 + export interface $output extends v.InferXRPCBodyInput<mainSchema["output"]> {} 52 + 53 + declare module "@atcute/lexicons/ambient" { 54 + interface XRPCProcedures { 55 + "app.wafrn.content.createPost": mainSchema; 56 + } 57 + }
+79
packages/server/src/xrpc/types/app/wafrn/content/post.ts
··· 1 + import type {} from "@atcute/lexicons"; 2 + import * as v from "@atcute/lexicons/validations"; 3 + import type {} from "@atcute/lexicons/ambient"; 4 + import * as ComAtprotoLabelDefs from "@atcute/atproto/types/label/defs"; 5 + 6 + const _mainSchema = /*#__PURE__*/ v.record( 7 + /*#__PURE__*/ v.tidString(), 8 + /*#__PURE__*/ v.object({ 9 + $type: /*#__PURE__*/ v.literal("app.wafrn.content.post"), 10 + get content() { 11 + return /*#__PURE__*/ v.variant([ 12 + postContentSchema, 13 + postEncryptedContentSchema, 14 + ]); 15 + }, 16 + /** 17 + * @default "public" 18 + */ 19 + visibility: /*#__PURE__*/ v.optional( 20 + /*#__PURE__*/ v.string< 21 + "followers" | "mentioned" | "public" | (string & {}) 22 + >(), 23 + "public", 24 + ), 25 + }), 26 + ); 27 + const _postContentSchema = /*#__PURE__*/ v.object({ 28 + $type: /*#__PURE__*/ v.optional( 29 + /*#__PURE__*/ v.literal("app.wafrn.content.post#postContent"), 30 + ), 31 + contentHTML: /*#__PURE__*/ v.string(), 32 + contentMarkdown: /*#__PURE__*/ v.string(), 33 + contentWarning: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.string()), 34 + createdAt: /*#__PURE__*/ v.datetimeString(), 35 + get labels() { 36 + return /*#__PURE__*/ v.optional( 37 + /*#__PURE__*/ v.variant([ComAtprotoLabelDefs.selfLabelsSchema]), 38 + ); 39 + }, 40 + tags: /*#__PURE__*/ v.optional( 41 + /*#__PURE__*/ v.array(/*#__PURE__*/ v.string()), 42 + ), 43 + updatedAt: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.datetimeString()), 44 + }); 45 + const _postEncryptedContentSchema = /*#__PURE__*/ v.object({ 46 + $type: /*#__PURE__*/ v.optional( 47 + /*#__PURE__*/ v.literal("app.wafrn.content.post#postEncryptedContent"), 48 + ), 49 + encryptedContent: /*#__PURE__*/ v.string(), 50 + /** 51 + * @minimum 0 52 + */ 53 + keyVersion: /*#__PURE__*/ v.integer(), 54 + }); 55 + 56 + type main$schematype = typeof _mainSchema; 57 + type postContent$schematype = typeof _postContentSchema; 58 + type postEncryptedContent$schematype = typeof _postEncryptedContentSchema; 59 + 60 + export interface mainSchema extends main$schematype {} 61 + export interface postContentSchema extends postContent$schematype {} 62 + export interface postEncryptedContentSchema 63 + extends postEncryptedContent$schematype {} 64 + 65 + export const mainSchema = _mainSchema as mainSchema; 66 + export const postContentSchema = _postContentSchema as postContentSchema; 67 + export const postEncryptedContentSchema = 68 + _postEncryptedContentSchema as postEncryptedContentSchema; 69 + 70 + export interface Main extends v.InferInput<typeof mainSchema> {} 71 + export interface PostContent extends v.InferInput<typeof postContentSchema> {} 72 + export interface PostEncryptedContent 73 + extends v.InferInput<typeof postEncryptedContentSchema> {} 74 + 75 + declare module "@atcute/lexicons/ambient" { 76 + interface Records { 77 + "app.wafrn.content.post": mainSchema; 78 + } 79 + }
+1 -1
packages/server/tsconfig.json
··· 7 7 8 8 /* Modules */ 9 9 "module": "ES2022", 10 - "moduleResolution": "node", 10 + "moduleResolution": "bundler", 11 11 "types": ["@types/bun"], 12 12 "resolveJsonModule": true, 13 13