feat(at-identity): PdsHandleResolver for cluster-local DID→handle resolution

The AppView's handle_sync worker consulted the public PLC directory
and the did:web: HTTPS resolver only. DIDs hosted on the local PDS
(notably did🔑 users and any other operator-hosted method)
weren't reachable without an external round trip, and unresolvable
DIDs (did🔑 not on this PDS, did:foo: anything) blocked the
100-row batch forever because did🔑 sorts lexicographically
before did:plc: / did:web:.

This commit adds:

* `PdsHandleResolver` (at-identity) — POSTs the DID as the
  `handle` field to the PDS's resolveHandle XRPC method. The PDS
  now recognises a `did:` prefix and does a PK lookup on
  `users.did`, returning `{did, handle}`. The resolver reads
  the `handle` field, so the AppView finally gets a real local
  handle for did🔑 users without ever dialing plc.directory.
* A 2 s timeout per request (was 10 s) and `DISPATCH_CONCURRENCY =
  8` so the worker caps a 100-DID batch at ~2 s with parallel
  dispatch instead of the ~17 min worst case the old serial + 10 s
  setup allowed.
* A new `posts.handle_sync_attempted_at` column (migration 0006)
  and `mark_attempted()` helper. The SELECT filter excludes rows
  attempted within the last hour, so an unresolvable DID dominates
  at most one batch before the worker advances. Cleared on success.
* `PDS_INTERNAL_URL` config so the AppView can reach the PDS via
  a cluster-internal hostname when the public URL isn't routable
  from inside the cluster.

Tests:
* `crates/at-identity/src/pds_handle.rs` — 4 unit tests against a
  stub HTTP server (200/404/5xx/missing-did-field).
* Existing handle_sync integration tests updated to wire in the
  new `pds_resolver` field.
This commit is contained in:
tomdebone
2026-07-18 17:56:11 +02:00
parent 391448a845
commit 3aa5d5c0e3
11 changed files with 378 additions and 27 deletions
+35 -7
View File
@@ -9,17 +9,42 @@ pub async fn resolve_handle(
State(state): State<AppState>,
Json(req): Json<ResolveHandleReq>,
) -> Result<Json<ResolveHandleResp>, (StatusCode, Json<crate::routes::types::ErrorBody>)> {
// Polymorphic input: `handle` may be a bare handle OR a DID
// (`did:plc:…`, `did:web:…`, `did:key:…`). When it's a DID we
// look the row up by `did` — the AppView's `PdsHandleResolver`
// uses this path to fill `posts.handle` for local-PDS users
// (including `did:key:`) without a second round-trip to plc.directory.
if req.handle.starts_with("did:") {
let row: Option<(String,)> =
sqlx::query_as("SELECT handle FROM users WHERE did = $1")
.bind(&req.handle)
.fetch_optional(&state.db)
.await
.map_err(|e| err(StatusCode::INTERNAL_SERVER_ERROR, e))?;
if let Some((handle,)) = row {
return Ok(Json(ResolveHandleResp {
did: req.handle.clone(),
handle: Some(handle),
}));
}
// DID not hosted here — fall through to the handle lookup
// (returns 404 below).
}
if let Some(zone) = state.cfg.pds_handle_dns_zone.strip_prefix(".") {
if let Some(stripped) = req.handle.strip_suffix(zone) {
let user = stripped.trim_end_matches('.');
let full = format!("{}{}", user, state.cfg.pds_handle_dns_zone);
let row: Option<(String,)> = sqlx::query_as("SELECT did FROM users WHERE handle = $1")
.bind(&full)
.fetch_optional(&state.db)
.await
.map_err(|e| err(StatusCode::INTERNAL_SERVER_ERROR, e))?;
let row: Option<(String,)> =
sqlx::query_as("SELECT did FROM users WHERE handle = $1")
.bind(&full)
.fetch_optional(&state.db)
.await
.map_err(|e| err(StatusCode::INTERNAL_SERVER_ERROR, e))?;
if let Some((did,)) = row {
return Ok(Json(ResolveHandleResp { did }));
return Ok(Json(ResolveHandleResp {
did,
handle: Some(full),
}));
}
}
}
@@ -29,7 +54,10 @@ pub async fn resolve_handle(
.await
.map_err(|e| err(StatusCode::INTERNAL_SERVER_ERROR, e))?;
match row {
Some((did,)) => Ok(Json(ResolveHandleResp { did })),
Some((did,)) => Ok(Json(ResolveHandleResp {
did,
handle: Some(req.handle.clone()),
})),
None => {
warn!(handle = %req.handle, "handle not found");
Err(err(
+7
View File
@@ -62,6 +62,13 @@ pub struct ResolveHandleReq {
#[derive(Debug, Serialize)]
pub struct ResolveHandleResp {
pub did: String,
/// Always populated when the lookup succeeds. For
/// handle → DID calls this is just the input echo; for
/// DID → handle calls this is the resolved local handle
/// (used by the AppView's `PdsHandleResolver` to fill
/// `posts.handle` without a second round-trip).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub handle: Option<String>,
}
#[derive(Debug, Deserialize)]