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
@@ -67,13 +67,14 @@ impl DidHandleResolver for StubResolver {
}
}
/// Build a worker whose PLC and web resolvers are both the same stub.
/// The integration tests in this file don't care which method the
/// DID uses — the stub answers for any prefix.
/// Build a worker whose PDS, PLC and web resolvers are all the same
/// stub. The integration tests in this file don't care which method
/// the DID uses — the stub answers for any prefix.
fn worker_with(db: sqlx::PgPool, stub: Arc<StubResolver>) -> HandleSyncWorker {
let r: Arc<dyn DidHandleResolver> = stub;
HandleSyncWorker {
db,
pds_resolver: Arc::clone(&r),
plc_resolver: Arc::clone(&r),
web_resolver: Arc::clone(&r),
interval_secs: 999,
@@ -389,6 +390,7 @@ async fn sync_resolves_did_web_via_web_resolver() {
let worker = HandleSyncWorker {
db: db.clone(),
pds_resolver: Arc::clone(&plc_arc),
plc_resolver: plc_arc,
web_resolver: web_arc,
interval_secs: 999,
@@ -442,6 +444,7 @@ async fn sync_resolves_did_plc_via_plc_resolver() {
let worker = HandleSyncWorker {
db: db.clone(),
pds_resolver: Arc::clone(&plc_arc),
plc_resolver: plc_arc,
web_resolver: web_arc,
interval_secs: 999,