Sync reading position from Moon Reader app to Bookhive atproto records
atproto bookhive ereader moonreader
3
fork

Configure Feed

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

Resolve PDS from handle; make PDS env optional

New atproto/identity.py does handle → DID → PDS in two HTTPS hops
(public bsky appview + PLC directory, with did:web fallback). Config
no longer requires PDS; ATProtoClient resolves it lazily inside
_ensure_session under the existing session lock, so the first auth
call pays the cost and subsequent requests see the cached value.
No new deps.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

+166 -9
+5 -2
.env.example
··· 1 - # ATProto — the identity spacebee writes to 2 - PDS=bsky.social 1 + # ATProto — the identity spacebee writes to. 2 + # PDS is optional: if unset, it's resolved from BSKY_HANDLE at startup. 3 + # Set it explicitly only if you want to skip the resolve (or pin to a 4 + # specific PDS that isn't what the handle points to). 5 + # PDS=bsky.social 3 6 BSKY_HANDLE=you.bsky.social 4 7 BSKY_APP_PASSWORD=xxxx-xxxx-xxxx-xxxx 5 8
+1 -1
README.md
··· 40 40 41 41 | Var | Purpose | 42 42 | --- | --- | 43 - | `PDS` | Your atproto PDS host (e.g. `bsky.social`) | 44 43 | `BSKY_HANDLE` | The handle spacebee writes records as | 45 44 | `BSKY_APP_PASSWORD` | An [app password] for that handle | 45 + | `PDS` | *Optional.* PDS host (e.g. `bsky.social`). If unset, resolved from the handle at first use via the public bsky appview + PLC directory. | 46 46 | `DAV_USER` / `DAV_PASSWORD` | Basic-auth credentials Moon+ Reader will send | 47 47 | `PASSTHROUGH_ROOT` | Local-disk scratch dir for non-`.po` paths | 48 48
+11 -2
src/spacebee/atproto/client.py
··· 12 12 13 13 import httpx 14 14 15 + from . import identity 16 + 15 17 log = logging.getLogger(__name__) 16 18 17 19 TIMEOUT = 15.0 ··· 25 27 26 28 27 29 class ATProtoClient: 28 - def __init__(self, pds: str, handle: str, app_password: str) -> None: 29 - self._pds = pds.rstrip("/") 30 + def __init__(self, pds: str | None, handle: str, app_password: str) -> None: 31 + # `pds` may be None — in that case it's resolved from the handle the 32 + # first time we authenticate. Lazy so config loading stays synchronous 33 + # and offline-friendly. 34 + self._pds: str | None = pds.rstrip("/") if pds else None 30 35 self._handle = handle 31 36 self._app_password = app_password 32 37 self._session: Session | None = None ··· 35 40 36 41 @property 37 42 def pds_url(self) -> str: 43 + if self._pds is None: 44 + raise RuntimeError("PDS not resolved yet — call an authenticated method first") 38 45 return f"https://{self._pds}" 39 46 40 47 @property ··· 67 74 if self._session is not None: 68 75 return self._session 69 76 async with self._lock: 77 + if self._pds is None: 78 + self._pds = await identity.resolve_pds(self._http, self._handle) 70 79 if self._session is None: 71 80 self._session = await self._create_session() 72 81 return self._session
+64
src/spacebee/atproto/identity.py
··· 1 + """Resolve an atproto handle to its PDS hostname. 2 + 3 + Two hops, both plain HTTPS (no DNS-TXT lookup, so no extra deps): 4 + 5 + handle ──(public bsky appview)──▶ DID ──(PLC directory / did:web doc)──▶ PDS 6 + 7 + Called lazily from `ATProtoClient._ensure_session()` when `PDS` env is unset, 8 + so users only need to configure their handle + app password. 9 + """ 10 + 11 + from __future__ import annotations 12 + 13 + import logging 14 + 15 + import httpx 16 + 17 + log = logging.getLogger(__name__) 18 + 19 + # Public, unauthenticated endpoints. Same appview we already hit for profiles. 20 + _RESOLVE_HANDLE_URL = "https://public.api.bsky.app/xrpc/com.atproto.identity.resolveHandle" 21 + _PLC_DIRECTORY_URL = "https://plc.directory" 22 + 23 + 24 + async def resolve_pds(http: httpx.AsyncClient, handle: str) -> str: 25 + """Return the PDS hostname (no scheme, no trailing slash) for the handle.""" 26 + did = await _resolve_handle(http, handle) 27 + doc = await _fetch_did_doc(http, did) 28 + endpoint = _pds_from_did_doc(doc, did) 29 + host = endpoint.removeprefix("https://").removeprefix("http://").rstrip("/") 30 + log.info("Resolved %s → DID %s → PDS %s", handle, did, host) 31 + return host 32 + 33 + 34 + async def _resolve_handle(http: httpx.AsyncClient, handle: str) -> str: 35 + resp = await http.get(_RESOLVE_HANDLE_URL, params={"handle": handle}) 36 + resp.raise_for_status() 37 + return resp.json()["did"] 38 + 39 + 40 + async def _fetch_did_doc(http: httpx.AsyncClient, did: str) -> dict: 41 + if did.startswith("did:plc:"): 42 + url = f"{_PLC_DIRECTORY_URL}/{did}" 43 + elif did.startswith("did:web:"): 44 + # did:web:example.com → https://example.com/.well-known/did.json 45 + # did:web:example.com:u:alice → https://example.com/u/alice/did.json 46 + parts = did.removeprefix("did:web:").split(":") 47 + url = ( 48 + f"https://{parts[0]}/.well-known/did.json" 49 + if len(parts) == 1 50 + else f"https://{'/'.join(parts)}/did.json" 51 + ) 52 + else: 53 + raise RuntimeError(f"Unsupported DID method for PDS resolution: {did}") 54 + 55 + resp = await http.get(url) 56 + resp.raise_for_status() 57 + return resp.json() 58 + 59 + 60 + def _pds_from_did_doc(doc: dict, did: str) -> str: 61 + for svc in doc.get("service") or []: 62 + if svc.get("type") == "AtprotoPersonalDataServer" and svc.get("serviceEndpoint"): 63 + return svc["serviceEndpoint"] 64 + raise RuntimeError(f"No AtprotoPersonalDataServer service entry in DID doc for {did}")
+5 -4
src/spacebee/config.py
··· 10 10 11 11 @dataclass(frozen=True) 12 12 class Config: 13 - # ATProto identity spacebee writes to 14 - pds: str 13 + # ATProto identity spacebee writes to. 14 + # `pds` is optional — when unset (or empty), ATProtoClient resolves it 15 + # from `bsky_handle` at first use via the public bsky appview + PLC. 16 + pds: str | None 15 17 bsky_handle: str 16 18 bsky_app_password: str 17 19 ··· 28 30 def load() -> Config: 29 31 load_dotenv() 30 32 required = [ 31 - "PDS", 32 33 "BSKY_HANDLE", 33 34 "BSKY_APP_PASSWORD", 34 35 "DAV_USER", ··· 39 40 raise RuntimeError(f"Missing required env vars: {', '.join(missing)}") 40 41 41 42 return Config( 42 - pds=os.environ["PDS"], 43 + pds=os.environ.get("PDS") or None, 43 44 bsky_handle=os.environ["BSKY_HANDLE"], 44 45 bsky_app_password=os.environ["BSKY_APP_PASSWORD"], 45 46 dav_user=os.environ["DAV_USER"],
+80
tests/test_identity.py
··· 1 + """Handle → DID → PDS resolution.""" 2 + 3 + from __future__ import annotations 4 + 5 + import httpx 6 + import pytest 7 + import respx 8 + 9 + from spacebee.atproto import identity 10 + 11 + 12 + @pytest.fixture 13 + async def http(): 14 + async with httpx.AsyncClient() as client: 15 + yield client 16 + 17 + 18 + @respx.mock 19 + async def test_resolve_plc_handle(http): 20 + respx.get( 21 + "https://public.api.bsky.app/xrpc/com.atproto.identity.resolveHandle", 22 + params={"handle": "alice.bsky.social"}, 23 + ).mock(return_value=httpx.Response(200, json={"did": "did:plc:abc123"})) 24 + respx.get("https://plc.directory/did:plc:abc123").mock( 25 + return_value=httpx.Response( 26 + 200, 27 + json={ 28 + "id": "did:plc:abc123", 29 + "service": [ 30 + { 31 + "id": "#atproto_pds", 32 + "type": "AtprotoPersonalDataServer", 33 + "serviceEndpoint": "https://morel.us-east.host.bsky.network", 34 + } 35 + ], 36 + }, 37 + ) 38 + ) 39 + 40 + host = await identity.resolve_pds(http, "alice.bsky.social") 41 + assert host == "morel.us-east.host.bsky.network" 42 + 43 + 44 + @respx.mock 45 + async def test_resolve_did_web_root(http): 46 + respx.get( 47 + "https://public.api.bsky.app/xrpc/com.atproto.identity.resolveHandle", 48 + params={"handle": "alice.example.com"}, 49 + ).mock(return_value=httpx.Response(200, json={"did": "did:web:example.com"})) 50 + respx.get("https://example.com/.well-known/did.json").mock( 51 + return_value=httpx.Response( 52 + 200, 53 + json={ 54 + "service": [ 55 + { 56 + "id": "#atproto_pds", 57 + "type": "AtprotoPersonalDataServer", 58 + "serviceEndpoint": "https://pds.example.com/", 59 + } 60 + ] 61 + }, 62 + ) 63 + ) 64 + 65 + host = await identity.resolve_pds(http, "alice.example.com") 66 + assert host == "pds.example.com" 67 + 68 + 69 + @respx.mock 70 + async def test_missing_pds_service_raises(http): 71 + respx.get( 72 + "https://public.api.bsky.app/xrpc/com.atproto.identity.resolveHandle", 73 + params={"handle": "alice.bsky.social"}, 74 + ).mock(return_value=httpx.Response(200, json={"did": "did:plc:abc"})) 75 + respx.get("https://plc.directory/did:plc:abc").mock( 76 + return_value=httpx.Response(200, json={"service": []}) 77 + ) 78 + 79 + with pytest.raises(RuntimeError, match="No AtprotoPersonalDataServer"): 80 + await identity.resolve_pds(http, "alice.bsky.social")