atproto user agency toolkit for individuals and groups
8
fork

Configure Feed

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

Add minimal OAuth client for remote PDS record publishing

Enable p2pds to authenticate as a user via AT Protocol OAuth and publish
records (peer info, replication offers) to their real PDS instead of only
the local SQLite repo. This unblocks real-world peer discovery.

- Add @atproto/oauth-client-node and @atproto/api dependencies
- SQLite-backed OAuth state/session stores (src/oauth/stores.ts)
- Loopback OAuth client setup per AT Protocol spec (src/oauth/client.ts)
- PdsClient implementing RecordWriter for remote XRPC calls (src/oauth/pds-client.ts)
- Browser login flow routes: /oauth/login, /oauth/callback, /oauth/status
- Extract RecordWriter interface from OfferManager (both RepoManager and
PdsClient satisfy it, no changes to method bodies)
- Wire OAuth through server.ts → ReplicationManager → OfferManager
- Add Account Connection card to admin dashboard
- Opt-in via OAUTH_ENABLED=true (existing deployments unchanged)

+1316 -8
+538
package-lock.json
··· 8 8 "name": "p2pds", 9 9 "version": "0.1.0", 10 10 "license": "MIT", 11 + "workspaces": [ 12 + "apps/*" 13 + ], 11 14 "dependencies": { 12 15 "@atcute/atproto": "^3.1.10", 13 16 "@atcute/bluesky": "^3.2.14", ··· 18 21 "@atcute/identity-resolver": "^1.2.2", 19 22 "@atcute/lexicons": "^1.2.6", 20 23 "@atcute/tid": "^1.1.1", 24 + "@atproto/api": "^0.18.21", 21 25 "@atproto/crypto": "^0.4.5", 22 26 "@atproto/lex-cbor": "^0.0.3", 23 27 "@atproto/lex-data": "^0.0.3", 24 28 "@atproto/lex-json": "^0.0.11", 29 + "@atproto/oauth-client-node": "^0.3.16", 25 30 "@atproto/repo": "^0.8.12", 26 31 "@hono/node-server": "^1.13.8", 27 32 "@libp2p/gossipsub": "^15.0.12", ··· 42 47 "tsx": "^4.21.0", 43 48 "typescript": "^5.9.3", 44 49 "vitest": "^3.0.0" 50 + } 51 + }, 52 + "apps/desktop": { 53 + "name": "p2pds-desktop", 54 + "version": "0.1.0", 55 + "devDependencies": { 56 + "@tauri-apps/api": "^2.0.0", 57 + "@tauri-apps/cli": "^2.0.0", 58 + "@tauri-apps/plugin-shell": "^2.0.0" 45 59 } 46 60 }, 47 61 "node_modules/@achingbrain/http-parser-js": { ··· 236 250 "unicode-segmenter": "^0.14.5" 237 251 } 238 252 }, 253 + "node_modules/@atproto-labs/did-resolver": { 254 + "version": "0.2.6", 255 + "resolved": "https://registry.npmjs.org/@atproto-labs/did-resolver/-/did-resolver-0.2.6.tgz", 256 + "integrity": "sha512-2K1bC04nI2fmgNcvof+yA28IhGlpWn2JKYlPa7To9JTKI45FINCGkQSGiL2nyXlyzDJJ34fZ1aq6/IRFIOIiqg==", 257 + "license": "MIT", 258 + "dependencies": { 259 + "@atproto-labs/fetch": "0.2.3", 260 + "@atproto-labs/pipe": "0.1.1", 261 + "@atproto-labs/simple-store": "0.3.0", 262 + "@atproto-labs/simple-store-memory": "0.1.4", 263 + "@atproto/did": "0.3.0", 264 + "zod": "^3.23.8" 265 + } 266 + }, 267 + "node_modules/@atproto-labs/fetch": { 268 + "version": "0.2.3", 269 + "resolved": "https://registry.npmjs.org/@atproto-labs/fetch/-/fetch-0.2.3.tgz", 270 + "integrity": "sha512-NZtbJOCbxKUFRFKMpamT38PUQMY0hX0p7TG5AEYOPhZKZEP7dHZ1K2s1aB8MdVH0qxmqX7nQleNrrvLf09Zfdw==", 271 + "license": "MIT", 272 + "dependencies": { 273 + "@atproto-labs/pipe": "0.1.1" 274 + } 275 + }, 276 + "node_modules/@atproto-labs/fetch-node": { 277 + "version": "0.2.0", 278 + "resolved": "https://registry.npmjs.org/@atproto-labs/fetch-node/-/fetch-node-0.2.0.tgz", 279 + "integrity": "sha512-Krq09nH/aeoiU2s9xdHA0FjTEFWG9B5FFenipv1iRixCcPc7V3DhTNDawxG9gI8Ny0k4dBVS9WTRN/IDzBx86Q==", 280 + "license": "MIT", 281 + "dependencies": { 282 + "@atproto-labs/fetch": "0.2.3", 283 + "@atproto-labs/pipe": "0.1.1", 284 + "ipaddr.js": "^2.1.0", 285 + "undici": "^6.14.1" 286 + }, 287 + "engines": { 288 + "node": ">=18.7.0" 289 + } 290 + }, 291 + "node_modules/@atproto-labs/fetch-node/node_modules/undici": { 292 + "version": "6.23.0", 293 + "resolved": "https://registry.npmjs.org/undici/-/undici-6.23.0.tgz", 294 + "integrity": "sha512-VfQPToRA5FZs/qJxLIinmU59u0r7LXqoJkCzinq3ckNJp3vKEh7jTWN589YQ5+aoAC/TGRLyJLCPKcLQbM8r9g==", 295 + "license": "MIT", 296 + "engines": { 297 + "node": ">=18.17" 298 + } 299 + }, 300 + "node_modules/@atproto-labs/handle-resolver": { 301 + "version": "0.3.6", 302 + "resolved": "https://registry.npmjs.org/@atproto-labs/handle-resolver/-/handle-resolver-0.3.6.tgz", 303 + "integrity": "sha512-qnSTXvOBNj1EHhp2qTWSX8MS5q3AwYU5LKlt5fBvSbCjgmTr2j0URHCv+ydrwO55KvsojIkTMgeMOh4YuY4fCA==", 304 + "license": "MIT", 305 + "dependencies": { 306 + "@atproto-labs/simple-store": "0.3.0", 307 + "@atproto-labs/simple-store-memory": "0.1.4", 308 + "@atproto/did": "0.3.0", 309 + "zod": "^3.23.8" 310 + } 311 + }, 312 + "node_modules/@atproto-labs/handle-resolver-node": { 313 + "version": "0.1.25", 314 + "resolved": "https://registry.npmjs.org/@atproto-labs/handle-resolver-node/-/handle-resolver-node-0.1.25.tgz", 315 + "integrity": "sha512-NY9WYM2VLd3IuMGRkkmvGBg8xqVEaK/fitv1vD8SMXqFTekdpjOLCCyv7EFtqVHouzmDcL83VOvWRfHVa8V9Yw==", 316 + "license": "MIT", 317 + "dependencies": { 318 + "@atproto-labs/fetch-node": "0.2.0", 319 + "@atproto-labs/handle-resolver": "0.3.6", 320 + "@atproto/did": "0.3.0" 321 + }, 322 + "engines": { 323 + "node": ">=18.7.0" 324 + } 325 + }, 326 + "node_modules/@atproto-labs/identity-resolver": { 327 + "version": "0.3.6", 328 + "resolved": "https://registry.npmjs.org/@atproto-labs/identity-resolver/-/identity-resolver-0.3.6.tgz", 329 + "integrity": "sha512-qoWqBDRobln0NR8L8dQjSp79E0chGkBhibEgxQa2f9WD+JbJdjQ0YvwwO5yeQn05pJoJmAwmI2wyJ45zjU7aWg==", 330 + "license": "MIT", 331 + "dependencies": { 332 + "@atproto-labs/did-resolver": "0.2.6", 333 + "@atproto-labs/handle-resolver": "0.3.6" 334 + } 335 + }, 336 + "node_modules/@atproto-labs/pipe": { 337 + "version": "0.1.1", 338 + "resolved": "https://registry.npmjs.org/@atproto-labs/pipe/-/pipe-0.1.1.tgz", 339 + "integrity": "sha512-hdNw2oUs2B6BN1lp+32pF7cp8EMKuIN5Qok2Vvv/aOpG/3tNSJ9YkvfI0k6Zd188LeDDYRUpYpxcoFIcGH/FNg==", 340 + "license": "MIT" 341 + }, 342 + "node_modules/@atproto-labs/simple-store": { 343 + "version": "0.3.0", 344 + "resolved": "https://registry.npmjs.org/@atproto-labs/simple-store/-/simple-store-0.3.0.tgz", 345 + "integrity": "sha512-nOb6ONKBRJHRlukW1sVawUkBqReLlLx6hT35VS3imaNPwiXDxLnTK7lxw3Lrl9k5yugSBDQAkZAq3MPTEFSUBQ==", 346 + "license": "MIT" 347 + }, 348 + "node_modules/@atproto-labs/simple-store-memory": { 349 + "version": "0.1.4", 350 + "resolved": "https://registry.npmjs.org/@atproto-labs/simple-store-memory/-/simple-store-memory-0.1.4.tgz", 351 + "integrity": "sha512-3mKY4dP8I7yKPFj9VKpYyCRzGJOi5CEpOLPlRhoJyLmgs3J4RzDrjn323Oakjz2Aj2JzRU/AIvWRAZVhpYNJHw==", 352 + "license": "MIT", 353 + "dependencies": { 354 + "@atproto-labs/simple-store": "0.3.0", 355 + "lru-cache": "^10.2.0" 356 + } 357 + }, 358 + "node_modules/@atproto-labs/simple-store-memory/node_modules/lru-cache": { 359 + "version": "10.4.3", 360 + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", 361 + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", 362 + "license": "ISC" 363 + }, 364 + "node_modules/@atproto/api": { 365 + "version": "0.18.21", 366 + "resolved": "https://registry.npmjs.org/@atproto/api/-/api-0.18.21.tgz", 367 + "integrity": "sha512-s35MIJerGT/pKe2xJtKKswqlIr/ola2r2iURBKBL0Mk1OKe6jP4YvTMh1N2d2PEANFzNNTbKoDaLfJPo2Uvc/w==", 368 + "license": "MIT", 369 + "dependencies": { 370 + "@atproto/common-web": "^0.4.16", 371 + "@atproto/lexicon": "^0.6.1", 372 + "@atproto/syntax": "^0.4.3", 373 + "@atproto/xrpc": "^0.7.7", 374 + "await-lock": "^2.2.2", 375 + "multiformats": "^9.9.0", 376 + "tlds": "^1.234.0", 377 + "zod": "^3.23.8" 378 + } 379 + }, 380 + "node_modules/@atproto/api/node_modules/@atproto/syntax": { 381 + "version": "0.4.3", 382 + "resolved": "https://registry.npmjs.org/@atproto/syntax/-/syntax-0.4.3.tgz", 383 + "integrity": "sha512-YoZUz40YAJr5nPwvCDWgodEOlt5IftZqPJvA0JDWjuZKD8yXddTwSzXSaKQAzGOpuM+/A3uXRtPzJJqlScc+iA==", 384 + "license": "MIT", 385 + "dependencies": { 386 + "tslib": "^2.8.1" 387 + } 388 + }, 239 389 "node_modules/@atproto/common": { 240 390 "version": "0.5.11", 241 391 "resolved": "https://registry.npmjs.org/@atproto/common/-/common-0.5.11.tgz", ··· 322 472 "node": ">=18.7.0" 323 473 } 324 474 }, 475 + "node_modules/@atproto/did": { 476 + "version": "0.3.0", 477 + "resolved": "https://registry.npmjs.org/@atproto/did/-/did-0.3.0.tgz", 478 + "integrity": "sha512-raUPzUGegtW/6OxwCmM8bhZvuIMzxG5t9oWsth6Tp91Kb5fTnHV2h/KKNF1C82doeA4BdXCErTyg7ISwLbQkzA==", 479 + "license": "MIT", 480 + "dependencies": { 481 + "zod": "^3.23.8" 482 + } 483 + }, 484 + "node_modules/@atproto/jwk": { 485 + "version": "0.6.0", 486 + "resolved": "https://registry.npmjs.org/@atproto/jwk/-/jwk-0.6.0.tgz", 487 + "integrity": "sha512-bDoJPvt7TrQVi/rBfBrSSpGykhtIriKxeYCYQTiPRKFfyRhbgpElF0wPXADjIswnbzZdOwbY63az4E/CFVT3Tw==", 488 + "license": "MIT", 489 + "dependencies": { 490 + "multiformats": "^9.9.0", 491 + "zod": "^3.23.8" 492 + } 493 + }, 494 + "node_modules/@atproto/jwk-jose": { 495 + "version": "0.1.11", 496 + "resolved": "https://registry.npmjs.org/@atproto/jwk-jose/-/jwk-jose-0.1.11.tgz", 497 + "integrity": "sha512-i4Fnr2sTBYmMmHXl7NJh8GrCH+tDQEVWrcDMDnV5DjJfkgT17wIqvojIw9SNbSL4Uf0OtfEv6AgG0A+mgh8b5Q==", 498 + "license": "MIT", 499 + "dependencies": { 500 + "@atproto/jwk": "0.6.0", 501 + "jose": "^5.2.0" 502 + } 503 + }, 504 + "node_modules/@atproto/jwk-jose/node_modules/jose": { 505 + "version": "5.10.0", 506 + "resolved": "https://registry.npmjs.org/jose/-/jose-5.10.0.tgz", 507 + "integrity": "sha512-s+3Al/p9g32Iq+oqXxkW//7jk2Vig6FF1CFqzVXoTUXt2qz89YWbL+OwS17NFYEvxC35n0FKeGO2LGYSxeM2Gg==", 508 + "license": "MIT", 509 + "funding": { 510 + "url": "https://github.com/sponsors/panva" 511 + } 512 + }, 513 + "node_modules/@atproto/jwk-webcrypto": { 514 + "version": "0.2.0", 515 + "resolved": "https://registry.npmjs.org/@atproto/jwk-webcrypto/-/jwk-webcrypto-0.2.0.tgz", 516 + "integrity": "sha512-UmgRrrEAkWvxwhlwe30UmDOdTEFidlIzBC7C3cCbeJMcBN1x8B3KH+crXrsTqfWQBG58mXgt8wgSK3Kxs2LhFg==", 517 + "license": "MIT", 518 + "dependencies": { 519 + "@atproto/jwk": "0.6.0", 520 + "@atproto/jwk-jose": "0.1.11", 521 + "zod": "^3.23.8" 522 + } 523 + }, 325 524 "node_modules/@atproto/lex-cbor": { 326 525 "version": "0.0.3", 327 526 "resolved": "https://registry.npmjs.org/@atproto/lex-cbor/-/lex-cbor-0.0.3.tgz", ··· 390 589 "tslib": "^2.8.1" 391 590 } 392 591 }, 592 + "node_modules/@atproto/oauth-client": { 593 + "version": "0.5.14", 594 + "resolved": "https://registry.npmjs.org/@atproto/oauth-client/-/oauth-client-0.5.14.tgz", 595 + "integrity": "sha512-sPH+vcdq9maTEAhJI0HzmFcFAMrkCS19np+RUssNkX6kS8Xr3OYr57tvYRCbkcnIyYTfYcxKQgpwHKx3RVEaYw==", 596 + "license": "MIT", 597 + "dependencies": { 598 + "@atproto-labs/did-resolver": "0.2.6", 599 + "@atproto-labs/fetch": "0.2.3", 600 + "@atproto-labs/handle-resolver": "0.3.6", 601 + "@atproto-labs/identity-resolver": "0.3.6", 602 + "@atproto-labs/simple-store": "0.3.0", 603 + "@atproto-labs/simple-store-memory": "0.1.4", 604 + "@atproto/did": "0.3.0", 605 + "@atproto/jwk": "0.6.0", 606 + "@atproto/oauth-types": "0.6.2", 607 + "@atproto/xrpc": "0.7.7", 608 + "core-js": "^3", 609 + "multiformats": "^9.9.0", 610 + "zod": "^3.23.8" 611 + } 612 + }, 613 + "node_modules/@atproto/oauth-client-node": { 614 + "version": "0.3.16", 615 + "resolved": "https://registry.npmjs.org/@atproto/oauth-client-node/-/oauth-client-node-0.3.16.tgz", 616 + "integrity": "sha512-2dooMzxAkiQ4MkOAZlEQ3iwbB9SEovrbIKMNuBbVCLQYORVNxe20tMdjs3lvhrzdpzvaHLlQnJJhw5dA9VELFw==", 617 + "license": "MIT", 618 + "dependencies": { 619 + "@atproto-labs/did-resolver": "0.2.6", 620 + "@atproto-labs/handle-resolver-node": "0.1.25", 621 + "@atproto-labs/simple-store": "0.3.0", 622 + "@atproto/did": "0.3.0", 623 + "@atproto/jwk": "0.6.0", 624 + "@atproto/jwk-jose": "0.1.11", 625 + "@atproto/jwk-webcrypto": "0.2.0", 626 + "@atproto/oauth-client": "0.5.14", 627 + "@atproto/oauth-types": "0.6.2" 628 + }, 629 + "engines": { 630 + "node": ">=18.7.0" 631 + } 632 + }, 633 + "node_modules/@atproto/oauth-types": { 634 + "version": "0.6.2", 635 + "resolved": "https://registry.npmjs.org/@atproto/oauth-types/-/oauth-types-0.6.2.tgz", 636 + "integrity": "sha512-2cuboM4RQBCYR8NQC5uGRkW6KgCgKyq/B5/+tnMmWZYtZGVUQvsUWQHK/ZiMCnVXbcDNtc/RIEJQJDZ8FXMoxg==", 637 + "license": "MIT", 638 + "dependencies": { 639 + "@atproto/did": "0.3.0", 640 + "@atproto/jwk": "0.6.0", 641 + "zod": "^3.23.8" 642 + } 643 + }, 393 644 "node_modules/@atproto/repo": { 394 645 "version": "0.8.12", 395 646 "resolved": "https://registry.npmjs.org/@atproto/repo/-/repo-0.8.12.tgz", ··· 415 666 "resolved": "https://registry.npmjs.org/@atproto/syntax/-/syntax-0.4.2.tgz", 416 667 "integrity": "sha512-X9XSRPinBy/0VQ677j8VXlBsYSsUXaiqxWVpGGxJYsAhugdQRb0jqaVKJFtm6RskeNkV6y9xclSUi9UYG/COrA==", 417 668 "license": "MIT" 669 + }, 670 + "node_modules/@atproto/xrpc": { 671 + "version": "0.7.7", 672 + "resolved": "https://registry.npmjs.org/@atproto/xrpc/-/xrpc-0.7.7.tgz", 673 + "integrity": "sha512-K1ZyO/BU8JNtXX5dmPp7b5UrkLMMqpsIa/Lrj5D3Su+j1Xwq1m6QJ2XJ1AgjEjkI1v4Muzm7klianLE6XGxtmA==", 674 + "license": "MIT", 675 + "dependencies": { 676 + "@atproto/lexicon": "^0.6.0", 677 + "zod": "^3.23.8" 678 + } 418 679 }, 419 680 "node_modules/@babel/code-frame": { 420 681 "version": "7.29.0", ··· 4015 4276 "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", 4016 4277 "license": "MIT" 4017 4278 }, 4279 + "node_modules/@tauri-apps/api": { 4280 + "version": "2.10.1", 4281 + "resolved": "https://registry.npmjs.org/@tauri-apps/api/-/api-2.10.1.tgz", 4282 + "integrity": "sha512-hKL/jWf293UDSUN09rR69hrToyIXBb8CjGaWC7gfinvnQrBVvnLr08FeFi38gxtugAVyVcTa5/FD/Xnkb1siBw==", 4283 + "dev": true, 4284 + "license": "Apache-2.0 OR MIT", 4285 + "funding": { 4286 + "type": "opencollective", 4287 + "url": "https://opencollective.com/tauri" 4288 + } 4289 + }, 4290 + "node_modules/@tauri-apps/cli": { 4291 + "version": "2.10.0", 4292 + "resolved": "https://registry.npmjs.org/@tauri-apps/cli/-/cli-2.10.0.tgz", 4293 + "integrity": "sha512-ZwT0T+7bw4+DPCSWzmviwq5XbXlM0cNoleDKOYPFYqcZqeKY31KlpoMW/MOON/tOFBPgi31a2v3w9gliqwL2+Q==", 4294 + "dev": true, 4295 + "license": "Apache-2.0 OR MIT", 4296 + "bin": { 4297 + "tauri": "tauri.js" 4298 + }, 4299 + "engines": { 4300 + "node": ">= 10" 4301 + }, 4302 + "funding": { 4303 + "type": "opencollective", 4304 + "url": "https://opencollective.com/tauri" 4305 + }, 4306 + "optionalDependencies": { 4307 + "@tauri-apps/cli-darwin-arm64": "2.10.0", 4308 + "@tauri-apps/cli-darwin-x64": "2.10.0", 4309 + "@tauri-apps/cli-linux-arm-gnueabihf": "2.10.0", 4310 + "@tauri-apps/cli-linux-arm64-gnu": "2.10.0", 4311 + "@tauri-apps/cli-linux-arm64-musl": "2.10.0", 4312 + "@tauri-apps/cli-linux-riscv64-gnu": "2.10.0", 4313 + "@tauri-apps/cli-linux-x64-gnu": "2.10.0", 4314 + "@tauri-apps/cli-linux-x64-musl": "2.10.0", 4315 + "@tauri-apps/cli-win32-arm64-msvc": "2.10.0", 4316 + "@tauri-apps/cli-win32-ia32-msvc": "2.10.0", 4317 + "@tauri-apps/cli-win32-x64-msvc": "2.10.0" 4318 + } 4319 + }, 4320 + "node_modules/@tauri-apps/cli-darwin-arm64": { 4321 + "version": "2.10.0", 4322 + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-arm64/-/cli-darwin-arm64-2.10.0.tgz", 4323 + "integrity": "sha512-avqHD4HRjrMamE/7R/kzJPcAJnZs0IIS+1nkDP5b+TNBn3py7N2aIo9LIpy+VQq0AkN8G5dDpZtOOBkmWt/zjA==", 4324 + "cpu": [ 4325 + "arm64" 4326 + ], 4327 + "dev": true, 4328 + "license": "Apache-2.0 OR MIT", 4329 + "optional": true, 4330 + "os": [ 4331 + "darwin" 4332 + ], 4333 + "engines": { 4334 + "node": ">= 10" 4335 + } 4336 + }, 4337 + "node_modules/@tauri-apps/cli-darwin-x64": { 4338 + "version": "2.10.0", 4339 + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-x64/-/cli-darwin-x64-2.10.0.tgz", 4340 + "integrity": "sha512-keDmlvJRStzVFjZTd0xYkBONLtgBC9eMTpmXnBXzsHuawV2q9PvDo2x6D5mhuoMVrJ9QWjgaPKBBCFks4dK71Q==", 4341 + "cpu": [ 4342 + "x64" 4343 + ], 4344 + "dev": true, 4345 + "license": "Apache-2.0 OR MIT", 4346 + "optional": true, 4347 + "os": [ 4348 + "darwin" 4349 + ], 4350 + "engines": { 4351 + "node": ">= 10" 4352 + } 4353 + }, 4354 + "node_modules/@tauri-apps/cli-linux-arm-gnueabihf": { 4355 + "version": "2.10.0", 4356 + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm-gnueabihf/-/cli-linux-arm-gnueabihf-2.10.0.tgz", 4357 + "integrity": "sha512-e5u0VfLZsMAC9iHaOEANumgl6lfnJx0Dtjkd8IJpysZ8jp0tJ6wrIkto2OzQgzcYyRCKgX72aKE0PFgZputA8g==", 4358 + "cpu": [ 4359 + "arm" 4360 + ], 4361 + "dev": true, 4362 + "license": "Apache-2.0 OR MIT", 4363 + "optional": true, 4364 + "os": [ 4365 + "linux" 4366 + ], 4367 + "engines": { 4368 + "node": ">= 10" 4369 + } 4370 + }, 4371 + "node_modules/@tauri-apps/cli-linux-arm64-gnu": { 4372 + "version": "2.10.0", 4373 + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-gnu/-/cli-linux-arm64-gnu-2.10.0.tgz", 4374 + "integrity": "sha512-YrYYk2dfmBs5m+OIMCrb+JH/oo+4FtlpcrTCgiFYc7vcs6m3QDd1TTyWu0u01ewsCtK2kOdluhr/zKku+KP7HA==", 4375 + "cpu": [ 4376 + "arm64" 4377 + ], 4378 + "dev": true, 4379 + "license": "Apache-2.0 OR MIT", 4380 + "optional": true, 4381 + "os": [ 4382 + "linux" 4383 + ], 4384 + "engines": { 4385 + "node": ">= 10" 4386 + } 4387 + }, 4388 + "node_modules/@tauri-apps/cli-linux-arm64-musl": { 4389 + "version": "2.10.0", 4390 + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.10.0.tgz", 4391 + "integrity": "sha512-GUoPdVJmrJRIXFfW3Rkt+eGK9ygOdyISACZfC/bCSfOnGt8kNdQIQr5WRH9QUaTVFIwxMlQyV3m+yXYP+xhSVA==", 4392 + "cpu": [ 4393 + "arm64" 4394 + ], 4395 + "dev": true, 4396 + "license": "Apache-2.0 OR MIT", 4397 + "optional": true, 4398 + "os": [ 4399 + "linux" 4400 + ], 4401 + "engines": { 4402 + "node": ">= 10" 4403 + } 4404 + }, 4405 + "node_modules/@tauri-apps/cli-linux-riscv64-gnu": { 4406 + "version": "2.10.0", 4407 + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-riscv64-gnu/-/cli-linux-riscv64-gnu-2.10.0.tgz", 4408 + "integrity": "sha512-JO7s3TlSxshwsoKNCDkyvsx5gw2QAs/Y2GbR5UE2d5kkU138ATKoPOtxn8G1fFT1aDW4LH0rYAAfBpGkDyJJnw==", 4409 + "cpu": [ 4410 + "riscv64" 4411 + ], 4412 + "dev": true, 4413 + "license": "Apache-2.0 OR MIT", 4414 + "optional": true, 4415 + "os": [ 4416 + "linux" 4417 + ], 4418 + "engines": { 4419 + "node": ">= 10" 4420 + } 4421 + }, 4422 + "node_modules/@tauri-apps/cli-linux-x64-gnu": { 4423 + "version": "2.10.0", 4424 + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-gnu/-/cli-linux-x64-gnu-2.10.0.tgz", 4425 + "integrity": "sha512-Uvh4SUUp4A6DVRSMWjelww0GnZI3PlVy7VS+DRF5napKuIehVjGl9XD0uKoCoxwAQBLctvipyEK+pDXpJeoHng==", 4426 + "cpu": [ 4427 + "x64" 4428 + ], 4429 + "dev": true, 4430 + "license": "Apache-2.0 OR MIT", 4431 + "optional": true, 4432 + "os": [ 4433 + "linux" 4434 + ], 4435 + "engines": { 4436 + "node": ">= 10" 4437 + } 4438 + }, 4439 + "node_modules/@tauri-apps/cli-linux-x64-musl": { 4440 + "version": "2.10.0", 4441 + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-musl/-/cli-linux-x64-musl-2.10.0.tgz", 4442 + "integrity": "sha512-AP0KRK6bJuTpQ8kMNWvhIpKUkQJfcPFeba7QshOQZjJ8wOS6emwTN4K5g/d3AbCMo0RRdnZWwu67MlmtJyxC1Q==", 4443 + "cpu": [ 4444 + "x64" 4445 + ], 4446 + "dev": true, 4447 + "license": "Apache-2.0 OR MIT", 4448 + "optional": true, 4449 + "os": [ 4450 + "linux" 4451 + ], 4452 + "engines": { 4453 + "node": ">= 10" 4454 + } 4455 + }, 4456 + "node_modules/@tauri-apps/cli-win32-arm64-msvc": { 4457 + "version": "2.10.0", 4458 + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-arm64-msvc/-/cli-win32-arm64-msvc-2.10.0.tgz", 4459 + "integrity": "sha512-97DXVU3dJystrq7W41IX+82JEorLNY+3+ECYxvXWqkq7DBN6FsA08x/EFGE8N/b0LTOui9X2dvpGGoeZKKV08g==", 4460 + "cpu": [ 4461 + "arm64" 4462 + ], 4463 + "dev": true, 4464 + "license": "Apache-2.0 OR MIT", 4465 + "optional": true, 4466 + "os": [ 4467 + "win32" 4468 + ], 4469 + "engines": { 4470 + "node": ">= 10" 4471 + } 4472 + }, 4473 + "node_modules/@tauri-apps/cli-win32-ia32-msvc": { 4474 + "version": "2.10.0", 4475 + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-ia32-msvc/-/cli-win32-ia32-msvc-2.10.0.tgz", 4476 + "integrity": "sha512-EHyQ1iwrWy1CwMalEm9z2a6L5isQ121pe7FcA2xe4VWMJp+GHSDDGvbTv/OPdkt2Lyr7DAZBpZHM6nvlHXEc4A==", 4477 + "cpu": [ 4478 + "ia32" 4479 + ], 4480 + "dev": true, 4481 + "license": "Apache-2.0 OR MIT", 4482 + "optional": true, 4483 + "os": [ 4484 + "win32" 4485 + ], 4486 + "engines": { 4487 + "node": ">= 10" 4488 + } 4489 + }, 4490 + "node_modules/@tauri-apps/cli-win32-x64-msvc": { 4491 + "version": "2.10.0", 4492 + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-x64-msvc/-/cli-win32-x64-msvc-2.10.0.tgz", 4493 + "integrity": "sha512-NTpyQxkpzGmU6ceWBTY2xRIEaS0ZLbVx1HE1zTA3TY/pV3+cPoPPOs+7YScr4IMzXMtOw7tLw5LEXo5oIG3qaQ==", 4494 + "cpu": [ 4495 + "x64" 4496 + ], 4497 + "dev": true, 4498 + "license": "Apache-2.0 OR MIT", 4499 + "optional": true, 4500 + "os": [ 4501 + "win32" 4502 + ], 4503 + "engines": { 4504 + "node": ">= 10" 4505 + } 4506 + }, 4507 + "node_modules/@tauri-apps/plugin-shell": { 4508 + "version": "2.3.5", 4509 + "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-shell/-/plugin-shell-2.3.5.tgz", 4510 + "integrity": "sha512-jewtULhiQ7lI7+owCKAjc8tYLJr92U16bPOeAa472LHJdgaibLP83NcfAF2e+wkEcA53FxKQAZ7byDzs2eeizg==", 4511 + "dev": true, 4512 + "license": "MIT OR Apache-2.0", 4513 + "dependencies": { 4514 + "@tauri-apps/api": "^2.10.1" 4515 + } 4516 + }, 4018 4517 "node_modules/@types/babel__core": { 4019 4518 "version": "7.20.5", 4020 4519 "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", ··· 4527 5026 "engines": { 4528 5027 "node": ">=8.0.0" 4529 5028 } 5029 + }, 5030 + "node_modules/await-lock": { 5031 + "version": "2.2.2", 5032 + "resolved": "https://registry.npmjs.org/await-lock/-/await-lock-2.2.2.tgz", 5033 + "integrity": "sha512-aDczADvlvTGajTDjcjpJMqRkOF6Qdz3YbPZm/PyW6tKPkx2hlYBzxMhEywM/tU72HrVZjgl5VCdRuMlA7pZ8Gw==", 5034 + "license": "MIT" 4530 5035 }, 4531 5036 "node_modules/axios": { 4532 5037 "version": "1.13.5", ··· 5226 5731 "url": "https://opencollective.com/express" 5227 5732 } 5228 5733 }, 5734 + "node_modules/core-js": { 5735 + "version": "3.48.0", 5736 + "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.48.0.tgz", 5737 + "integrity": "sha512-zpEHTy1fjTMZCKLHUZoVeylt9XrzaIN2rbPXEt0k+q7JE5CkCZdo6bNq55bn24a69CH7ErAVLKijxJja4fw+UQ==", 5738 + "hasInstallScript": true, 5739 + "license": "MIT", 5740 + "funding": { 5741 + "type": "opencollective", 5742 + "url": "https://opencollective.com/core-js" 5743 + } 5744 + }, 5229 5745 "node_modules/cross-spawn": { 5230 5746 "version": "7.0.6", 5231 5747 "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", ··· 6407 6923 "url": "https://github.com/sponsors/sindresorhus" 6408 6924 } 6409 6925 }, 6926 + "node_modules/ipaddr.js": { 6927 + "version": "2.3.0", 6928 + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.3.0.tgz", 6929 + "integrity": "sha512-Zv/pA+ciVFbCSBBjGfaKUya/CcGmUHzTydLMaTwrUUEM2DIEO3iZvueGxmacvmN50fGpGVKeTXpb2LcYQxeVdg==", 6930 + "license": "MIT", 6931 + "engines": { 6932 + "node": ">= 10" 6933 + } 6934 + }, 6410 6935 "node_modules/ipns": { 6411 6936 "version": "10.1.3", 6412 6937 "resolved": "https://registry.npmjs.org/ipns/-/ipns-10.1.3.tgz", ··· 8115 8640 "url": "https://github.com/sponsors/sindresorhus" 8116 8641 } 8117 8642 }, 8643 + "node_modules/p2pds-desktop": { 8644 + "resolved": "apps/desktop", 8645 + "link": true 8646 + }, 8118 8647 "node_modules/parseurl": { 8119 8648 "version": "1.3.3", 8120 8649 "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", ··· 9624 10153 "license": "MIT", 9625 10154 "engines": { 9626 10155 "node": ">=14.0.0" 10156 + } 10157 + }, 10158 + "node_modules/tlds": { 10159 + "version": "1.261.0", 10160 + "resolved": "https://registry.npmjs.org/tlds/-/tlds-1.261.0.tgz", 10161 + "integrity": "sha512-QXqwfEl9ddlGBaRFXIvNKK6OhipSiLXuRuLJX5DErz0o0Q0rYxulWLdFryTkV5PkdZct5iMInwYEGe/eR++1AA==", 10162 + "license": "MIT", 10163 + "bin": { 10164 + "tlds": "bin.js" 9627 10165 } 9628 10166 }, 9629 10167 "node_modules/tmpl": {
+5 -1
package.json
··· 20 20 "@atcute/identity-resolver": "^1.2.2", 21 21 "@atcute/lexicons": "^1.2.6", 22 22 "@atcute/tid": "^1.1.1", 23 + "@atproto/api": "^0.18.21", 23 24 "@atproto/crypto": "^0.4.5", 24 25 "@atproto/lex-cbor": "^0.0.3", 25 26 "@atproto/lex-data": "^0.0.3", 26 27 "@atproto/lex-json": "^0.0.11", 28 + "@atproto/oauth-client-node": "^0.3.16", 27 29 "@atproto/repo": "^0.8.12", 28 30 "@hono/node-server": "^1.13.8", 29 31 "@libp2p/gossipsub": "^15.0.12", ··· 46 48 "vitest": "^3.0.0" 47 49 }, 48 50 "license": "MIT", 49 - "workspaces": ["apps/*"] 51 + "workspaces": [ 52 + "apps/*" 53 + ] 50 54 }
+3
src/config.ts
··· 34 34 RATE_LIMIT_CHALLENGE_PER_MIN: number; 35 35 RATE_LIMIT_MAX_CONNECTIONS: number; 36 36 RATE_LIMIT_FIREHOSE_PER_IP: number; 37 + /** Whether OAuth login is enabled for remote PDS publishing (default false). */ 38 + OAUTH_ENABLED: boolean; 37 39 } 38 40 39 41 const REQUIRED_KEYS = [ ··· 128 130 RATE_LIMIT_CHALLENGE_PER_MIN: parseInt(process.env.RATE_LIMIT_CHALLENGE_PER_MIN ?? "20", 10), 129 131 RATE_LIMIT_MAX_CONNECTIONS: parseInt(process.env.RATE_LIMIT_MAX_CONNECTIONS ?? "100", 10), 130 132 RATE_LIMIT_FIREHOSE_PER_IP: parseInt(process.env.RATE_LIMIT_FIREHOSE_PER_IP ?? "3", 10), 133 + OAUTH_ENABLED: process.env.OAUTH_ENABLED === "true", 131 134 }; 132 135 } 133 136
+12
src/index.ts
··· 25 25 import type { StorageChallenge } from "./replication/challenge-response/types.js"; 26 26 import { MAX_RECORD_PATHS, MAX_BLOCK_CIDS } from "./replication/challenge-response/types.js"; 27 27 import { generateMstProof } from "./replication/mst-proof.js"; 28 + import { registerOAuthRoutes } from "./oauth/routes.js"; 29 + import type { OAuthClientManager } from "./oauth/client.js"; 30 + import type { PdsClient } from "./oauth/pds-client.js"; 28 31 29 32 const VERSION = "0.1.0"; 30 33 ··· 42 45 replicatedRepoReader?: ReplicatedRepoReader, 43 46 repoManager?: RepoManager, 44 47 rateLimiter?: RateLimiter, 48 + oauthClientManager?: OAuthClientManager, 49 + pdsClient?: PdsClient, 45 50 ) { 46 51 const configDid = config.DID ?? ""; 47 52 ··· 65 70 maxAge: 86400, 66 71 }), 67 72 ); 73 + 74 + // ============================================ 75 + // OAuth routes (before auth middleware — these handle browser redirect flow) 76 + // ============================================ 77 + if (oauthClientManager && pdsClient) { 78 + registerOAuthRoutes(app, config, oauthClientManager.client, pdsClient, networkService); 79 + } 68 80 69 81 // ============================================ 70 82 // Rate limit + body size middleware (per route group)
+1
src/ipfs.test.ts
··· 51 51 RATE_LIMIT_CHALLENGE_PER_MIN: 20, 52 52 RATE_LIMIT_MAX_CONNECTIONS: 100, 53 53 RATE_LIMIT_FIREHOSE_PER_IP: 3, 54 + OAUTH_ENABLED: false, 54 55 }; 55 56 } 56 57
+54
src/oauth/client.ts
··· 1 + /** 2 + * OAuth client setup for AT Protocol authentication. 3 + * 4 + * Uses loopback client_id format per the AT Protocol OAuth spec: 5 + * authorization servers provide virtual metadata for http://localhost clients. 6 + */ 7 + 8 + import { NodeOAuthClient } from "@atproto/oauth-client-node"; 9 + import type Database from "better-sqlite3"; 10 + import type { Config } from "../config.js"; 11 + import { OAuthStateStore, OAuthSessionStore } from "./stores.js"; 12 + 13 + export interface OAuthClientManager { 14 + client: NodeOAuthClient; 15 + stateStore: OAuthStateStore; 16 + sessionStore: OAuthSessionStore; 17 + } 18 + 19 + export async function createOAuthClient( 20 + db: Database.Database, 21 + config: Config, 22 + ): Promise<OAuthClientManager> { 23 + const stateStore = new OAuthStateStore(db); 24 + const sessionStore = new OAuthSessionStore(db); 25 + 26 + const redirectUri = `http://127.0.0.1:${config.PORT}/oauth/callback`; 27 + const scope = "atproto transition:generic"; 28 + 29 + // Loopback client_id: http://localhost with redirect_uri and scope as query params. 30 + // AT Protocol authorization servers provide virtual metadata for this format. 31 + const clientId = 32 + `http://localhost?redirect_uri=${encodeURIComponent(redirectUri)}&scope=${encodeURIComponent(scope)}`; 33 + 34 + const client = new NodeOAuthClient({ 35 + clientMetadata: { 36 + client_id: clientId, 37 + client_name: "p2pds", 38 + client_uri: `http://127.0.0.1:${config.PORT}` as `http://127.0.0.1:${string}`, 39 + redirect_uris: [redirectUri as `http://127.0.0.1:${string}`], 40 + scope, 41 + grant_types: ["authorization_code", "refresh_token"], 42 + response_types: ["code"], 43 + token_endpoint_auth_method: "none", 44 + application_type: "native", 45 + dpop_bound_access_tokens: true, 46 + }, 47 + stateStore, 48 + sessionStore, 49 + // Allow HTTP for loopback development 50 + allowHttp: true, 51 + }); 52 + 53 + return { client, stateStore, sessionStore }; 54 + }
+74
src/oauth/pds-client.test.ts
··· 1 + import { describe, it, expect, vi } from "vitest"; 2 + import { PdsClient } from "./pds-client.js"; 3 + import type { NodeOAuthClient } from "@atproto/oauth-client-node"; 4 + 5 + function createMockOAuthClient(options?: { 6 + restoreFails?: boolean; 7 + }): { oauthClient: NodeOAuthClient; restoreSpy: ReturnType<typeof vi.fn> } { 8 + const mockSession = { 9 + did: "did:plc:testuser", 10 + fetchHandler: vi.fn().mockResolvedValue( 11 + new Response("{}", { status: 200, headers: { "content-type": "application/json" } }), 12 + ), 13 + }; 14 + 15 + const restoreSpy = options?.restoreFails 16 + ? vi.fn().mockRejectedValue(new Error("No session")) 17 + : vi.fn().mockResolvedValue(mockSession); 18 + 19 + const oauthClient = { restore: restoreSpy } as unknown as NodeOAuthClient; 20 + return { oauthClient, restoreSpy }; 21 + } 22 + 23 + describe("PdsClient", () => { 24 + it("hasSession returns true when restore succeeds", async () => { 25 + const { oauthClient } = createMockOAuthClient(); 26 + const client = new PdsClient(oauthClient, "did:plc:testuser"); 27 + expect(await client.hasSession()).toBe(true); 28 + }); 29 + 30 + it("hasSession returns false when restore fails", async () => { 31 + const { oauthClient } = createMockOAuthClient({ restoreFails: true }); 32 + const client = new PdsClient(oauthClient, "did:plc:testuser"); 33 + expect(await client.hasSession()).toBe(false); 34 + }); 35 + 36 + it("getAgent restores session and returns Agent", async () => { 37 + const { oauthClient, restoreSpy } = createMockOAuthClient(); 38 + const client = new PdsClient(oauthClient, "did:plc:testuser"); 39 + 40 + const agent = await client.getAgent(); 41 + expect(agent).toBeDefined(); 42 + expect(agent.did).toBe("did:plc:testuser"); 43 + expect(restoreSpy).toHaveBeenCalledWith("did:plc:testuser"); 44 + }); 45 + 46 + it("getAgent caches agent across calls", async () => { 47 + const { oauthClient, restoreSpy } = createMockOAuthClient(); 48 + const client = new PdsClient(oauthClient, "did:plc:testuser"); 49 + 50 + const agent1 = await client.getAgent(); 51 + const agent2 = await client.getAgent(); 52 + expect(agent1).toBe(agent2); // same instance 53 + expect(restoreSpy).toHaveBeenCalledTimes(1); 54 + }); 55 + 56 + it("clearAgent forces re-restore on next getAgent call", async () => { 57 + const { oauthClient, restoreSpy } = createMockOAuthClient(); 58 + const client = new PdsClient(oauthClient, "did:plc:testuser"); 59 + 60 + await client.getAgent(); 61 + expect(restoreSpy).toHaveBeenCalledTimes(1); 62 + 63 + client.clearAgent(); 64 + 65 + await client.getAgent(); 66 + expect(restoreSpy).toHaveBeenCalledTimes(2); 67 + }); 68 + 69 + it("getAgent throws when no session", async () => { 70 + const { oauthClient } = createMockOAuthClient({ restoreFails: true }); 71 + const client = new PdsClient(oauthClient, "did:plc:testuser"); 72 + await expect(client.getAgent()).rejects.toThrow("No session"); 73 + }); 74 + });
+100
src/oauth/pds-client.ts
··· 1 + /** 2 + * Remote PDS record operations via authenticated OAuth session. 3 + * 4 + * PdsClient wraps @atproto/api Agent to provide the same record 5 + * operations that RepoManager exposes, making it a drop-in 6 + * replacement for remote publishing (offers, peer records). 7 + */ 8 + 9 + import { Agent } from "@atproto/api"; 10 + import type { NodeOAuthClient } from "@atproto/oauth-client-node"; 11 + import type { RecordWriter } from "../replication/offer-manager.js"; 12 + 13 + export class PdsClient implements RecordWriter { 14 + private agent: Agent | null = null; 15 + 16 + constructor( 17 + private oauthClient: NodeOAuthClient, 18 + private did: string, 19 + ) {} 20 + 21 + /** 22 + * Get or restore an authenticated Agent. 23 + * The OAuth library handles token refresh automatically. 24 + */ 25 + async getAgent(): Promise<Agent> { 26 + if (this.agent) return this.agent; 27 + 28 + const session = await this.oauthClient.restore(this.did); 29 + this.agent = new Agent(session); 30 + return this.agent; 31 + } 32 + 33 + /** 34 + * Check if we have a valid session (can be restored). 35 + */ 36 + async hasSession(): Promise<boolean> { 37 + try { 38 + await this.oauthClient.restore(this.did); 39 + return true; 40 + } catch { 41 + return false; 42 + } 43 + } 44 + 45 + /** 46 + * Clear the cached agent (e.g., on session error). 47 + */ 48 + clearAgent(): void { 49 + this.agent = null; 50 + } 51 + 52 + async putRecord( 53 + collection: string, 54 + rkey: string, 55 + record: unknown, 56 + ): Promise<unknown> { 57 + const agent = await this.getAgent(); 58 + const result = await agent.com.atproto.repo.putRecord({ 59 + repo: this.did, 60 + collection, 61 + rkey, 62 + record: record as Record<string, unknown>, 63 + }); 64 + return result.data; 65 + } 66 + 67 + async deleteRecord( 68 + collection: string, 69 + rkey: string, 70 + ): Promise<unknown> { 71 + const agent = await this.getAgent(); 72 + const result = await agent.com.atproto.repo.deleteRecord({ 73 + repo: this.did, 74 + collection, 75 + rkey, 76 + }); 77 + return result.data; 78 + } 79 + 80 + async listRecords( 81 + collection: string, 82 + opts: { limit: number }, 83 + ): Promise<{ 84 + records: Array<{ uri: string; cid: string; value: unknown }>; 85 + }> { 86 + const agent = await this.getAgent(); 87 + const result = await agent.com.atproto.repo.listRecords({ 88 + repo: this.did, 89 + collection, 90 + limit: opts.limit, 91 + }); 92 + return { 93 + records: result.data.records.map((r) => ({ 94 + uri: r.uri, 95 + cid: r.cid, 96 + value: r.value, 97 + })), 98 + }; 99 + } 100 + }
+190
src/oauth/routes.ts
··· 1 + /** 2 + * OAuth HTTP routes for browser-based login flow. 3 + * 4 + * GET /oauth/login?handle=alice.bsky.social — Start OAuth flow 5 + * GET /oauth/callback?code=...&state=...&iss=... — Exchange code for session 6 + * GET /oauth/status — JSON session status 7 + */ 8 + 9 + import type { Hono } from "hono"; 10 + import type { NodeOAuthClient } from "@atproto/oauth-client-node"; 11 + import type { Config } from "../config.js"; 12 + import type { PdsClient } from "./pds-client.js"; 13 + import type { NetworkService } from "../ipfs.js"; 14 + 15 + // eslint-disable-next-line @typescript-eslint/no-explicit-any 16 + export function registerOAuthRoutes( 17 + app: Hono<any>, 18 + config: Config, 19 + oauthClient: NodeOAuthClient, 20 + pdsClient: PdsClient, 21 + networkService?: NetworkService, 22 + ): void { 23 + /** 24 + * Start OAuth login flow. 25 + * Redirects the user's browser to their PDS authorization endpoint. 26 + */ 27 + app.get("/oauth/login", async (c) => { 28 + const handle = c.req.query("handle"); 29 + if (!handle) { 30 + return c.json( 31 + { error: "MissingParameter", message: "handle query parameter is required" }, 32 + 400, 33 + ); 34 + } 35 + 36 + try { 37 + const authUrl = await oauthClient.authorize(handle, { 38 + scope: "atproto transition:generic", 39 + }); 40 + return c.redirect(authUrl.toString()); 41 + } catch (err) { 42 + const message = err instanceof Error ? err.message : String(err); 43 + return c.json( 44 + { error: "AuthorizationFailed", message }, 45 + 500, 46 + ); 47 + } 48 + }); 49 + 50 + /** 51 + * OAuth callback — exchange authorization code for session. 52 + * Shows a simple HTML success/error page. 53 + */ 54 + app.get("/oauth/callback", async (c) => { 55 + const params = new URLSearchParams(c.req.url.split("?")[1] ?? ""); 56 + 57 + try { 58 + const { session } = await oauthClient.callback(params); 59 + const did = session.did; 60 + 61 + // Enforce DID match if configured 62 + if (config.DID && did !== config.DID) { 63 + return c.html(errorPage( 64 + "DID Mismatch", 65 + `Authenticated as ${did} but this node is configured for ${config.DID}. Please log in with the correct account.`, 66 + ), 403); 67 + } 68 + 69 + // Publish peer record on successful auth 70 + try { 71 + await publishPeerRecord(pdsClient, networkService); 72 + } catch (err) { 73 + console.warn( 74 + "[oauth] Failed to publish peer record:", 75 + err instanceof Error ? err.message : String(err), 76 + ); 77 + } 78 + 79 + return c.html(successPage(did)); 80 + } catch (err) { 81 + const message = err instanceof Error ? err.message : String(err); 82 + return c.html(errorPage("Authentication Failed", message), 500); 83 + } 84 + }); 85 + 86 + /** 87 + * Session status endpoint for dashboard polling. 88 + */ 89 + app.get("/oauth/status", async (c) => { 90 + try { 91 + const hasSession = await pdsClient.hasSession(); 92 + return c.json({ 93 + authenticated: hasSession, 94 + did: hasSession ? config.DID : null, 95 + }); 96 + } catch { 97 + return c.json({ authenticated: false, did: null }); 98 + } 99 + }); 100 + } 101 + 102 + /** 103 + * Publish org.p2pds.peer/self record to the user's PDS. 104 + */ 105 + async function publishPeerRecord( 106 + pdsClient: PdsClient, 107 + networkService?: NetworkService, 108 + ): Promise<void> { 109 + const peerId = networkService?.getPeerId() ?? null; 110 + const multiaddrs = networkService?.getMultiaddrs() ?? []; 111 + 112 + await pdsClient.putRecord("org.p2pds.peer", "self", { 113 + $type: "org.p2pds.peer", 114 + peerId, 115 + multiaddrs, 116 + createdAt: new Date().toISOString(), 117 + }); 118 + } 119 + 120 + function successPage(did: string): string { 121 + return `<!DOCTYPE html> 122 + <html lang="en"> 123 + <head> 124 + <meta charset="utf-8"> 125 + <meta name="viewport" content="width=device-width, initial-scale=1"> 126 + <title>P2PDS - Connected</title> 127 + <style> 128 + * { margin: 0; padding: 0; box-sizing: border-box; } 129 + body { 130 + min-height: 100vh; display: flex; flex-direction: column; 131 + justify-content: center; align-items: center; 132 + font-family: ui-monospace, 'Cascadia Code', 'Source Code Pro', Menlo, Consolas, monospace; 133 + background: #f0f0f0; color: #000; padding: 2rem; 134 + } 135 + .card { background: #fff; border-radius: 8px; padding: 2rem; max-width: 400px; text-align: center; box-shadow: 0 2px 8px rgba(0,0,0,0.1); } 136 + .status { font-size: 1.5rem; margin-bottom: 1rem; } 137 + .did { font-size: 0.8rem; color: #666; word-break: break-all; margin-bottom: 1.5rem; } 138 + a { color: #000; text-decoration: none; border: 1px solid #000; padding: 0.5rem 1rem; border-radius: 4px; } 139 + a:hover { background: #000; color: #fff; } 140 + </style> 141 + </head> 142 + <body> 143 + <div class="card"> 144 + <div class="status">Connected</div> 145 + <div class="did">${escapeHtml(did)}</div> 146 + <a href="/xrpc/org.p2pds.admin.dashboard">Back to Dashboard</a> 147 + </div> 148 + </body> 149 + </html>`; 150 + } 151 + 152 + function errorPage(title: string, message: string): string { 153 + return `<!DOCTYPE html> 154 + <html lang="en"> 155 + <head> 156 + <meta charset="utf-8"> 157 + <meta name="viewport" content="width=device-width, initial-scale=1"> 158 + <title>P2PDS - Error</title> 159 + <style> 160 + * { margin: 0; padding: 0; box-sizing: border-box; } 161 + body { 162 + min-height: 100vh; display: flex; flex-direction: column; 163 + justify-content: center; align-items: center; 164 + font-family: ui-monospace, 'Cascadia Code', 'Source Code Pro', Menlo, Consolas, monospace; 165 + background: #f0f0f0; color: #000; padding: 2rem; 166 + } 167 + .card { background: #fff; border-radius: 8px; padding: 2rem; max-width: 400px; text-align: center; box-shadow: 0 2px 8px rgba(0,0,0,0.1); } 168 + .status { font-size: 1.2rem; margin-bottom: 1rem; color: #ef4444; } 169 + .message { font-size: 0.85rem; color: #666; margin-bottom: 1.5rem; word-break: break-word; } 170 + a { color: #000; text-decoration: none; border: 1px solid #000; padding: 0.5rem 1rem; border-radius: 4px; } 171 + a:hover { background: #000; color: #fff; } 172 + </style> 173 + </head> 174 + <body> 175 + <div class="card"> 176 + <div class="status">${escapeHtml(title)}</div> 177 + <div class="message">${escapeHtml(message)}</div> 178 + <a href="/xrpc/org.p2pds.admin.dashboard">Back to Dashboard</a> 179 + </div> 180 + </body> 181 + </html>`; 182 + } 183 + 184 + function escapeHtml(s: string): string { 185 + return s 186 + .replace(/&/g, "&amp;") 187 + .replace(/</g, "&lt;") 188 + .replace(/>/g, "&gt;") 189 + .replace(/"/g, "&quot;"); 190 + }
+170
src/oauth/stores.test.ts
··· 1 + import { describe, it, expect, beforeEach, afterEach } from "vitest"; 2 + import { mkdtempSync, rmSync } from "node:fs"; 3 + import { tmpdir } from "node:os"; 4 + import { join } from "node:path"; 5 + import Database from "better-sqlite3"; 6 + import { OAuthStateStore, OAuthSessionStore } from "./stores.js"; 7 + import type { NodeSavedState, NodeSavedSession } from "@atproto/oauth-client-node"; 8 + 9 + describe("OAuthStateStore", () => { 10 + let tmpDir: string; 11 + let db: InstanceType<typeof Database>; 12 + let store: OAuthStateStore; 13 + 14 + beforeEach(() => { 15 + tmpDir = mkdtempSync(join(tmpdir(), "oauth-state-test-")); 16 + db = new Database(join(tmpDir, "test.db")); 17 + db.pragma("journal_mode = WAL"); 18 + store = new OAuthStateStore(db); 19 + }); 20 + 21 + afterEach(() => { 22 + db.close(); 23 + rmSync(tmpDir, { recursive: true, force: true }); 24 + }); 25 + 26 + it("get returns undefined for missing key", async () => { 27 + const result = await store.get("nonexistent"); 28 + expect(result).toBeUndefined(); 29 + }); 30 + 31 + it("set and get round-trip", async () => { 32 + const state: NodeSavedState = { 33 + dpopJwk: { kty: "EC", crv: "P-256", x: "test-x", y: "test-y" }, 34 + iss: "https://bsky.social", 35 + verifier: "test-verifier-123", 36 + appState: undefined, 37 + } as unknown as NodeSavedState; 38 + 39 + await store.set("state-key-1", state); 40 + const result = await store.get("state-key-1"); 41 + 42 + expect(result).toBeDefined(); 43 + expect((result as any).iss).toBe("https://bsky.social"); 44 + expect((result as any).verifier).toBe("test-verifier-123"); 45 + }); 46 + 47 + it("set overwrites existing value", async () => { 48 + const state1 = { iss: "https://pds1.example.com", verifier: "v1" } as unknown as NodeSavedState; 49 + const state2 = { iss: "https://pds2.example.com", verifier: "v2" } as unknown as NodeSavedState; 50 + 51 + await store.set("key", state1); 52 + await store.set("key", state2); 53 + 54 + const result = await store.get("key"); 55 + expect((result as any).iss).toBe("https://pds2.example.com"); 56 + }); 57 + 58 + it("del removes entry", async () => { 59 + const state = { iss: "https://example.com" } as unknown as NodeSavedState; 60 + await store.set("key", state); 61 + await store.del("key"); 62 + 63 + const result = await store.get("key"); 64 + expect(result).toBeUndefined(); 65 + }); 66 + 67 + it("del on missing key is a no-op", async () => { 68 + // Should not throw 69 + await store.del("nonexistent"); 70 + }); 71 + 72 + it("creates table on construction", () => { 73 + const tables = db 74 + .prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='oauth_state'") 75 + .all(); 76 + expect(tables).toHaveLength(1); 77 + }); 78 + }); 79 + 80 + describe("OAuthSessionStore", () => { 81 + let tmpDir: string; 82 + let db: InstanceType<typeof Database>; 83 + let store: OAuthSessionStore; 84 + 85 + beforeEach(() => { 86 + tmpDir = mkdtempSync(join(tmpdir(), "oauth-session-test-")); 87 + db = new Database(join(tmpDir, "test.db")); 88 + db.pragma("journal_mode = WAL"); 89 + store = new OAuthSessionStore(db); 90 + }); 91 + 92 + afterEach(() => { 93 + db.close(); 94 + rmSync(tmpDir, { recursive: true, force: true }); 95 + }); 96 + 97 + it("get returns undefined for missing DID", async () => { 98 + const result = await store.get("did:plc:nonexistent"); 99 + expect(result).toBeUndefined(); 100 + }); 101 + 102 + it("set and get round-trip with DID key", async () => { 103 + const session: NodeSavedSession = { 104 + dpopJwk: { kty: "EC", crv: "P-256", x: "x", y: "y" }, 105 + tokenSet: { 106 + access_token: "at-123", 107 + token_type: "DPoP", 108 + sub: "did:plc:testuser", 109 + }, 110 + } as unknown as NodeSavedSession; 111 + 112 + await store.set("did:plc:testuser", session); 113 + const result = await store.get("did:plc:testuser"); 114 + 115 + expect(result).toBeDefined(); 116 + expect((result as any).tokenSet.sub).toBe("did:plc:testuser"); 117 + }); 118 + 119 + it("set overwrites existing session", async () => { 120 + const session1 = { tokenSet: { access_token: "old" } } as unknown as NodeSavedSession; 121 + const session2 = { tokenSet: { access_token: "new" } } as unknown as NodeSavedSession; 122 + 123 + await store.set("did:plc:user", session1); 124 + await store.set("did:plc:user", session2); 125 + 126 + const result = await store.get("did:plc:user"); 127 + expect((result as any).tokenSet.access_token).toBe("new"); 128 + }); 129 + 130 + it("del removes session", async () => { 131 + const session = { tokenSet: { access_token: "token" } } as unknown as NodeSavedSession; 132 + await store.set("did:plc:user", session); 133 + await store.del("did:plc:user"); 134 + 135 + const result = await store.get("did:plc:user"); 136 + expect(result).toBeUndefined(); 137 + }); 138 + 139 + it("creates table on construction", () => { 140 + const tables = db 141 + .prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='oauth_session'") 142 + .all(); 143 + expect(tables).toHaveLength(1); 144 + }); 145 + 146 + it("multiple sessions for different DIDs", async () => { 147 + const s1 = { tokenSet: { sub: "did:plc:a" } } as unknown as NodeSavedSession; 148 + const s2 = { tokenSet: { sub: "did:plc:b" } } as unknown as NodeSavedSession; 149 + 150 + await store.set("did:plc:a", s1); 151 + await store.set("did:plc:b", s2); 152 + 153 + const r1 = await store.get("did:plc:a"); 154 + const r2 = await store.get("did:plc:b"); 155 + 156 + expect((r1 as any).tokenSet.sub).toBe("did:plc:a"); 157 + expect((r2 as any).tokenSet.sub).toBe("did:plc:b"); 158 + }); 159 + 160 + it("shares database with other tables", () => { 161 + // Create state store on same db — should not conflict 162 + const stateStore = new OAuthStateStore(db); 163 + const tables = db 164 + .prepare("SELECT name FROM sqlite_master WHERE type='table' ORDER BY name") 165 + .all() as { name: string }[]; 166 + const names = tables.map((t) => t.name); 167 + expect(names).toContain("oauth_state"); 168 + expect(names).toContain("oauth_session"); 169 + }); 170 + });
+75
src/oauth/stores.ts
··· 1 + /** 2 + * SQLite-backed stores for OAuth state and session persistence. 3 + * 4 + * OAuthStateStore: ephemeral auth flow state (PKCE, DPoP). 5 + * OAuthSessionStore: persistent sessions keyed by DID (sub). 6 + * 7 + * Both implement SimpleStore<string, V> from @atproto-labs/simple-store. 8 + */ 9 + 10 + import type Database from "better-sqlite3"; 11 + import type { NodeSavedState, NodeSavedStateStore, NodeSavedSession, NodeSavedSessionStore } from "@atproto/oauth-client-node"; 12 + 13 + export class OAuthStateStore implements NodeSavedStateStore { 14 + constructor(private db: Database.Database) { 15 + this.db.exec(` 16 + CREATE TABLE IF NOT EXISTS oauth_state ( 17 + key TEXT PRIMARY KEY, 18 + data TEXT NOT NULL, 19 + created_at TEXT NOT NULL DEFAULT (datetime('now')) 20 + ) 21 + `); 22 + } 23 + 24 + async get(key: string): Promise<NodeSavedState | undefined> { 25 + const row = this.db 26 + .prepare("SELECT data FROM oauth_state WHERE key = ?") 27 + .get(key) as { data: string } | undefined; 28 + if (!row) return undefined; 29 + return JSON.parse(row.data) as NodeSavedState; 30 + } 31 + 32 + async set(key: string, value: NodeSavedState): Promise<void> { 33 + this.db 34 + .prepare( 35 + "INSERT OR REPLACE INTO oauth_state (key, data, created_at) VALUES (?, ?, datetime('now'))", 36 + ) 37 + .run(key, JSON.stringify(value)); 38 + } 39 + 40 + async del(key: string): Promise<void> { 41 + this.db.prepare("DELETE FROM oauth_state WHERE key = ?").run(key); 42 + } 43 + } 44 + 45 + export class OAuthSessionStore implements NodeSavedSessionStore { 46 + constructor(private db: Database.Database) { 47 + this.db.exec(` 48 + CREATE TABLE IF NOT EXISTS oauth_session ( 49 + sub TEXT PRIMARY KEY, 50 + data TEXT NOT NULL, 51 + updated_at TEXT NOT NULL DEFAULT (datetime('now')) 52 + ) 53 + `); 54 + } 55 + 56 + async get(key: string): Promise<NodeSavedSession | undefined> { 57 + const row = this.db 58 + .prepare("SELECT data FROM oauth_session WHERE sub = ?") 59 + .get(key) as { data: string } | undefined; 60 + if (!row) return undefined; 61 + return JSON.parse(row.data) as NodeSavedSession; 62 + } 63 + 64 + async set(key: string, value: NodeSavedSession): Promise<void> { 65 + this.db 66 + .prepare( 67 + "INSERT OR REPLACE INTO oauth_session (sub, data, updated_at) VALUES (?, ?, datetime('now'))", 68 + ) 69 + .run(key, JSON.stringify(value)); 70 + } 71 + 72 + async del(key: string): Promise<void> { 73 + this.db.prepare("DELETE FROM oauth_session WHERE sub = ?").run(key); 74 + } 75 + }
+1
src/replication/challenge-response/challenge-response.test.ts
··· 43 43 RATE_LIMIT_CHALLENGE_PER_MIN: 20, 44 44 RATE_LIMIT_MAX_CONNECTIONS: 100, 45 45 RATE_LIMIT_FIREHOSE_PER_IP: 3, 46 + OAUTH_ENABLED: false, 46 47 }; 47 48 } 48 49
+1
src/replication/challenge-response/e2e-challenge.test.ts
··· 51 51 RATE_LIMIT_CHALLENGE_PER_MIN: 20, 52 52 RATE_LIMIT_MAX_CONNECTIONS: 100, 53 53 RATE_LIMIT_FIREHOSE_PER_IP: 3, 54 + OAUTH_ENABLED: false, 54 55 }; 55 56 } 56 57
+1
src/replication/e2e-multi-node.test.ts
··· 56 56 RATE_LIMIT_CHALLENGE_PER_MIN: 20, 57 57 RATE_LIMIT_MAX_CONNECTIONS: 100, 58 58 RATE_LIMIT_FIREHOSE_PER_IP: 3, 59 + OAUTH_ENABLED: false, 59 60 }; 60 61 } 61 62
+1
src/replication/firehose-incremental.test.ts
··· 70 70 RATE_LIMIT_CHALLENGE_PER_MIN: 20, 71 71 RATE_LIMIT_MAX_CONNECTIONS: 100, 72 72 RATE_LIMIT_FIREHOSE_PER_IP: 3, 73 + OAUTH_ENABLED: false, 73 74 }; 74 75 } 75 76
+3
src/replication/gossipsub-notifications.test.ts
··· 305 305 RATE_LIMIT_CHALLENGE_PER_MIN: 20, 306 306 RATE_LIMIT_MAX_CONNECTIONS: 100, 307 307 RATE_LIMIT_FIREHOSE_PER_IP: 3, 308 + OAUTH_ENABLED: false, 308 309 }; 309 310 310 311 const { RepoManager } = await import("../repo-manager.js"); ··· 371 372 RATE_LIMIT_CHALLENGE_PER_MIN: 20, 372 373 RATE_LIMIT_MAX_CONNECTIONS: 100, 373 374 RATE_LIMIT_FIREHOSE_PER_IP: 3, 375 + OAUTH_ENABLED: false, 374 376 }; 375 377 376 378 const { RepoManager } = await import("../repo-manager.js"); ··· 459 461 RATE_LIMIT_CHALLENGE_PER_MIN: 20, 460 462 RATE_LIMIT_MAX_CONNECTIONS: 100, 461 463 RATE_LIMIT_FIREHOSE_PER_IP: 3, 464 + OAUTH_ENABLED: false, 462 465 }; 463 466 464 467 const { RepoManager } = await import("../repo-manager.js");
+1
src/replication/mst-proof.test.ts
··· 36 36 RATE_LIMIT_CHALLENGE_PER_MIN: 20, 37 37 RATE_LIMIT_MAX_CONNECTIONS: 100, 38 38 RATE_LIMIT_FIREHOSE_PER_IP: 3, 39 + OAUTH_ENABLED: false, 39 40 }; 40 41 } 41 42
+1
src/replication/offer-manager.test.ts
··· 38 38 RATE_LIMIT_CHALLENGE_PER_MIN: 20, 39 39 RATE_LIMIT_MAX_CONNECTIONS: 100, 40 40 RATE_LIMIT_FIREHOSE_PER_IP: 3, 41 + OAUTH_ENABLED: false, 41 42 }; 42 43 } 43 44
+16 -5
src/replication/offer-manager.ts
··· 3 3 * and policy generation from mutual agreements. 4 4 */ 5 5 6 - import type { RepoManager } from "../repo-manager.js"; 7 6 import type { PeerDiscovery } from "./peer-discovery.js"; 8 7 import type { PolicyEngine } from "../policy/engine.js"; 9 8 import type { Policy } from "../policy/types.js"; ··· 13 12 type OfferRecord, 14 13 } from "./types.js"; 15 14 15 + /** 16 + * Interface for record read/write operations. 17 + * Both RepoManager (local) and PdsClient (remote) satisfy this interface. 18 + */ 19 + export interface RecordWriter { 20 + putRecord(collection: string, rkey: string, record: unknown): Promise<unknown>; 21 + deleteRecord(collection: string, rkey: string): Promise<unknown>; 22 + listRecords(collection: string, opts: { limit: number }): Promise<{ 23 + records: Array<{ uri: string; cid: string; value: unknown }>; 24 + }>; 25 + } 26 + 16 27 /** A detected mutual replication agreement between two peers. */ 17 28 export interface Agreement { 18 29 counterpartyDid: string; ··· 26 37 27 38 export class OfferManager { 28 39 constructor( 29 - private repoManager: RepoManager, 40 + private recordWriter: RecordWriter, 30 41 private peerDiscovery: PeerDiscovery, 31 42 private policyEngine: PolicyEngine, 32 43 private localDid: string, ··· 48 59 createdAt: new Date().toISOString(), 49 60 }; 50 61 51 - await this.repoManager.putRecord(OFFER_NSID, didToRkey(subject), record); 62 + await this.recordWriter.putRecord(OFFER_NSID, didToRkey(subject), record); 52 63 return record; 53 64 } 54 65 ··· 56 67 * Revoke a replication offer and remove any derived policy. 57 68 */ 58 69 async revokeOffer(subject: string): Promise<void> { 59 - await this.repoManager.deleteRecord(OFFER_NSID, didToRkey(subject)); 70 + await this.recordWriter.deleteRecord(OFFER_NSID, didToRkey(subject)); 60 71 // Remove the P2P policy derived from this offer 61 72 this.policyEngine.removePolicy(`${P2P_POLICY_PREFIX}${subject}`); 62 73 } ··· 65 76 * List all local offers from our repo. 66 77 */ 67 78 async getLocalOffers(): Promise<OfferRecord[]> { 68 - const result = await this.repoManager.listRecords(OFFER_NSID, { 79 + const result = await this.recordWriter.listRecords(OFFER_NSID, { 69 80 limit: 100, 70 81 }); 71 82 return result.records
+1
src/replication/peer-freshness.test.ts
··· 52 52 RATE_LIMIT_CHALLENGE_PER_MIN: 20, 53 53 RATE_LIMIT_MAX_CONNECTIONS: 100, 54 54 RATE_LIMIT_FIREHOSE_PER_IP: 3, 55 + OAUTH_ENABLED: false, 55 56 }; 56 57 } 57 58
+1
src/replication/policy-integration.test.ts
··· 60 60 RATE_LIMIT_CHALLENGE_PER_MIN: 20, 61 61 RATE_LIMIT_MAX_CONNECTIONS: 100, 62 62 RATE_LIMIT_FIREHOSE_PER_IP: 3, 63 + OAUTH_ENABLED: false, 63 64 }; 64 65 } 65 66
+5 -2
src/replication/replication-manager.ts
··· 38 38 import { ChallengeScheduler } from "./challenge-response/challenge-scheduler.js"; 39 39 import { ChallengeStorage, type ChallengeHistoryRow, type PeerReliabilityRow } from "./challenge-response/challenge-storage.js"; 40 40 import type { ChallengeTransport } from "./challenge-response/transport.js"; 41 - import { OfferManager } from "./offer-manager.js"; 41 + import { OfferManager, type RecordWriter } from "./offer-manager.js"; 42 42 43 43 /** How old cached peer info can be before re-fetching (1 hour). */ 44 44 const PEER_INFO_TTL_MS = 60 * 60 * 1000; ··· 83 83 verificationConfig?: Partial<VerificationConfig>, 84 84 private replicatedRepoReader?: ReplicatedRepoReader, 85 85 policyEngine?: PolicyEngine, 86 + pdsClient?: RecordWriter, 86 87 ) { 87 88 this.syncStorage = new SyncStorage(db); 88 89 this.challengeStorage = new ChallengeStorage(db); ··· 99 100 ); 100 101 if (policyEngine) { 101 102 this.policyEngine = policyEngine; 103 + // Prefer remote PDS client for offer records; fall back to local repo 104 + const recordWriter: RecordWriter = pdsClient ?? repoManager; 102 105 this.offerManager = new OfferManager( 103 - repoManager, 106 + recordWriter, 104 107 this.peerDiscovery, 105 108 policyEngine, 106 109 config.DID ?? "",
+1
src/replication/replication.test.ts
··· 63 63 RATE_LIMIT_CHALLENGE_PER_MIN: 20, 64 64 RATE_LIMIT_MAX_CONNECTIONS: 100, 65 65 RATE_LIMIT_FIREHOSE_PER_IP: 3, 66 + OAUTH_ENABLED: false, 66 67 }; 67 68 } 68 69
+22
src/server.ts
··· 23 23 import type { ChallengeTransport } from "./replication/challenge-response/transport.js"; 24 24 import type { Libp2p } from "@libp2p/interface"; 25 25 import { RateLimiter } from "./rate-limiter.js"; 26 + import { createOAuthClient, type OAuthClientManager } from "./oauth/client.js"; 27 + import { PdsClient } from "./oauth/pds-client.js"; 26 28 27 29 // Load configuration 28 30 const config = loadConfig(); ··· 86 88 } 87 89 } 88 90 91 + // Initialize OAuth client (if enabled) 92 + let oauthClientManager: OAuthClientManager | undefined; 93 + let pdsClient: PdsClient | undefined; 94 + if (config.OAUTH_ENABLED) { 95 + oauthClientManager = await createOAuthClient(db, config); 96 + if (config.DID) { 97 + pdsClient = new PdsClient(oauthClientManager.client, config.DID); 98 + } 99 + } 100 + 89 101 // Determine if we have DIDs to replicate (from config and/or policies) 90 102 const hasReplicateDids = 91 103 config.REPLICATE_DIDS.length > 0 || ··· 105 117 undefined, 106 118 undefined, 107 119 policyEngine, 120 + pdsClient, 108 121 ); 109 122 replicatedRepoReader = new ReplicatedRepoReader( 110 123 ipfsService, ··· 129 142 replicatedRepoReader, 130 143 repoManager, 131 144 rateLimiter, 145 + oauthClientManager, 146 + pdsClient, 132 147 ); 133 148 134 149 // Create HTTP server using @hono/node-server's request listener ··· 209 224 console.log(pc.dim(` Handle: @${config.HANDLE}`)); 210 225 } 211 226 console.log(pc.dim(` Data: ${dataDir}`)); 227 + if (oauthClientManager) { 228 + if (pdsClient && await pdsClient.hasSession().catch(() => false)) { 229 + console.log(pc.dim(` OAuth: session active for ${config.DID}`)); 230 + } else { 231 + console.log(pc.dim(` OAuth: enabled (no active session)`)); 232 + } 233 + } 212 234 213 235 // Start IPFS after HTTP server is listening (IPFS startup can be slow) 214 236 if (ipfsService) {
+1
src/xrpc/admin-e2e.test.ts
··· 53 53 RATE_LIMIT_CHALLENGE_PER_MIN: 20, 54 54 RATE_LIMIT_MAX_CONNECTIONS: 100, 55 55 RATE_LIMIT_FIREHOSE_PER_IP: 3, 56 + OAUTH_ENABLED: false, 56 57 }; 57 58 } 58 59
+1
src/xrpc/admin.test.ts
··· 39 39 RATE_LIMIT_CHALLENGE_PER_MIN: 20, 40 40 RATE_LIMIT_MAX_CONNECTIONS: 100, 41 41 RATE_LIMIT_FIREHOSE_PER_IP: 3, 42 + OAUTH_ENABLED: false, 42 43 }; 43 44 } 44 45
+37
src/xrpc/admin.ts
··· 245 245 <div id="overview-content" class="loading">Loading...</div> 246 246 </section> 247 247 248 + <section class="card" id="section-account"> 249 + <h2>Account Connection</h2> 250 + <div id="account-content" class="loading">Loading...</div> 251 + </section> 252 + 248 253 <section class="card" id="section-metrics"> 249 254 <h2>Replication Summary</h2> 250 255 <div id="metrics-content" class="loading">Loading...</div> ··· 524 529 el.innerHTML = html; 525 530 } 526 531 532 + async function refreshAccount() { 533 + var el = document.getElementById("account-content"); 534 + try { 535 + var res = await fetch("/oauth/status"); 536 + if (!res.ok) { el.innerHTML = '<span style="color:#999">OAuth not enabled</span>'; return; } 537 + var data = await res.json(); 538 + if (data.authenticated) { 539 + el.innerHTML = '<dl class="kv">' 540 + + '<dt>Status</dt><dd><span class="dot dot-synced"></span>Connected</dd>' 541 + + '<dt>DID</dt><dd>' + esc(data.did) + '</dd>' 542 + + '</dl>'; 543 + } else { 544 + el.innerHTML = '<div class="add-did-form">' 545 + + '<input type="text" id="oauth-handle" placeholder="handle (e.g. alice.bsky.social)" autocomplete="off">' 546 + + '<button id="oauth-connect-btn">Connect Account</button>' 547 + + '</div>' 548 + + '<div style="font-size:0.8rem;color:#666;margin-top:0.3rem">Authenticate with your AT Protocol account to publish records to your PDS.</div>'; 549 + document.getElementById("oauth-connect-btn").addEventListener("click", function() { 550 + var handle = document.getElementById("oauth-handle").value.trim(); 551 + if (!handle) return; 552 + window.location.href = "/oauth/login?handle=" + encodeURIComponent(handle); 553 + }); 554 + document.getElementById("oauth-handle").addEventListener("keydown", function(e) { 555 + if (e.key === "Enter") document.getElementById("oauth-connect-btn").click(); 556 + }); 557 + } 558 + } catch (e) { 559 + el.innerHTML = '<span style="color:#999">OAuth not enabled</span>'; 560 + } 561 + } 562 + 527 563 async function refresh() { 528 564 try { 529 565 const [overview, network, policies, syncHistory] = await Promise.all([ ··· 533 569 apiFetch("org.p2pds.admin.getSyncHistory", { limit: "20" }), 534 570 ]); 535 571 renderOverview(overview); 572 + refreshAccount(); 536 573 renderMetrics(overview); 537 574 renderReplication(overview); 538 575 renderSyncHistory(syncHistory);