diff --git a/package-lock.json b/package-lock.json index e10f30f..be436e0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,9 @@ "name": "p2pds", "version": "0.1.0", "license": "MIT", + "workspaces": [ + "apps/*" + ], "dependencies": { "@atcute/atproto": "^3.1.10", "@atcute/bluesky": "^3.2.14", @@ -18,10 +21,12 @@ "@atcute/identity-resolver": "^1.2.2", "@atcute/lexicons": "^1.2.6", "@atcute/tid": "^1.1.1", + "@atproto/api": "^0.18.21", "@atproto/crypto": "^0.4.5", "@atproto/lex-cbor": "^0.0.3", "@atproto/lex-data": "^0.0.3", "@atproto/lex-json": "^0.0.11", + "@atproto/oauth-client-node": "^0.3.16", "@atproto/repo": "^0.8.12", "@hono/node-server": "^1.13.8", "@libp2p/gossipsub": "^15.0.12", @@ -44,6 +49,15 @@ "vitest": "^3.0.0" } }, + "apps/desktop": { + "name": "p2pds-desktop", + "version": "0.1.0", + "devDependencies": { + "@tauri-apps/api": "^2.0.0", + "@tauri-apps/cli": "^2.0.0", + "@tauri-apps/plugin-shell": "^2.0.0" + } + }, "node_modules/@achingbrain/http-parser-js": { "version": "0.5.9", "resolved": "https://registry.npmjs.org/@achingbrain/http-parser-js/-/http-parser-js-0.5.9.tgz", @@ -236,6 +250,142 @@ "unicode-segmenter": "^0.14.5" } }, + "node_modules/@atproto-labs/did-resolver": { + "version": "0.2.6", + "resolved": "https://registry.npmjs.org/@atproto-labs/did-resolver/-/did-resolver-0.2.6.tgz", + "integrity": "sha512-2K1bC04nI2fmgNcvof+yA28IhGlpWn2JKYlPa7To9JTKI45FINCGkQSGiL2nyXlyzDJJ34fZ1aq6/IRFIOIiqg==", + "license": "MIT", + "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.3.0", + "zod": "^3.23.8" + } + }, + "node_modules/@atproto-labs/fetch": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/@atproto-labs/fetch/-/fetch-0.2.3.tgz", + "integrity": "sha512-NZtbJOCbxKUFRFKMpamT38PUQMY0hX0p7TG5AEYOPhZKZEP7dHZ1K2s1aB8MdVH0qxmqX7nQleNrrvLf09Zfdw==", + "license": "MIT", + "dependencies": { + "@atproto-labs/pipe": "0.1.1" + } + }, + "node_modules/@atproto-labs/fetch-node": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/@atproto-labs/fetch-node/-/fetch-node-0.2.0.tgz", + "integrity": "sha512-Krq09nH/aeoiU2s9xdHA0FjTEFWG9B5FFenipv1iRixCcPc7V3DhTNDawxG9gI8Ny0k4dBVS9WTRN/IDzBx86Q==", + "license": "MIT", + "dependencies": { + "@atproto-labs/fetch": "0.2.3", + "@atproto-labs/pipe": "0.1.1", + "ipaddr.js": "^2.1.0", + "undici": "^6.14.1" + }, + "engines": { + "node": ">=18.7.0" + } + }, + "node_modules/@atproto-labs/fetch-node/node_modules/undici": { + "version": "6.23.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-6.23.0.tgz", + "integrity": "sha512-VfQPToRA5FZs/qJxLIinmU59u0r7LXqoJkCzinq3ckNJp3vKEh7jTWN589YQ5+aoAC/TGRLyJLCPKcLQbM8r9g==", + "license": "MIT", + "engines": { + "node": ">=18.17" + } + }, + "node_modules/@atproto-labs/handle-resolver": { + "version": "0.3.6", + "resolved": "https://registry.npmjs.org/@atproto-labs/handle-resolver/-/handle-resolver-0.3.6.tgz", + "integrity": "sha512-qnSTXvOBNj1EHhp2qTWSX8MS5q3AwYU5LKlt5fBvSbCjgmTr2j0URHCv+ydrwO55KvsojIkTMgeMOh4YuY4fCA==", + "license": "MIT", + "dependencies": { + "@atproto-labs/simple-store": "0.3.0", + "@atproto-labs/simple-store-memory": "0.1.4", + "@atproto/did": "0.3.0", + "zod": "^3.23.8" + } + }, + "node_modules/@atproto-labs/handle-resolver-node": { + "version": "0.1.25", + "resolved": "https://registry.npmjs.org/@atproto-labs/handle-resolver-node/-/handle-resolver-node-0.1.25.tgz", + "integrity": "sha512-NY9WYM2VLd3IuMGRkkmvGBg8xqVEaK/fitv1vD8SMXqFTekdpjOLCCyv7EFtqVHouzmDcL83VOvWRfHVa8V9Yw==", + "license": "MIT", + "dependencies": { + "@atproto-labs/fetch-node": "0.2.0", + "@atproto-labs/handle-resolver": "0.3.6", + "@atproto/did": "0.3.0" + }, + "engines": { + "node": ">=18.7.0" + } + }, + "node_modules/@atproto-labs/identity-resolver": { + "version": "0.3.6", + "resolved": "https://registry.npmjs.org/@atproto-labs/identity-resolver/-/identity-resolver-0.3.6.tgz", + "integrity": "sha512-qoWqBDRobln0NR8L8dQjSp79E0chGkBhibEgxQa2f9WD+JbJdjQ0YvwwO5yeQn05pJoJmAwmI2wyJ45zjU7aWg==", + "license": "MIT", + "dependencies": { + "@atproto-labs/did-resolver": "0.2.6", + "@atproto-labs/handle-resolver": "0.3.6" + } + }, + "node_modules/@atproto-labs/pipe": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/@atproto-labs/pipe/-/pipe-0.1.1.tgz", + "integrity": "sha512-hdNw2oUs2B6BN1lp+32pF7cp8EMKuIN5Qok2Vvv/aOpG/3tNSJ9YkvfI0k6Zd188LeDDYRUpYpxcoFIcGH/FNg==", + "license": "MIT" + }, + "node_modules/@atproto-labs/simple-store": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@atproto-labs/simple-store/-/simple-store-0.3.0.tgz", + "integrity": "sha512-nOb6ONKBRJHRlukW1sVawUkBqReLlLx6hT35VS3imaNPwiXDxLnTK7lxw3Lrl9k5yugSBDQAkZAq3MPTEFSUBQ==", + "license": "MIT" + }, + "node_modules/@atproto-labs/simple-store-memory": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/@atproto-labs/simple-store-memory/-/simple-store-memory-0.1.4.tgz", + "integrity": "sha512-3mKY4dP8I7yKPFj9VKpYyCRzGJOi5CEpOLPlRhoJyLmgs3J4RzDrjn323Oakjz2Aj2JzRU/AIvWRAZVhpYNJHw==", + "license": "MIT", + "dependencies": { + "@atproto-labs/simple-store": "0.3.0", + "lru-cache": "^10.2.0" + } + }, + "node_modules/@atproto-labs/simple-store-memory/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "license": "ISC" + }, + "node_modules/@atproto/api": { + "version": "0.18.21", + "resolved": "https://registry.npmjs.org/@atproto/api/-/api-0.18.21.tgz", + "integrity": "sha512-s35MIJerGT/pKe2xJtKKswqlIr/ola2r2iURBKBL0Mk1OKe6jP4YvTMh1N2d2PEANFzNNTbKoDaLfJPo2Uvc/w==", + "license": "MIT", + "dependencies": { + "@atproto/common-web": "^0.4.16", + "@atproto/lexicon": "^0.6.1", + "@atproto/syntax": "^0.4.3", + "@atproto/xrpc": "^0.7.7", + "await-lock": "^2.2.2", + "multiformats": "^9.9.0", + "tlds": "^1.234.0", + "zod": "^3.23.8" + } + }, + "node_modules/@atproto/api/node_modules/@atproto/syntax": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@atproto/syntax/-/syntax-0.4.3.tgz", + "integrity": "sha512-YoZUz40YAJr5nPwvCDWgodEOlt5IftZqPJvA0JDWjuZKD8yXddTwSzXSaKQAzGOpuM+/A3uXRtPzJJqlScc+iA==", + "license": "MIT", + "dependencies": { + "tslib": "^2.8.1" + } + }, "node_modules/@atproto/common": { "version": "0.5.11", "resolved": "https://registry.npmjs.org/@atproto/common/-/common-0.5.11.tgz", @@ -322,6 +472,55 @@ "node": ">=18.7.0" } }, + "node_modules/@atproto/did": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@atproto/did/-/did-0.3.0.tgz", + "integrity": "sha512-raUPzUGegtW/6OxwCmM8bhZvuIMzxG5t9oWsth6Tp91Kb5fTnHV2h/KKNF1C82doeA4BdXCErTyg7ISwLbQkzA==", + "license": "MIT", + "dependencies": { + "zod": "^3.23.8" + } + }, + "node_modules/@atproto/jwk": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/@atproto/jwk/-/jwk-0.6.0.tgz", + "integrity": "sha512-bDoJPvt7TrQVi/rBfBrSSpGykhtIriKxeYCYQTiPRKFfyRhbgpElF0wPXADjIswnbzZdOwbY63az4E/CFVT3Tw==", + "license": "MIT", + "dependencies": { + "multiformats": "^9.9.0", + "zod": "^3.23.8" + } + }, + "node_modules/@atproto/jwk-jose": { + "version": "0.1.11", + "resolved": "https://registry.npmjs.org/@atproto/jwk-jose/-/jwk-jose-0.1.11.tgz", + "integrity": "sha512-i4Fnr2sTBYmMmHXl7NJh8GrCH+tDQEVWrcDMDnV5DjJfkgT17wIqvojIw9SNbSL4Uf0OtfEv6AgG0A+mgh8b5Q==", + "license": "MIT", + "dependencies": { + "@atproto/jwk": "0.6.0", + "jose": "^5.2.0" + } + }, + "node_modules/@atproto/jwk-jose/node_modules/jose": { + "version": "5.10.0", + "resolved": "https://registry.npmjs.org/jose/-/jose-5.10.0.tgz", + "integrity": "sha512-s+3Al/p9g32Iq+oqXxkW//7jk2Vig6FF1CFqzVXoTUXt2qz89YWbL+OwS17NFYEvxC35n0FKeGO2LGYSxeM2Gg==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/@atproto/jwk-webcrypto": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/@atproto/jwk-webcrypto/-/jwk-webcrypto-0.2.0.tgz", + "integrity": "sha512-UmgRrrEAkWvxwhlwe30UmDOdTEFidlIzBC7C3cCbeJMcBN1x8B3KH+crXrsTqfWQBG58mXgt8wgSK3Kxs2LhFg==", + "license": "MIT", + "dependencies": { + "@atproto/jwk": "0.6.0", + "@atproto/jwk-jose": "0.1.11", + "zod": "^3.23.8" + } + }, "node_modules/@atproto/lex-cbor": { "version": "0.0.3", "resolved": "https://registry.npmjs.org/@atproto/lex-cbor/-/lex-cbor-0.0.3.tgz", @@ -390,6 +589,58 @@ "tslib": "^2.8.1" } }, + "node_modules/@atproto/oauth-client": { + "version": "0.5.14", + "resolved": "https://registry.npmjs.org/@atproto/oauth-client/-/oauth-client-0.5.14.tgz", + "integrity": "sha512-sPH+vcdq9maTEAhJI0HzmFcFAMrkCS19np+RUssNkX6kS8Xr3OYr57tvYRCbkcnIyYTfYcxKQgpwHKx3RVEaYw==", + "license": "MIT", + "dependencies": { + "@atproto-labs/did-resolver": "0.2.6", + "@atproto-labs/fetch": "0.2.3", + "@atproto-labs/handle-resolver": "0.3.6", + "@atproto-labs/identity-resolver": "0.3.6", + "@atproto-labs/simple-store": "0.3.0", + "@atproto-labs/simple-store-memory": "0.1.4", + "@atproto/did": "0.3.0", + "@atproto/jwk": "0.6.0", + "@atproto/oauth-types": "0.6.2", + "@atproto/xrpc": "0.7.7", + "core-js": "^3", + "multiformats": "^9.9.0", + "zod": "^3.23.8" + } + }, + "node_modules/@atproto/oauth-client-node": { + "version": "0.3.16", + "resolved": "https://registry.npmjs.org/@atproto/oauth-client-node/-/oauth-client-node-0.3.16.tgz", + "integrity": "sha512-2dooMzxAkiQ4MkOAZlEQ3iwbB9SEovrbIKMNuBbVCLQYORVNxe20tMdjs3lvhrzdpzvaHLlQnJJhw5dA9VELFw==", + "license": "MIT", + "dependencies": { + "@atproto-labs/did-resolver": "0.2.6", + "@atproto-labs/handle-resolver-node": "0.1.25", + "@atproto-labs/simple-store": "0.3.0", + "@atproto/did": "0.3.0", + "@atproto/jwk": "0.6.0", + "@atproto/jwk-jose": "0.1.11", + "@atproto/jwk-webcrypto": "0.2.0", + "@atproto/oauth-client": "0.5.14", + "@atproto/oauth-types": "0.6.2" + }, + "engines": { + "node": ">=18.7.0" + } + }, + "node_modules/@atproto/oauth-types": { + "version": "0.6.2", + "resolved": "https://registry.npmjs.org/@atproto/oauth-types/-/oauth-types-0.6.2.tgz", + "integrity": "sha512-2cuboM4RQBCYR8NQC5uGRkW6KgCgKyq/B5/+tnMmWZYtZGVUQvsUWQHK/ZiMCnVXbcDNtc/RIEJQJDZ8FXMoxg==", + "license": "MIT", + "dependencies": { + "@atproto/did": "0.3.0", + "@atproto/jwk": "0.6.0", + "zod": "^3.23.8" + } + }, "node_modules/@atproto/repo": { "version": "0.8.12", "resolved": "https://registry.npmjs.org/@atproto/repo/-/repo-0.8.12.tgz", @@ -416,6 +667,16 @@ "integrity": "sha512-X9XSRPinBy/0VQ677j8VXlBsYSsUXaiqxWVpGGxJYsAhugdQRb0jqaVKJFtm6RskeNkV6y9xclSUi9UYG/COrA==", "license": "MIT" }, + "node_modules/@atproto/xrpc": { + "version": "0.7.7", + "resolved": "https://registry.npmjs.org/@atproto/xrpc/-/xrpc-0.7.7.tgz", + "integrity": "sha512-K1ZyO/BU8JNtXX5dmPp7b5UrkLMMqpsIa/Lrj5D3Su+j1Xwq1m6QJ2XJ1AgjEjkI1v4Muzm7klianLE6XGxtmA==", + "license": "MIT", + "dependencies": { + "@atproto/lexicon": "^0.6.0", + "zod": "^3.23.8" + } + }, "node_modules/@babel/code-frame": { "version": "7.29.0", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", @@ -4015,6 +4276,244 @@ "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", "license": "MIT" }, + "node_modules/@tauri-apps/api": { + "version": "2.10.1", + "resolved": "https://registry.npmjs.org/@tauri-apps/api/-/api-2.10.1.tgz", + "integrity": "sha512-hKL/jWf293UDSUN09rR69hrToyIXBb8CjGaWC7gfinvnQrBVvnLr08FeFi38gxtugAVyVcTa5/FD/Xnkb1siBw==", + "dev": true, + "license": "Apache-2.0 OR MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/tauri" + } + }, + "node_modules/@tauri-apps/cli": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli/-/cli-2.10.0.tgz", + "integrity": "sha512-ZwT0T+7bw4+DPCSWzmviwq5XbXlM0cNoleDKOYPFYqcZqeKY31KlpoMW/MOON/tOFBPgi31a2v3w9gliqwL2+Q==", + "dev": true, + "license": "Apache-2.0 OR MIT", + "bin": { + "tauri": "tauri.js" + }, + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/tauri" + }, + "optionalDependencies": { + "@tauri-apps/cli-darwin-arm64": "2.10.0", + "@tauri-apps/cli-darwin-x64": "2.10.0", + "@tauri-apps/cli-linux-arm-gnueabihf": "2.10.0", + "@tauri-apps/cli-linux-arm64-gnu": "2.10.0", + "@tauri-apps/cli-linux-arm64-musl": "2.10.0", + "@tauri-apps/cli-linux-riscv64-gnu": "2.10.0", + "@tauri-apps/cli-linux-x64-gnu": "2.10.0", + "@tauri-apps/cli-linux-x64-musl": "2.10.0", + "@tauri-apps/cli-win32-arm64-msvc": "2.10.0", + "@tauri-apps/cli-win32-ia32-msvc": "2.10.0", + "@tauri-apps/cli-win32-x64-msvc": "2.10.0" + } + }, + "node_modules/@tauri-apps/cli-darwin-arm64": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-arm64/-/cli-darwin-arm64-2.10.0.tgz", + "integrity": "sha512-avqHD4HRjrMamE/7R/kzJPcAJnZs0IIS+1nkDP5b+TNBn3py7N2aIo9LIpy+VQq0AkN8G5dDpZtOOBkmWt/zjA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-darwin-x64": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-x64/-/cli-darwin-x64-2.10.0.tgz", + "integrity": "sha512-keDmlvJRStzVFjZTd0xYkBONLtgBC9eMTpmXnBXzsHuawV2q9PvDo2x6D5mhuoMVrJ9QWjgaPKBBCFks4dK71Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-linux-arm-gnueabihf": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm-gnueabihf/-/cli-linux-arm-gnueabihf-2.10.0.tgz", + "integrity": "sha512-e5u0VfLZsMAC9iHaOEANumgl6lfnJx0Dtjkd8IJpysZ8jp0tJ6wrIkto2OzQgzcYyRCKgX72aKE0PFgZputA8g==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-linux-arm64-gnu": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-gnu/-/cli-linux-arm64-gnu-2.10.0.tgz", + "integrity": "sha512-YrYYk2dfmBs5m+OIMCrb+JH/oo+4FtlpcrTCgiFYc7vcs6m3QDd1TTyWu0u01ewsCtK2kOdluhr/zKku+KP7HA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-linux-arm64-musl": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.10.0.tgz", + "integrity": "sha512-GUoPdVJmrJRIXFfW3Rkt+eGK9ygOdyISACZfC/bCSfOnGt8kNdQIQr5WRH9QUaTVFIwxMlQyV3m+yXYP+xhSVA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-linux-riscv64-gnu": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-riscv64-gnu/-/cli-linux-riscv64-gnu-2.10.0.tgz", + "integrity": "sha512-JO7s3TlSxshwsoKNCDkyvsx5gw2QAs/Y2GbR5UE2d5kkU138ATKoPOtxn8G1fFT1aDW4LH0rYAAfBpGkDyJJnw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-linux-x64-gnu": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-gnu/-/cli-linux-x64-gnu-2.10.0.tgz", + "integrity": "sha512-Uvh4SUUp4A6DVRSMWjelww0GnZI3PlVy7VS+DRF5napKuIehVjGl9XD0uKoCoxwAQBLctvipyEK+pDXpJeoHng==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-linux-x64-musl": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-musl/-/cli-linux-x64-musl-2.10.0.tgz", + "integrity": "sha512-AP0KRK6bJuTpQ8kMNWvhIpKUkQJfcPFeba7QshOQZjJ8wOS6emwTN4K5g/d3AbCMo0RRdnZWwu67MlmtJyxC1Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-win32-arm64-msvc": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-arm64-msvc/-/cli-win32-arm64-msvc-2.10.0.tgz", + "integrity": "sha512-97DXVU3dJystrq7W41IX+82JEorLNY+3+ECYxvXWqkq7DBN6FsA08x/EFGE8N/b0LTOui9X2dvpGGoeZKKV08g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-win32-ia32-msvc": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-ia32-msvc/-/cli-win32-ia32-msvc-2.10.0.tgz", + "integrity": "sha512-EHyQ1iwrWy1CwMalEm9z2a6L5isQ121pe7FcA2xe4VWMJp+GHSDDGvbTv/OPdkt2Lyr7DAZBpZHM6nvlHXEc4A==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-win32-x64-msvc": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-x64-msvc/-/cli-win32-x64-msvc-2.10.0.tgz", + "integrity": "sha512-NTpyQxkpzGmU6ceWBTY2xRIEaS0ZLbVx1HE1zTA3TY/pV3+cPoPPOs+7YScr4IMzXMtOw7tLw5LEXo5oIG3qaQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/plugin-shell": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-shell/-/plugin-shell-2.3.5.tgz", + "integrity": "sha512-jewtULhiQ7lI7+owCKAjc8tYLJr92U16bPOeAa472LHJdgaibLP83NcfAF2e+wkEcA53FxKQAZ7byDzs2eeizg==", + "dev": true, + "license": "MIT OR Apache-2.0", + "dependencies": { + "@tauri-apps/api": "^2.10.1" + } + }, "node_modules/@types/babel__core": { "version": "7.20.5", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", @@ -4528,6 +5027,12 @@ "node": ">=8.0.0" } }, + "node_modules/await-lock": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/await-lock/-/await-lock-2.2.2.tgz", + "integrity": "sha512-aDczADvlvTGajTDjcjpJMqRkOF6Qdz3YbPZm/PyW6tKPkx2hlYBzxMhEywM/tU72HrVZjgl5VCdRuMlA7pZ8Gw==", + "license": "MIT" + }, "node_modules/axios": { "version": "1.13.5", "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.5.tgz", @@ -5226,6 +5731,17 @@ "url": "https://opencollective.com/express" } }, + "node_modules/core-js": { + "version": "3.48.0", + "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.48.0.tgz", + "integrity": "sha512-zpEHTy1fjTMZCKLHUZoVeylt9XrzaIN2rbPXEt0k+q7JE5CkCZdo6bNq55bn24a69CH7ErAVLKijxJja4fw+UQ==", + "hasInstallScript": true, + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/core-js" + } + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -6407,6 +6923,15 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/ipaddr.js": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.3.0.tgz", + "integrity": "sha512-Zv/pA+ciVFbCSBBjGfaKUya/CcGmUHzTydLMaTwrUUEM2DIEO3iZvueGxmacvmN50fGpGVKeTXpb2LcYQxeVdg==", + "license": "MIT", + "engines": { + "node": ">= 10" + } + }, "node_modules/ipns": { "version": "10.1.3", "resolved": "https://registry.npmjs.org/ipns/-/ipns-10.1.3.tgz", @@ -8115,6 +8640,10 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/p2pds-desktop": { + "resolved": "apps/desktop", + "link": true + }, "node_modules/parseurl": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", @@ -9626,6 +10155,15 @@ "node": ">=14.0.0" } }, + "node_modules/tlds": { + "version": "1.261.0", + "resolved": "https://registry.npmjs.org/tlds/-/tlds-1.261.0.tgz", + "integrity": "sha512-QXqwfEl9ddlGBaRFXIvNKK6OhipSiLXuRuLJX5DErz0o0Q0rYxulWLdFryTkV5PkdZct5iMInwYEGe/eR++1AA==", + "license": "MIT", + "bin": { + "tlds": "bin.js" + } + }, "node_modules/tmpl": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.5.tgz", diff --git a/package.json b/package.json index b035489..3cf5aa2 100644 --- a/package.json +++ b/package.json @@ -20,10 +20,12 @@ "@atcute/identity-resolver": "^1.2.2", "@atcute/lexicons": "^1.2.6", "@atcute/tid": "^1.1.1", + "@atproto/api": "^0.18.21", "@atproto/crypto": "^0.4.5", "@atproto/lex-cbor": "^0.0.3", "@atproto/lex-data": "^0.0.3", "@atproto/lex-json": "^0.0.11", + "@atproto/oauth-client-node": "^0.3.16", "@atproto/repo": "^0.8.12", "@hono/node-server": "^1.13.8", "@libp2p/gossipsub": "^15.0.12", @@ -46,5 +48,7 @@ "vitest": "^3.0.0" }, "license": "MIT", - "workspaces": ["apps/*"] + "workspaces": [ + "apps/*" + ] } diff --git a/src/config.ts b/src/config.ts index 6f59dbb..9bc0148 100644 --- a/src/config.ts +++ b/src/config.ts @@ -34,6 +34,8 @@ export interface Config { RATE_LIMIT_CHALLENGE_PER_MIN: number; RATE_LIMIT_MAX_CONNECTIONS: number; RATE_LIMIT_FIREHOSE_PER_IP: number; + /** Whether OAuth login is enabled for remote PDS publishing (default false). */ + OAUTH_ENABLED: boolean; } const REQUIRED_KEYS = [ @@ -128,6 +130,7 @@ export function loadConfig(envPath?: string): Config { RATE_LIMIT_CHALLENGE_PER_MIN: parseInt(process.env.RATE_LIMIT_CHALLENGE_PER_MIN ?? "20", 10), RATE_LIMIT_MAX_CONNECTIONS: parseInt(process.env.RATE_LIMIT_MAX_CONNECTIONS ?? "100", 10), RATE_LIMIT_FIREHOSE_PER_IP: parseInt(process.env.RATE_LIMIT_FIREHOSE_PER_IP ?? "3", 10), + OAUTH_ENABLED: process.env.OAUTH_ENABLED === "true", }; } diff --git a/src/index.ts b/src/index.ts index 05e2504..99d5ba2 100644 --- a/src/index.ts +++ b/src/index.ts @@ -25,6 +25,9 @@ import { serializeResponse } from "./replication/challenge-response/http-transpo import type { StorageChallenge } from "./replication/challenge-response/types.js"; import { MAX_RECORD_PATHS, MAX_BLOCK_CIDS } from "./replication/challenge-response/types.js"; import { generateMstProof } from "./replication/mst-proof.js"; +import { registerOAuthRoutes } from "./oauth/routes.js"; +import type { OAuthClientManager } from "./oauth/client.js"; +import type { PdsClient } from "./oauth/pds-client.js"; const VERSION = "0.1.0"; @@ -42,6 +45,8 @@ export function createApp( replicatedRepoReader?: ReplicatedRepoReader, repoManager?: RepoManager, rateLimiter?: RateLimiter, + oauthClientManager?: OAuthClientManager, + pdsClient?: PdsClient, ) { const configDid = config.DID ?? ""; @@ -66,6 +71,13 @@ export function createApp( }), ); + // ============================================ + // OAuth routes (before auth middleware — these handle browser redirect flow) + // ============================================ + if (oauthClientManager && pdsClient) { + registerOAuthRoutes(app, config, oauthClientManager.client, pdsClient, networkService); + } + // ============================================ // Rate limit + body size middleware (per route group) // ============================================ diff --git a/src/ipfs.test.ts b/src/ipfs.test.ts index 2f05e83..fadcc52 100644 --- a/src/ipfs.test.ts +++ b/src/ipfs.test.ts @@ -51,6 +51,7 @@ function testConfig(dataDir: string): Config { RATE_LIMIT_CHALLENGE_PER_MIN: 20, RATE_LIMIT_MAX_CONNECTIONS: 100, RATE_LIMIT_FIREHOSE_PER_IP: 3, + OAUTH_ENABLED: false, }; } diff --git a/src/oauth/client.ts b/src/oauth/client.ts new file mode 100644 index 0000000..f9f5938 --- /dev/null +++ b/src/oauth/client.ts @@ -0,0 +1,54 @@ +/** + * OAuth client setup for AT Protocol authentication. + * + * Uses loopback client_id format per the AT Protocol OAuth spec: + * authorization servers provide virtual metadata for http://localhost clients. + */ + +import { NodeOAuthClient } from "@atproto/oauth-client-node"; +import type Database from "better-sqlite3"; +import type { Config } from "../config.js"; +import { OAuthStateStore, OAuthSessionStore } from "./stores.js"; + +export interface OAuthClientManager { + client: NodeOAuthClient; + stateStore: OAuthStateStore; + sessionStore: OAuthSessionStore; +} + +export async function createOAuthClient( + db: Database.Database, + config: Config, +): Promise { + const stateStore = new OAuthStateStore(db); + const sessionStore = new OAuthSessionStore(db); + + const redirectUri = `http://127.0.0.1:${config.PORT}/oauth/callback`; + const scope = "atproto transition:generic"; + + // Loopback client_id: http://localhost with redirect_uri and scope as query params. + // AT Protocol authorization servers provide virtual metadata for this format. + const clientId = + `http://localhost?redirect_uri=${encodeURIComponent(redirectUri)}&scope=${encodeURIComponent(scope)}`; + + const client = new NodeOAuthClient({ + clientMetadata: { + client_id: clientId, + client_name: "p2pds", + client_uri: `http://127.0.0.1:${config.PORT}` as `http://127.0.0.1:${string}`, + redirect_uris: [redirectUri as `http://127.0.0.1:${string}`], + scope, + grant_types: ["authorization_code", "refresh_token"], + response_types: ["code"], + token_endpoint_auth_method: "none", + application_type: "native", + dpop_bound_access_tokens: true, + }, + stateStore, + sessionStore, + // Allow HTTP for loopback development + allowHttp: true, + }); + + return { client, stateStore, sessionStore }; +} diff --git a/src/oauth/pds-client.test.ts b/src/oauth/pds-client.test.ts new file mode 100644 index 0000000..af8ccc5 --- /dev/null +++ b/src/oauth/pds-client.test.ts @@ -0,0 +1,74 @@ +import { describe, it, expect, vi } from "vitest"; +import { PdsClient } from "./pds-client.js"; +import type { NodeOAuthClient } from "@atproto/oauth-client-node"; + +function createMockOAuthClient(options?: { + restoreFails?: boolean; +}): { oauthClient: NodeOAuthClient; restoreSpy: ReturnType } { + const mockSession = { + did: "did:plc:testuser", + fetchHandler: vi.fn().mockResolvedValue( + new Response("{}", { status: 200, headers: { "content-type": "application/json" } }), + ), + }; + + const restoreSpy = options?.restoreFails + ? vi.fn().mockRejectedValue(new Error("No session")) + : vi.fn().mockResolvedValue(mockSession); + + const oauthClient = { restore: restoreSpy } as unknown as NodeOAuthClient; + return { oauthClient, restoreSpy }; +} + +describe("PdsClient", () => { + it("hasSession returns true when restore succeeds", async () => { + const { oauthClient } = createMockOAuthClient(); + const client = new PdsClient(oauthClient, "did:plc:testuser"); + expect(await client.hasSession()).toBe(true); + }); + + it("hasSession returns false when restore fails", async () => { + const { oauthClient } = createMockOAuthClient({ restoreFails: true }); + const client = new PdsClient(oauthClient, "did:plc:testuser"); + expect(await client.hasSession()).toBe(false); + }); + + it("getAgent restores session and returns Agent", async () => { + const { oauthClient, restoreSpy } = createMockOAuthClient(); + const client = new PdsClient(oauthClient, "did:plc:testuser"); + + const agent = await client.getAgent(); + expect(agent).toBeDefined(); + expect(agent.did).toBe("did:plc:testuser"); + expect(restoreSpy).toHaveBeenCalledWith("did:plc:testuser"); + }); + + it("getAgent caches agent across calls", async () => { + const { oauthClient, restoreSpy } = createMockOAuthClient(); + const client = new PdsClient(oauthClient, "did:plc:testuser"); + + const agent1 = await client.getAgent(); + const agent2 = await client.getAgent(); + expect(agent1).toBe(agent2); // same instance + expect(restoreSpy).toHaveBeenCalledTimes(1); + }); + + it("clearAgent forces re-restore on next getAgent call", async () => { + const { oauthClient, restoreSpy } = createMockOAuthClient(); + const client = new PdsClient(oauthClient, "did:plc:testuser"); + + await client.getAgent(); + expect(restoreSpy).toHaveBeenCalledTimes(1); + + client.clearAgent(); + + await client.getAgent(); + expect(restoreSpy).toHaveBeenCalledTimes(2); + }); + + it("getAgent throws when no session", async () => { + const { oauthClient } = createMockOAuthClient({ restoreFails: true }); + const client = new PdsClient(oauthClient, "did:plc:testuser"); + await expect(client.getAgent()).rejects.toThrow("No session"); + }); +}); diff --git a/src/oauth/pds-client.ts b/src/oauth/pds-client.ts new file mode 100644 index 0000000..cf7c5d8 --- /dev/null +++ b/src/oauth/pds-client.ts @@ -0,0 +1,100 @@ +/** + * Remote PDS record operations via authenticated OAuth session. + * + * PdsClient wraps @atproto/api Agent to provide the same record + * operations that RepoManager exposes, making it a drop-in + * replacement for remote publishing (offers, peer records). + */ + +import { Agent } from "@atproto/api"; +import type { NodeOAuthClient } from "@atproto/oauth-client-node"; +import type { RecordWriter } from "../replication/offer-manager.js"; + +export class PdsClient implements RecordWriter { + private agent: Agent | null = null; + + constructor( + private oauthClient: NodeOAuthClient, + private did: string, + ) {} + + /** + * Get or restore an authenticated Agent. + * The OAuth library handles token refresh automatically. + */ + async getAgent(): Promise { + if (this.agent) return this.agent; + + const session = await this.oauthClient.restore(this.did); + this.agent = new Agent(session); + return this.agent; + } + + /** + * Check if we have a valid session (can be restored). + */ + async hasSession(): Promise { + try { + await this.oauthClient.restore(this.did); + return true; + } catch { + return false; + } + } + + /** + * Clear the cached agent (e.g., on session error). + */ + clearAgent(): void { + this.agent = null; + } + + async putRecord( + collection: string, + rkey: string, + record: unknown, + ): Promise { + const agent = await this.getAgent(); + const result = await agent.com.atproto.repo.putRecord({ + repo: this.did, + collection, + rkey, + record: record as Record, + }); + return result.data; + } + + async deleteRecord( + collection: string, + rkey: string, + ): Promise { + const agent = await this.getAgent(); + const result = await agent.com.atproto.repo.deleteRecord({ + repo: this.did, + collection, + rkey, + }); + return result.data; + } + + async listRecords( + collection: string, + opts: { limit: number }, + ): Promise<{ + records: Array<{ uri: string; cid: string; value: unknown }>; + }> { + const agent = await this.getAgent(); + const result = await agent.com.atproto.repo.listRecords({ + repo: this.did, + collection, + limit: opts.limit, + }); + return { + records: result.data.records.map((r) => ({ + uri: r.uri, + cid: r.cid, + value: r.value, + })), + }; + } +} diff --git a/src/oauth/routes.ts b/src/oauth/routes.ts new file mode 100644 index 0000000..e936b9f --- /dev/null +++ b/src/oauth/routes.ts @@ -0,0 +1,190 @@ +/** + * OAuth HTTP routes for browser-based login flow. + * + * GET /oauth/login?handle=alice.bsky.social — Start OAuth flow + * GET /oauth/callback?code=...&state=...&iss=... — Exchange code for session + * GET /oauth/status — JSON session status + */ + +import type { Hono } from "hono"; +import type { NodeOAuthClient } from "@atproto/oauth-client-node"; +import type { Config } from "../config.js"; +import type { PdsClient } from "./pds-client.js"; +import type { NetworkService } from "../ipfs.js"; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export function registerOAuthRoutes( + app: Hono, + config: Config, + oauthClient: NodeOAuthClient, + pdsClient: PdsClient, + networkService?: NetworkService, +): void { + /** + * Start OAuth login flow. + * Redirects the user's browser to their PDS authorization endpoint. + */ + app.get("/oauth/login", async (c) => { + const handle = c.req.query("handle"); + if (!handle) { + return c.json( + { error: "MissingParameter", message: "handle query parameter is required" }, + 400, + ); + } + + try { + const authUrl = await oauthClient.authorize(handle, { + scope: "atproto transition:generic", + }); + return c.redirect(authUrl.toString()); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + return c.json( + { error: "AuthorizationFailed", message }, + 500, + ); + } + }); + + /** + * OAuth callback — exchange authorization code for session. + * Shows a simple HTML success/error page. + */ + app.get("/oauth/callback", async (c) => { + const params = new URLSearchParams(c.req.url.split("?")[1] ?? ""); + + try { + const { session } = await oauthClient.callback(params); + const did = session.did; + + // Enforce DID match if configured + if (config.DID && did !== config.DID) { + return c.html(errorPage( + "DID Mismatch", + `Authenticated as ${did} but this node is configured for ${config.DID}. Please log in with the correct account.`, + ), 403); + } + + // Publish peer record on successful auth + try { + await publishPeerRecord(pdsClient, networkService); + } catch (err) { + console.warn( + "[oauth] Failed to publish peer record:", + err instanceof Error ? err.message : String(err), + ); + } + + return c.html(successPage(did)); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + return c.html(errorPage("Authentication Failed", message), 500); + } + }); + + /** + * Session status endpoint for dashboard polling. + */ + app.get("/oauth/status", async (c) => { + try { + const hasSession = await pdsClient.hasSession(); + return c.json({ + authenticated: hasSession, + did: hasSession ? config.DID : null, + }); + } catch { + return c.json({ authenticated: false, did: null }); + } + }); +} + +/** + * Publish org.p2pds.peer/self record to the user's PDS. + */ +async function publishPeerRecord( + pdsClient: PdsClient, + networkService?: NetworkService, +): Promise { + const peerId = networkService?.getPeerId() ?? null; + const multiaddrs = networkService?.getMultiaddrs() ?? []; + + await pdsClient.putRecord("org.p2pds.peer", "self", { + $type: "org.p2pds.peer", + peerId, + multiaddrs, + createdAt: new Date().toISOString(), + }); +} + +function successPage(did: string): string { + return ` + + + + +P2PDS - Connected + + + +
+
Connected
+
${escapeHtml(did)}
+ Back to Dashboard +
+ +`; +} + +function errorPage(title: string, message: string): string { + return ` + + + + +P2PDS - Error + + + +
+
${escapeHtml(title)}
+
${escapeHtml(message)}
+ Back to Dashboard +
+ +`; +} + +function escapeHtml(s: string): string { + return s + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """); +} diff --git a/src/oauth/stores.test.ts b/src/oauth/stores.test.ts new file mode 100644 index 0000000..9cb9a22 --- /dev/null +++ b/src/oauth/stores.test.ts @@ -0,0 +1,170 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import Database from "better-sqlite3"; +import { OAuthStateStore, OAuthSessionStore } from "./stores.js"; +import type { NodeSavedState, NodeSavedSession } from "@atproto/oauth-client-node"; + +describe("OAuthStateStore", () => { + let tmpDir: string; + let db: InstanceType; + let store: OAuthStateStore; + + beforeEach(() => { + tmpDir = mkdtempSync(join(tmpdir(), "oauth-state-test-")); + db = new Database(join(tmpDir, "test.db")); + db.pragma("journal_mode = WAL"); + store = new OAuthStateStore(db); + }); + + afterEach(() => { + db.close(); + rmSync(tmpDir, { recursive: true, force: true }); + }); + + it("get returns undefined for missing key", async () => { + const result = await store.get("nonexistent"); + expect(result).toBeUndefined(); + }); + + it("set and get round-trip", async () => { + const state: NodeSavedState = { + dpopJwk: { kty: "EC", crv: "P-256", x: "test-x", y: "test-y" }, + iss: "https://bsky.social", + verifier: "test-verifier-123", + appState: undefined, + } as unknown as NodeSavedState; + + await store.set("state-key-1", state); + const result = await store.get("state-key-1"); + + expect(result).toBeDefined(); + expect((result as any).iss).toBe("https://bsky.social"); + expect((result as any).verifier).toBe("test-verifier-123"); + }); + + it("set overwrites existing value", async () => { + const state1 = { iss: "https://pds1.example.com", verifier: "v1" } as unknown as NodeSavedState; + const state2 = { iss: "https://pds2.example.com", verifier: "v2" } as unknown as NodeSavedState; + + await store.set("key", state1); + await store.set("key", state2); + + const result = await store.get("key"); + expect((result as any).iss).toBe("https://pds2.example.com"); + }); + + it("del removes entry", async () => { + const state = { iss: "https://example.com" } as unknown as NodeSavedState; + await store.set("key", state); + await store.del("key"); + + const result = await store.get("key"); + expect(result).toBeUndefined(); + }); + + it("del on missing key is a no-op", async () => { + // Should not throw + await store.del("nonexistent"); + }); + + it("creates table on construction", () => { + const tables = db + .prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='oauth_state'") + .all(); + expect(tables).toHaveLength(1); + }); +}); + +describe("OAuthSessionStore", () => { + let tmpDir: string; + let db: InstanceType; + let store: OAuthSessionStore; + + beforeEach(() => { + tmpDir = mkdtempSync(join(tmpdir(), "oauth-session-test-")); + db = new Database(join(tmpDir, "test.db")); + db.pragma("journal_mode = WAL"); + store = new OAuthSessionStore(db); + }); + + afterEach(() => { + db.close(); + rmSync(tmpDir, { recursive: true, force: true }); + }); + + it("get returns undefined for missing DID", async () => { + const result = await store.get("did:plc:nonexistent"); + expect(result).toBeUndefined(); + }); + + it("set and get round-trip with DID key", async () => { + const session: NodeSavedSession = { + dpopJwk: { kty: "EC", crv: "P-256", x: "x", y: "y" }, + tokenSet: { + access_token: "at-123", + token_type: "DPoP", + sub: "did:plc:testuser", + }, + } as unknown as NodeSavedSession; + + await store.set("did:plc:testuser", session); + const result = await store.get("did:plc:testuser"); + + expect(result).toBeDefined(); + expect((result as any).tokenSet.sub).toBe("did:plc:testuser"); + }); + + it("set overwrites existing session", async () => { + const session1 = { tokenSet: { access_token: "old" } } as unknown as NodeSavedSession; + const session2 = { tokenSet: { access_token: "new" } } as unknown as NodeSavedSession; + + await store.set("did:plc:user", session1); + await store.set("did:plc:user", session2); + + const result = await store.get("did:plc:user"); + expect((result as any).tokenSet.access_token).toBe("new"); + }); + + it("del removes session", async () => { + const session = { tokenSet: { access_token: "token" } } as unknown as NodeSavedSession; + await store.set("did:plc:user", session); + await store.del("did:plc:user"); + + const result = await store.get("did:plc:user"); + expect(result).toBeUndefined(); + }); + + it("creates table on construction", () => { + const tables = db + .prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='oauth_session'") + .all(); + expect(tables).toHaveLength(1); + }); + + it("multiple sessions for different DIDs", async () => { + const s1 = { tokenSet: { sub: "did:plc:a" } } as unknown as NodeSavedSession; + const s2 = { tokenSet: { sub: "did:plc:b" } } as unknown as NodeSavedSession; + + await store.set("did:plc:a", s1); + await store.set("did:plc:b", s2); + + const r1 = await store.get("did:plc:a"); + const r2 = await store.get("did:plc:b"); + + expect((r1 as any).tokenSet.sub).toBe("did:plc:a"); + expect((r2 as any).tokenSet.sub).toBe("did:plc:b"); + }); + + it("shares database with other tables", () => { + // Create state store on same db — should not conflict + const stateStore = new OAuthStateStore(db); + const tables = db + .prepare("SELECT name FROM sqlite_master WHERE type='table' ORDER BY name") + .all() as { name: string }[]; + const names = tables.map((t) => t.name); + expect(names).toContain("oauth_state"); + expect(names).toContain("oauth_session"); + }); +}); diff --git a/src/oauth/stores.ts b/src/oauth/stores.ts new file mode 100644 index 0000000..0419a76 --- /dev/null +++ b/src/oauth/stores.ts @@ -0,0 +1,75 @@ +/** + * SQLite-backed stores for OAuth state and session persistence. + * + * OAuthStateStore: ephemeral auth flow state (PKCE, DPoP). + * OAuthSessionStore: persistent sessions keyed by DID (sub). + * + * Both implement SimpleStore from @atproto-labs/simple-store. + */ + +import type Database from "better-sqlite3"; +import type { NodeSavedState, NodeSavedStateStore, NodeSavedSession, NodeSavedSessionStore } from "@atproto/oauth-client-node"; + +export class OAuthStateStore implements NodeSavedStateStore { + constructor(private db: Database.Database) { + this.db.exec(` + CREATE TABLE IF NOT EXISTS oauth_state ( + key TEXT PRIMARY KEY, + data TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT (datetime('now')) + ) + `); + } + + async get(key: string): Promise { + const row = this.db + .prepare("SELECT data FROM oauth_state WHERE key = ?") + .get(key) as { data: string } | undefined; + if (!row) return undefined; + return JSON.parse(row.data) as NodeSavedState; + } + + async set(key: string, value: NodeSavedState): Promise { + this.db + .prepare( + "INSERT OR REPLACE INTO oauth_state (key, data, created_at) VALUES (?, ?, datetime('now'))", + ) + .run(key, JSON.stringify(value)); + } + + async del(key: string): Promise { + this.db.prepare("DELETE FROM oauth_state WHERE key = ?").run(key); + } +} + +export class OAuthSessionStore implements NodeSavedSessionStore { + constructor(private db: Database.Database) { + this.db.exec(` + CREATE TABLE IF NOT EXISTS oauth_session ( + sub TEXT PRIMARY KEY, + data TEXT NOT NULL, + updated_at TEXT NOT NULL DEFAULT (datetime('now')) + ) + `); + } + + async get(key: string): Promise { + const row = this.db + .prepare("SELECT data FROM oauth_session WHERE sub = ?") + .get(key) as { data: string } | undefined; + if (!row) return undefined; + return JSON.parse(row.data) as NodeSavedSession; + } + + async set(key: string, value: NodeSavedSession): Promise { + this.db + .prepare( + "INSERT OR REPLACE INTO oauth_session (sub, data, updated_at) VALUES (?, ?, datetime('now'))", + ) + .run(key, JSON.stringify(value)); + } + + async del(key: string): Promise { + this.db.prepare("DELETE FROM oauth_session WHERE sub = ?").run(key); + } +} diff --git a/src/replication/challenge-response/challenge-response.test.ts b/src/replication/challenge-response/challenge-response.test.ts index ef461cb..09e16f8 100644 --- a/src/replication/challenge-response/challenge-response.test.ts +++ b/src/replication/challenge-response/challenge-response.test.ts @@ -43,6 +43,7 @@ function testConfig(dataDir: string): Config { RATE_LIMIT_CHALLENGE_PER_MIN: 20, RATE_LIMIT_MAX_CONNECTIONS: 100, RATE_LIMIT_FIREHOSE_PER_IP: 3, + OAUTH_ENABLED: false, }; } diff --git a/src/replication/challenge-response/e2e-challenge.test.ts b/src/replication/challenge-response/e2e-challenge.test.ts index a2ff0f0..d0aaf98 100644 --- a/src/replication/challenge-response/e2e-challenge.test.ts +++ b/src/replication/challenge-response/e2e-challenge.test.ts @@ -51,6 +51,7 @@ function testConfig(dataDir: string): Config { RATE_LIMIT_CHALLENGE_PER_MIN: 20, RATE_LIMIT_MAX_CONNECTIONS: 100, RATE_LIMIT_FIREHOSE_PER_IP: 3, + OAUTH_ENABLED: false, }; } diff --git a/src/replication/e2e-multi-node.test.ts b/src/replication/e2e-multi-node.test.ts index 2c5c10b..080ef73 100644 --- a/src/replication/e2e-multi-node.test.ts +++ b/src/replication/e2e-multi-node.test.ts @@ -56,6 +56,7 @@ function testConfig(dataDir: string): Config { RATE_LIMIT_CHALLENGE_PER_MIN: 20, RATE_LIMIT_MAX_CONNECTIONS: 100, RATE_LIMIT_FIREHOSE_PER_IP: 3, + OAUTH_ENABLED: false, }; } diff --git a/src/replication/firehose-incremental.test.ts b/src/replication/firehose-incremental.test.ts index 14b447f..f11d0f3 100644 --- a/src/replication/firehose-incremental.test.ts +++ b/src/replication/firehose-incremental.test.ts @@ -70,6 +70,7 @@ function testConfig(dataDir: string, replicateDids: string[] = []): Config { RATE_LIMIT_CHALLENGE_PER_MIN: 20, RATE_LIMIT_MAX_CONNECTIONS: 100, RATE_LIMIT_FIREHOSE_PER_IP: 3, + OAUTH_ENABLED: false, }; } diff --git a/src/replication/gossipsub-notifications.test.ts b/src/replication/gossipsub-notifications.test.ts index 26fecc5..a009304 100644 --- a/src/replication/gossipsub-notifications.test.ts +++ b/src/replication/gossipsub-notifications.test.ts @@ -305,6 +305,7 @@ describe("ReplicationManager gossipsub integration", () => { RATE_LIMIT_CHALLENGE_PER_MIN: 20, RATE_LIMIT_MAX_CONNECTIONS: 100, RATE_LIMIT_FIREHOSE_PER_IP: 3, + OAUTH_ENABLED: false, }; const { RepoManager } = await import("../repo-manager.js"); @@ -371,6 +372,7 @@ describe("ReplicationManager gossipsub integration", () => { RATE_LIMIT_CHALLENGE_PER_MIN: 20, RATE_LIMIT_MAX_CONNECTIONS: 100, RATE_LIMIT_FIREHOSE_PER_IP: 3, + OAUTH_ENABLED: false, }; const { RepoManager } = await import("../repo-manager.js"); @@ -459,6 +461,7 @@ describe("ReplicationManager gossipsub integration", () => { RATE_LIMIT_CHALLENGE_PER_MIN: 20, RATE_LIMIT_MAX_CONNECTIONS: 100, RATE_LIMIT_FIREHOSE_PER_IP: 3, + OAUTH_ENABLED: false, }; const { RepoManager } = await import("../repo-manager.js"); diff --git a/src/replication/mst-proof.test.ts b/src/replication/mst-proof.test.ts index b365955..40bb2cf 100644 --- a/src/replication/mst-proof.test.ts +++ b/src/replication/mst-proof.test.ts @@ -36,6 +36,7 @@ function testConfig(dataDir: string): Config { RATE_LIMIT_CHALLENGE_PER_MIN: 20, RATE_LIMIT_MAX_CONNECTIONS: 100, RATE_LIMIT_FIREHOSE_PER_IP: 3, + OAUTH_ENABLED: false, }; } diff --git a/src/replication/offer-manager.test.ts b/src/replication/offer-manager.test.ts index 99579a1..dae38aa 100644 --- a/src/replication/offer-manager.test.ts +++ b/src/replication/offer-manager.test.ts @@ -38,6 +38,7 @@ function testConfig(dataDir: string, did = "did:plc:local"): Config { RATE_LIMIT_CHALLENGE_PER_MIN: 20, RATE_LIMIT_MAX_CONNECTIONS: 100, RATE_LIMIT_FIREHOSE_PER_IP: 3, + OAUTH_ENABLED: false, }; } diff --git a/src/replication/offer-manager.ts b/src/replication/offer-manager.ts index d712003..b2a08e1 100644 --- a/src/replication/offer-manager.ts +++ b/src/replication/offer-manager.ts @@ -3,7 +3,6 @@ * and policy generation from mutual agreements. */ -import type { RepoManager } from "../repo-manager.js"; import type { PeerDiscovery } from "./peer-discovery.js"; import type { PolicyEngine } from "../policy/engine.js"; import type { Policy } from "../policy/types.js"; @@ -13,6 +12,18 @@ import { type OfferRecord, } from "./types.js"; +/** + * Interface for record read/write operations. + * Both RepoManager (local) and PdsClient (remote) satisfy this interface. + */ +export interface RecordWriter { + putRecord(collection: string, rkey: string, record: unknown): Promise; + deleteRecord(collection: string, rkey: string): Promise; + listRecords(collection: string, opts: { limit: number }): Promise<{ + records: Array<{ uri: string; cid: string; value: unknown }>; + }>; +} + /** A detected mutual replication agreement between two peers. */ export interface Agreement { counterpartyDid: string; @@ -26,7 +37,7 @@ const P2P_POLICY_PREFIX = "p2p:"; export class OfferManager { constructor( - private repoManager: RepoManager, + private recordWriter: RecordWriter, private peerDiscovery: PeerDiscovery, private policyEngine: PolicyEngine, private localDid: string, @@ -48,7 +59,7 @@ export class OfferManager { createdAt: new Date().toISOString(), }; - await this.repoManager.putRecord(OFFER_NSID, didToRkey(subject), record); + await this.recordWriter.putRecord(OFFER_NSID, didToRkey(subject), record); return record; } @@ -56,7 +67,7 @@ export class OfferManager { * Revoke a replication offer and remove any derived policy. */ async revokeOffer(subject: string): Promise { - await this.repoManager.deleteRecord(OFFER_NSID, didToRkey(subject)); + await this.recordWriter.deleteRecord(OFFER_NSID, didToRkey(subject)); // Remove the P2P policy derived from this offer this.policyEngine.removePolicy(`${P2P_POLICY_PREFIX}${subject}`); } @@ -65,7 +76,7 @@ export class OfferManager { * List all local offers from our repo. */ async getLocalOffers(): Promise { - const result = await this.repoManager.listRecords(OFFER_NSID, { + const result = await this.recordWriter.listRecords(OFFER_NSID, { limit: 100, }); return result.records diff --git a/src/replication/peer-freshness.test.ts b/src/replication/peer-freshness.test.ts index 2492c95..1a0137f 100644 --- a/src/replication/peer-freshness.test.ts +++ b/src/replication/peer-freshness.test.ts @@ -52,6 +52,7 @@ function testConfig(dataDir: string, replicateDids: string[] = []): Config { RATE_LIMIT_CHALLENGE_PER_MIN: 20, RATE_LIMIT_MAX_CONNECTIONS: 100, RATE_LIMIT_FIREHOSE_PER_IP: 3, + OAUTH_ENABLED: false, }; } diff --git a/src/replication/policy-integration.test.ts b/src/replication/policy-integration.test.ts index 36b8fce..7d64d7c 100644 --- a/src/replication/policy-integration.test.ts +++ b/src/replication/policy-integration.test.ts @@ -60,6 +60,7 @@ function testConfig(dataDir: string, replicateDids: string[] = []): Config { RATE_LIMIT_CHALLENGE_PER_MIN: 20, RATE_LIMIT_MAX_CONNECTIONS: 100, RATE_LIMIT_FIREHOSE_PER_IP: 3, + OAUTH_ENABLED: false, }; } diff --git a/src/replication/replication-manager.ts b/src/replication/replication-manager.ts index 17f7deb..a6ba908 100644 --- a/src/replication/replication-manager.ts +++ b/src/replication/replication-manager.ts @@ -38,7 +38,7 @@ import { import { ChallengeScheduler } from "./challenge-response/challenge-scheduler.js"; import { ChallengeStorage, type ChallengeHistoryRow, type PeerReliabilityRow } from "./challenge-response/challenge-storage.js"; import type { ChallengeTransport } from "./challenge-response/transport.js"; -import { OfferManager } from "./offer-manager.js"; +import { OfferManager, type RecordWriter } from "./offer-manager.js"; /** How old cached peer info can be before re-fetching (1 hour). */ const PEER_INFO_TTL_MS = 60 * 60 * 1000; @@ -83,6 +83,7 @@ export class ReplicationManager { verificationConfig?: Partial, private replicatedRepoReader?: ReplicatedRepoReader, policyEngine?: PolicyEngine, + pdsClient?: RecordWriter, ) { this.syncStorage = new SyncStorage(db); this.challengeStorage = new ChallengeStorage(db); @@ -99,8 +100,10 @@ export class ReplicationManager { ); if (policyEngine) { this.policyEngine = policyEngine; + // Prefer remote PDS client for offer records; fall back to local repo + const recordWriter: RecordWriter = pdsClient ?? repoManager; this.offerManager = new OfferManager( - repoManager, + recordWriter, this.peerDiscovery, policyEngine, config.DID ?? "", diff --git a/src/replication/replication.test.ts b/src/replication/replication.test.ts index 3946080..0548dbb 100644 --- a/src/replication/replication.test.ts +++ b/src/replication/replication.test.ts @@ -63,6 +63,7 @@ function testConfig(dataDir: string, replicateDids: string[] = []): Config { RATE_LIMIT_CHALLENGE_PER_MIN: 20, RATE_LIMIT_MAX_CONNECTIONS: 100, RATE_LIMIT_FIREHOSE_PER_IP: 3, + OAUTH_ENABLED: false, }; } diff --git a/src/server.ts b/src/server.ts index 11c369d..bafd570 100644 --- a/src/server.ts +++ b/src/server.ts @@ -23,6 +23,8 @@ import { FailoverChallengeTransport } from "./replication/challenge-response/fai import type { ChallengeTransport } from "./replication/challenge-response/transport.js"; import type { Libp2p } from "@libp2p/interface"; import { RateLimiter } from "./rate-limiter.js"; +import { createOAuthClient, type OAuthClientManager } from "./oauth/client.js"; +import { PdsClient } from "./oauth/pds-client.js"; // Load configuration const config = loadConfig(); @@ -86,6 +88,16 @@ if (config.POLICY_FILE) { } } +// Initialize OAuth client (if enabled) +let oauthClientManager: OAuthClientManager | undefined; +let pdsClient: PdsClient | undefined; +if (config.OAUTH_ENABLED) { + oauthClientManager = await createOAuthClient(db, config); + if (config.DID) { + pdsClient = new PdsClient(oauthClientManager.client, config.DID); + } +} + // Determine if we have DIDs to replicate (from config and/or policies) const hasReplicateDids = config.REPLICATE_DIDS.length > 0 || @@ -105,6 +117,7 @@ if (ipfsService && hasReplicateDids && repoManager) { undefined, undefined, policyEngine, + pdsClient, ); replicatedRepoReader = new ReplicatedRepoReader( ipfsService, @@ -129,6 +142,8 @@ const app = createApp( replicatedRepoReader, repoManager, rateLimiter, + oauthClientManager, + pdsClient, ); // Create HTTP server using @hono/node-server's request listener @@ -209,6 +224,13 @@ httpServer.listen(config.PORT, async () => { console.log(pc.dim(` Handle: @${config.HANDLE}`)); } console.log(pc.dim(` Data: ${dataDir}`)); + if (oauthClientManager) { + if (pdsClient && await pdsClient.hasSession().catch(() => false)) { + console.log(pc.dim(` OAuth: session active for ${config.DID}`)); + } else { + console.log(pc.dim(` OAuth: enabled (no active session)`)); + } + } // Start IPFS after HTTP server is listening (IPFS startup can be slow) if (ipfsService) { diff --git a/src/xrpc/admin-e2e.test.ts b/src/xrpc/admin-e2e.test.ts index b32ce40..98bb56f 100644 --- a/src/xrpc/admin-e2e.test.ts +++ b/src/xrpc/admin-e2e.test.ts @@ -53,6 +53,7 @@ function makeConfig( RATE_LIMIT_CHALLENGE_PER_MIN: 20, RATE_LIMIT_MAX_CONNECTIONS: 100, RATE_LIMIT_FIREHOSE_PER_IP: 3, + OAUTH_ENABLED: false, }; } diff --git a/src/xrpc/admin.test.ts b/src/xrpc/admin.test.ts index 71d4f93..c98a7a4 100644 --- a/src/xrpc/admin.test.ts +++ b/src/xrpc/admin.test.ts @@ -39,6 +39,7 @@ function testConfig(dataDir: string, replicateDids: string[] = []): Config { RATE_LIMIT_CHALLENGE_PER_MIN: 20, RATE_LIMIT_MAX_CONNECTIONS: 100, RATE_LIMIT_FIREHOSE_PER_IP: 3, + OAUTH_ENABLED: false, }; } diff --git a/src/xrpc/admin.ts b/src/xrpc/admin.ts index d2efd72..98f2b7d 100644 --- a/src/xrpc/admin.ts +++ b/src/xrpc/admin.ts @@ -245,6 +245,11 @@ tr.clickable:hover { background: #f8f8f8; }
Loading...
+
+

Account Connection

+
Loading...
+
+

Replication Summary

Loading...
@@ -524,6 +529,37 @@ function renderVerification(data) { el.innerHTML = html; } +async function refreshAccount() { + var el = document.getElementById("account-content"); + try { + var res = await fetch("/oauth/status"); + if (!res.ok) { el.innerHTML = 'OAuth not enabled'; return; } + var data = await res.json(); + if (data.authenticated) { + el.innerHTML = '
' + + '
Status
Connected
' + + '
DID
' + esc(data.did) + '
' + + '
'; + } else { + el.innerHTML = '
' + + '' + + '' + + '
' + + '
Authenticate with your AT Protocol account to publish records to your PDS.
'; + document.getElementById("oauth-connect-btn").addEventListener("click", function() { + var handle = document.getElementById("oauth-handle").value.trim(); + if (!handle) return; + window.location.href = "/oauth/login?handle=" + encodeURIComponent(handle); + }); + document.getElementById("oauth-handle").addEventListener("keydown", function(e) { + if (e.key === "Enter") document.getElementById("oauth-connect-btn").click(); + }); + } + } catch (e) { + el.innerHTML = 'OAuth not enabled'; + } +} + async function refresh() { try { const [overview, network, policies, syncHistory] = await Promise.all([ @@ -533,6 +569,7 @@ async function refresh() { apiFetch("org.p2pds.admin.getSyncHistory", { limit: "20" }), ]); renderOverview(overview); + refreshAccount(); renderMetrics(overview); renderReplication(overview); renderSyncHistory(syncHistory);